diff --git a/src/App.tsx b/src/App.tsx index 832e1837..51e4e598 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,6 +47,7 @@ import ProtectedRoute from "./router/ProtectedRoute"; import { ROLE } from "./utils/interfaces"; import AssignReviewer from "./pages/Assignments/AssignReviewer"; import StudentTasks from "./pages/StudentTasks/StudentTasks"; +import StudentTaskDetail from "pages/StudentTasks/StudentTaskDetail"; import StudentTeams from "./pages/Student Teams/StudentTeamView"; import StudentTeamView from "./pages/Student Teams/StudentTeamView"; import NewTeammateAdvertisement from './pages/Student Teams/NewTeammateAdvertisement'; @@ -75,7 +76,12 @@ function App() { path: "edit-questionnaire", element: } />, }, - + { + path: "student_task_detail/:id", + element: ( + } leastPrivilegeRole={ROLE.STUDENT} /> + ), + }, { path: "assignments/edit/:id", element: , diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index a39ba44f..fe70d45f 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,53 +1,58 @@ import { ColumnDef, ColumnFiltersState, - ExpandedState, flexRender, getCoreRowModel, - getExpandedRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, SortingState, useReactTable, + ExpandedState, + getExpandedRowModel, } from "@tanstack/react-table"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Table as BTable, Col, Container, Row } from "react-bootstrap"; -import { BsChevronDown, BsChevronRight } from "react-icons/bs"; -import { FaSearch } from "react-icons/fa"; +import { Col, Container, Row, Table as BTable } from "react-bootstrap"; +import { BsChevronRight, BsChevronDown } from "react-icons/bs"; import ColumnFilter from "./ColumnFilter"; import GlobalFilter from "./GlobalFilter"; import Pagination from "./Pagination"; import RowSelectCheckBox from "./RowSelectCheckBox"; +import { FaSearch } from "react-icons/fa"; +import ToolTip from "components/ToolTip"; interface TableProps { data: Record[]; columns: ColumnDef[]; - disableGlobalFilter?: boolean; showGlobalFilter?: boolean; showColumnFilter?: boolean; showPagination?: boolean; tableSize?: { span: number; offset: number }; columnVisibility?: Record; onSelectionChange?: (selectedData: Record[]) => void; - onRowClick?: (row: any) => void; renderSubComponent?: (props: { row: any }) => React.ReactNode; getRowCanExpand?: (row: any) => boolean; + disableGlobalFilter?: boolean; + // Maps column header text to a tooltip string shown as an info icon in the header. + headingComments?: Record; + // When true, passes fluid to Container so the table spans the full viewport width. + fluid?: boolean; } const Table: React.FC = ({ data: initialData, columns, - disableGlobalFilter = false, showGlobalFilter = false, showColumnFilter = true, showPagination = true, onSelectionChange, - onRowClick, columnVisibility = {}, tableSize = { span: 12, offset: 0 }, renderSubComponent, getRowCanExpand, + disableGlobalFilter = false, // Disable the Global Search + headingComments = {}, + fluid = false, }) => { const [rowSelection, setRowSelection] = useState({}); const [sorting, setSorting] = useState([]); @@ -82,7 +87,6 @@ const Table: React.FC = ({ ); }, - size: 40, enableSorting: false, enableColumnFilter: false, }; @@ -110,7 +114,6 @@ const Table: React.FC = ({ }} /> ), - size: 40, enableSorting: false, enableFilter: false, }, @@ -145,12 +148,6 @@ const Table: React.FC = ({ getExpandedRowModel: getExpandedRowModel(), }); - - //Enable search filters for columns based on page size - const totalItems = initialData.length; - const pageSize = table.getState().pagination.pageSize; - const shouldShowColumnFilters = showColumnFilter && totalItems > pageSize; - const flatRows = table.getSelectedRowModel().flatRows; useEffect(() => { @@ -175,76 +172,72 @@ const Table: React.FC = ({ return ( <> - {!disableGlobalFilter && ( - - - - {isGlobalFilterVisible && ( - - )} - - {/* + + + + {isGlobalFilterVisible && ( + + )} + + {!disableGlobalFilter && ( + {isGlobalFilterVisible ? " Hide" : " Show"} - */} - - - )} - - + + )} + + + + - + {table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder ? null : ( - <> -
- {flexRender(header.column.columnDef.header, header.getContext())} - {{ - asc: " 🔼", - desc: " 🔽", - }[header.column.getIsSorted() as string] ?? null} -
- {shouldShowColumnFilters && header.column.getCanFilter() ? ( - - ) : null} - - )} - - ))} + {headerGroup.headers.map((header) => { + // Add info icon to Heading if comment exists. + const comment = headingComments[header.column.columnDef.header as string]; + return ( + + {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {comment && } + {{ + asc: " 🔼", + desc: " 🔽", + }[header.column.getIsSorted() as string] ?? null} +
+ {/* Previously hidden when data fit a single page; now always driven by the showColumnFilter prop. */} + {showColumnFilter && header.column.getCanFilter() ? ( + + ) : null} + + )} + + ); + })} ))} {table.getRowModel().rows.map((row) => ( - onRowClick?.(row.original)} - style={{ - cursor: onRowClick ? 'pointer' : 'default', - backgroundColor: row.original.isSelected ? '#fff3cd' : undefined - }} - > - {row.getVisibleCells().map((cell) => { - const selected = !!row.original.isSelected; - return ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ); - })} - + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + {row.getIsExpanded() && renderSubComponent && ( @@ -266,7 +259,8 @@ const Table: React.FC = ({ setPageSize={table.setPageSize} getPageCount={table.getPageCount} getState={table.getState} - totalItems={initialData.length} + // Use pre-pagination row count so the total reflects active filters, not raw data length. + totalItems={table.getPrePaginationRowModel().rows.length} /> )} diff --git a/src/css-modules.d.ts b/src/css-modules.d.ts index 96ac9c7e..1f64335c 100644 --- a/src/css-modules.d.ts +++ b/src/css-modules.d.ts @@ -1,4 +1,25 @@ +/** + * Type declarations for CSS Modules. + * + * Vite processes any file named *.module.css or *.module.scss as a CSS Module, + * hashing all local class names so they are scoped to the importing component. + * These declarations tell TypeScript that `import styles from "*.module.css/scss"` + * returns a plain object mapping original class names to their hashed equivalents, + * e.g. styles.tbl_heat → "tbl_heat_a1b2c3". + * + * Classes that must remain globally named (used as plain strings by components that + * don't import the module) should be wrapped in `:global { }` inside the SCSS file. + * + * Note: do NOT add `declare module "*.scss"` or `declare module "*.css"` here — + * those blanket declarations would allow side-effect imports of non-module files + * without type checking and mask missing-file errors. + */ declare module "*.module.css" { const classes: { readonly [key: string]: string }; export default classes; } + +declare module "*.module.scss" { + const classes: { readonly [key: string]: string }; + export default classes; +} diff --git a/src/pages/ReviewTableau/ScoreWidgets.tsx b/src/pages/ReviewTableau/ScoreWidgets.tsx index 7a255c1b..061d9d98 100644 --- a/src/pages/ReviewTableau/ScoreWidgets.tsx +++ b/src/pages/ReviewTableau/ScoreWidgets.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { getColorClass } from '../ViewTeamGrades/utils'; +import { getColorClass } from '../ViewTeamGrades/heatgridUtils'; import { ScoreWidgetProps } from '../../types/reviewTableau'; -import '../ViewTeamGrades/grades.scss'; /** * Reusable circular score widget that matches the design used in ViewTeamGrades diff --git a/src/pages/StudentTasks/StudentTaskDetail.module.css b/src/pages/StudentTasks/StudentTaskDetail.module.css new file mode 100644 index 00000000..4b1236e1 --- /dev/null +++ b/src/pages/StudentTasks/StudentTaskDetail.module.css @@ -0,0 +1,185 @@ +.container { + font-family: Arial, sans-serif; + padding: 1.5rem 2.5rem; + max-width: 1200px; + margin: 0 auto; +} + +/* Header Section */ +.header { + border-bottom: 2px solid #e0e0e0; + padding-bottom: 1rem; + margin-bottom: 1.5rem; + overflow: hidden; +} + +.header h1 { + font-size: 22px; + font-weight: 600; + color: #333; + margin: 0; +} + +.programLink { + color: #333; + text-decoration: none; +} + +.programLink:hover { + text-decoration: underline; +} + +/* Email link */ +.link_to_right { + float: right; + font-size: 13px; +} + +/* Task Links Section */ +.taskLinks { + background-color: #fafafa; + border: 1px solid #e8e8e8; + border-radius: 6px; + padding: 1rem 1.5rem; + margin-bottom: 2rem; +} + +.taskList { + list-style: none; + padding: 0; + margin: 0; +} + +.taskItem { + font-size: 16px; + line-height: 1.5; + border-bottom: 1px solid #f0f0f0; + padding: 4px 0; +} + +.taskItem:last-child { + border-bottom: none; +} + +.taskDescription { + color: #666; + font-size: 15px; +} + +/* Timeline Section */ +.timelineContainer { + margin: 20px 0; + background-color: #fafafa; + border: 1px solid #e8e8e8; + border-radius: 6px; + padding: 2rem; +} + +.timelineDates { + display: flex; + justify-content: space-between; + font-size: 14px; + font-weight: 500; + color: #333; + align-items: center; + margin-bottom: 12px; +} + +.timelineVisual { + position: relative; + height: 30px; + margin: 15px 0; +} + +.timelineLine { + position: absolute; + top: 50%; + left: 0; + right: 0; + background-color: #dc3545; + transform: translateY(-50%); + height: 4px; + width: 100%; +} + +.timelineDots { + display: flex; + justify-content: space-between; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 100%; +} + +.dot { + width: 25px; + height: 25px; + background-color: #dc3545; + border-radius: 50%; + margin-top: 7px; +} + +.timelineDeadlines { + display: flex; + justify-content: space-between; + margin-top: 20px; +} + +.deadlineLink { + color: #986633; + text-decoration: none; + font-weight: 600; + text-align: center; + flex: 1; + font-size: 15px; +} + +/* Send Email button */ +.emailButton { + position: absolute; + top: calc(1rem + 10px); + transform: translateY(-50%); + right: 3rem; + background-color: #A90201; + color: white; + border: none; + border-radius: 4px; + padding: 6px 14px; + font-size: 14px; + cursor: pointer; + text-decoration: none; +} + +.emailButton:hover { + background-color: #870100; + color: white; + text-decoration: none; +} + +.deadlineLink:hover { + text-decoration: underline; + color: #7a4f1f; +} + +/* Footer Section */ +.footer { + display: flex; + justify-content: center; + padding: 20px 0; + border-top: 1px solid #e0e0e0; + margin-top: 2rem; + gap: 20px; +} + +/* Clickable link styling */ +.clickableLink { + color: #986633; + text-decoration: none; +} + +.clickableLink:hover { + color: #000000; + text-decoration: underline; +} + diff --git a/src/pages/StudentTasks/StudentTaskDetail.tsx b/src/pages/StudentTasks/StudentTaskDetail.tsx new file mode 100644 index 00000000..baa6f905 --- /dev/null +++ b/src/pages/StudentTasks/StudentTaskDetail.tsx @@ -0,0 +1,350 @@ +/** + * StudentTaskDetail — the per-assignment detail page reached by clicking an assignment + * name in the StudentTasks dashboard. + * + * Two data sources are combined: + * - router state (location.state): summary fields (assignment name, currentStage, etc.) + * passed via Link state= in StudentTasks. These are available immediately, so the page + * renders a meaningful header before the API responds. + * - GET /student_tasks/show/:participantId: full task data including due_dates[]. This + * is always fetched — router state deliberately omits due_dates to avoid serialising + * large arrays into navigation history. + * + * Timeline rendering: + * - due_dates are sorted by date and displayed as three aligned rows: + * Row 1: formatted date + time labels + * Row 2: visual track line with coloured nodes (completed=red filled, current=pulsing, + * pending=grey outline) + * Row 3: deadline names, linked to /responses/:id for submitted responses + * - progressPercent drives a CSS linear-gradient on the track line so the red portion + * advances to the midpoint of the current stage node. + * - Date parsing handles both ISO (YYYY-MM-DD) and legacy dd-mm-yyyy formats from the API. + * + * "Your feedbacks" link navigates to /view-team-grades?assignmentId=X, using the + * assignmentId passed via router state (participant.parent_id on the backend). + */ +import React, { useState, useEffect, useMemo } from "react"; +import { useParams, Link, useLocation, useNavigate } from "react-router-dom"; +import styles from "./StudentTaskDetail.module.css"; +import axiosClient from "utils/axios_client"; + +interface DueDates { + id: number | null; + type: number; + name: string; + date: string; + round: number | null; +} + +interface TaskData { + assignment: string; + badges: boolean; + course: string; + currentStage: string; + due_dates: DueDates[]; + id: number; + publishingRights: boolean; + reviewGrade: string; + stageDeadline: string; + topic: string; +} + +interface StateData { + task: TaskData; + assignmentId?: number; +} + +const StudentTaskDetail: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const location = useLocation(); + const navigate = useNavigate(); + const stateData = location.state as StateData; + + // 1. Establish a single, consistent baseline instance for "today" + const today = useMemo(() => new Date(), []); + + // 2. Always fetch full task data from API (router state lacks due_dates) + const [apiData, setApiData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchTaskDetails = async () => { + try { + setIsLoading(true); + const response = await axiosClient.get(`/student_tasks/show/${id}`); + setApiData(response.data); + } catch (error: any) { + if (error?.response?.status === 403 || error?.response?.status === 404) { + navigate("/"); + return; + } + console.error("Error fetching student task details:", error); + } finally { + setIsLoading(false); + } + }; + + fetchTaskDetails(); + }, [id]); + + // Permissions from the participant object in the API response + const canSubmit = apiData?.participant?.can_submit !== false; + const canReview = apiData?.participant?.can_review !== false; + + // Router state has camelCase summary fields; API has snake_case + due_dates + const assignment = stateData?.task?.assignment || apiData?.assignment || "Unknown Assignment"; + const current_stage = stateData?.task?.currentStage || apiData?.current_stage || "Not Started"; + + // Parse due_dates from the API response. The backend may return dates in either + // ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ) or legacy dd-mm-yyyy HH:MM:SS format. + // Only the legacy format is reformatted — ISO strings already parse correctly in all browsers. + const due_dates: DueDates[] = useMemo(() => { + return (apiData?.due_dates || []).map((due: any) => { + let safeDateString = due.date; + + // Only reformat dd-mm-yyyy strings; skip ISO dates (which already start YYYY-) + if (due.date && due.date.includes('-') && !/^\d{4}-/.test(due.date)) { + const [datePart, timePart] = due.date.split(' '); + const [day, month, year] = datePart.split('-'); + safeDateString = `${year}-${month}-${day}T${timePart}Z`; + } + + return { + id: due.id ?? null, + type: due.type, + name: due.name || "Unknown Stage", + date: safeDateString || new Date().toISOString(), + round: due.round ?? null + }; + }); + }, [apiData?.due_dates]); + + /** + * Determines the visual status of a due-date node for the timeline. + * Primary path: find the index of the due_date whose name matches current_stage + * (e.g. "submission" matches "Submission deadline"). Nodes before it are "completed", + * the matching node is "current", nodes after are "pending". + * Fallback (when no name match): treat the first future due_date as "current" and + * everything before it as "completed". This handles stages like "In progress" that + * don't map directly to a due_date name. + */ + const getStageStatus = (index: number): "completed" | "current" | "pending" => { + const currentStageIndex = due_dates.findIndex(due_date => + due_date.name.toLowerCase().includes(current_stage.toLowerCase()) + ); + + // Fallback: If "In progress" doesn't strictly string-match "Submission" or "Review" + if (currentStageIndex === -1) { + const deadlineDate = new Date(due_dates[index].date); + if (deadlineDate > today) { + const firstFutureIndex = due_dates.findIndex(d => new Date(d.date) > today); + return index === firstFutureIndex ? "current" : "pending"; + } + return "completed"; + } + + if (index < currentStageIndex) return "completed"; + if (index === currentStageIndex) return "current"; + return "pending"; + }; + + if (isLoading) { + return
Loading task details...
; + } + + // Compute how far along the timeline track line should be filled red. + // Uses step-based calculation (each node occupies 100/N %) rather than wall-clock time, + // so equally spaced nodes visually represent equal progress regardless of real duration. + // The active node's midpoint is used so the red line ends at the centre of the current dot. + const progressPercent = (() => { + const totalCount = due_dates.length; + if (totalCount === 0) return 0; + + const activeIndex = due_dates.findIndex((_, idx) => getStageStatus(idx) === "current"); + + if (activeIndex === -1) { + const allPast = due_dates.every(d => new Date(d.date) < today); + return allPast ? 100 : 0; + } + + const stepSize = 100 / totalCount; + return (activeIndex * stepSize) + (stepSize / 2); + })(); + + return ( +
+
+

+ Submit or Review work for{" "} + + {assignment} + +

+
+ + +
+ + Send Email To Reviewers + +
    +
  • + Your team + (View and manage your team) +
  • + {canSubmit && ( +
  • + Your work + (View your work) +
  • + )} + {canReview && ( +
  • + Others' work + (Give feedback to others on their work) +
  • + )} +
  • + Your feedbacks + (View scores and feedback on your work) +
  • +
  • + Change your handle + (Provide a different handle for this assignment) +
  • +
+
+ + {/* Unified Timeline Container Wrapper */} +
+
+ + {/* Row 1: Calendar Date Headings */} +
+ {due_dates.map((due_date: DueDates, index: number) => ( +
+ {(() => { + const d = new Date(due_date.date); + const datePart = d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }); + const timePart = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false }); + return `${datePart} ${timePart}`; + })()} +
+ ))} +
+ + {/* Row 2: Dynamic Visual Line Progression and Nodes */} +
+ + {/* Dynamic Colored Timeline Track Line (Overrides solid background with inline linear gradient) */} +
+ + {/* Nodes Stacked Directly on top of the track line */} +
+ {due_dates.map((due_date: DueDates, index: number) => { + const status = getStageStatus(index); + const isPast = status === "completed"; + const isCurrent = status === "current"; + + return ( +
+
+
+ ); + })} +
+
+ + {/* Row 3: Target Deadlines / Link Anchors */} +
+ {due_dates.map((due_date: DueDates, index: number) => { + const isNonClickable = due_date.id === null || due_date.id === undefined; + + // Base shared column block sizing styles + const containerStyles: React.CSSProperties = { + flex: 1, + textAlign: 'center', + minWidth: 0, + display: 'inline-block' + }; + + return isNonClickable ? ( + + {due_date.name} + + ) : ( +
+ + {due_date.name} + +
+ ); + })} +
+ +
+
+ +
+ + Back + +
+ +
+
+ + Help + + + Papers on Expertiza + +
+
+
+ ); +}; + +export default StudentTaskDetail; \ No newline at end of file diff --git a/src/pages/StudentTasks/StudentTasks.module.css b/src/pages/StudentTasks/StudentTasks.module.css new file mode 100644 index 00000000..ff6f4a21 --- /dev/null +++ b/src/pages/StudentTasks/StudentTasks.module.css @@ -0,0 +1,197 @@ +/* Base container styling */ +.container { + font-family: Arial, sans-serif; + margin: 20px; +} + +/* Header 1 styling */ +h1 { + text-align: left; + padding-top: 5px; + padding-left: 15px; + margin-bottom: 0px; + padding-bottom: 0px; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.428571429; + color: #333333; +} + +/* Table base styling */ +table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; +} + +/* Table, th, and td shared border styling */ +table, +th, +td { + border: 1px solid #ddd; +} + +/* Table header styling — #f0f0f0 matches the scores heatgrid header row */ +th { + background-color: #f0f0f0; + color: #333; + text-align: left; + padding: 8px; +} + +/* Table data cell styling */ +td { + padding: 8px; + text-align: left; +} + +/* First child of th and td border styling */ +th:first-child, +td:first-child { + border-left: none; +} + +/* Last child of th and td border styling */ +th:last-child, +td:last-child { + border-right: none; +} + +/* Table row striping — matches the scores heatgrid alternating row colours: + odd rows #fff (same as scores table first/odd rows), even rows #f5f5f5 */ +tr:nth-child(odd) { + background-color: #fff; +} + +tr:nth-child(even) { + background-color: #f5f5f5; +} + +/* Badge info icon and publishing rights checkbox styling */ +.badge-info-icon, +.publishing-rights-checkbox { + cursor: pointer; + margin-left: 5px; +} + +/* Checked state styling for publishing rights checkbox */ +.publishing-rights-checkbox:checked { + accent-color: #009688; +} + +/* Table header row font styling */ +thead tr { + font-weight: bold; +} + +/* Status indicator icons styling */ +.status-indicator { + color: #009688; + margin-left: 5px; +} + +/* Disabled state styling for checkboxes */ +input[type="checkbox"][disabled] { + opacity: 0.6; + cursor: not-allowed; +} + +/* Information icon styling in table */ +.info-icon { + font-style: normal; + color: #017bff; + cursor: help; +} + +/* Side information section styling */ +.side-info { + color: #333; + padding: 10px 0; +} + +/* Number styling in side information section */ +.side-info .number { + font-weight: bold; + color: #d9534f; /* Red color for the number badge */ +} + +/* Page layout styling */ +.pageLayout { + display: flex; + margin: 16px; +} + +/* Sidebar styling */ +.sidebar { + width: 250px; + margin-right: 20px; + padding-top: 20px; +} + +/* Main content area styling */ +.mainContent { + flex-grow: 1; + overflow: hidden; +} + +/* Header below pageLayout styling */ +.header { + margin-bottom: 20px; +} + +/* Tasks table styling */ +.tasksTable { + width: 100%; +} + +/* Page assignments styling */ +.assignments-page { + font-family: 'Arial', sans-serif; +} + +/* Title in assignments page styling */ +.assignments-title { + color: #333; + text-align: left; + padding: 20px; + font-size: 24px; + font-weight: bold; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + margin-bottom: 0; +} + +/* Styling for the course section headings */ +.courseTitle { + font-size: 1.5rem; + margin: 0 0 1rem 0; + padding-left: 8px; + font-weight: bold; + text-align: left; + color: #333; +} + +/* Footer section styling */ +.footer { + display: flex; + justify-content: center; + padding: 20px 0; +} + +/* Footer link styling */ +.footerLink { + margin: 0 10px; + color: #986633; + text-decoration: none; +} + +/* Footer link hover styling */ +.footerLink:hover { + color: #000000; + text-decoration: underline; +} + +/* Center checkbox in table data cell styling */ +.centerCheckbox { + text-align: center; + vertical-align: middle; +} diff --git a/src/pages/StudentTasks/StudentTasks.tsx b/src/pages/StudentTasks/StudentTasks.tsx index d1954d9a..96587699 100644 --- a/src/pages/StudentTasks/StudentTasks.tsx +++ b/src/pages/StudentTasks/StudentTasks.tsx @@ -1,460 +1,271 @@ -import React, { useEffect, useCallback, useMemo, useState } from "react"; -import { Container, Spinner, Alert, Row, Col } from "react-bootstrap"; -import { useParams } from "react-router-dom"; -import useAPI from "../../hooks/useAPI"; -import { useSelector } from "react-redux"; -import { RootState } from "../../store/store"; -import TopicsTable, { TopicRow } from "pages/Assignments/components/TopicsTable"; - -interface Topic { - id: string; - databaseId?: number; - name: string; - availableSlots: number; - waitlist: number; - isBookmarked?: boolean; - isSelected?: boolean; - isTaken?: boolean; - isWaitlisted?: boolean; -} - -const StudentTasks: React.FC = () => { - const { assignmentId } = useParams<{ assignmentId?: string }>(); - const { data: topicsResponse, error: topicsError, isLoading: topicsLoading, sendRequest: fetchTopicsAPI } = useAPI(); - const { data: assignmentResponse, sendRequest: fetchAssignment } = useAPI(); - const { data: signUpResponse, error: signUpError, sendRequest: signUpAPI } = useAPI(); - const { data: dropResponse, error: dropError, sendRequest: dropAPI } = useAPI(); - - const auth = useSelector((state: RootState) => state.authentication); - const currentUser = auth.user; - - const [bookmarkedTopics, setBookmarkedTopics] = useState>(new Set()); - // UI-selected topic override for instant icon/row updates - const [uiSelectedTopic, setUiSelectedTopic] = useState(null); - const [isSigningUp, setIsSigningUp] = useState(false); - const [optimisticSlotChanges, setOptimisticSlotChanges] = useState>(new Map()); - const [optimisticSelection, setOptimisticSelection] = useState>(new Map()); - const [pendingDeselections, setPendingDeselections] = useState>(new Set()); - const [lastSignedDbTopicId, setLastSignedDbTopicId] = useState(null); - - const fetchAssignmentData = useCallback(() => { - if (assignmentId) { - fetchAssignment({ url: `/assignments/${assignmentId}`, method: 'GET' }); - } else { - fetchAssignment({ url: `/assignments`, method: 'GET' }); - } - }, [assignmentId, fetchAssignment]); - - const fetchTopics = useCallback((assignmentId: number) => { - if (!assignmentId) return; - fetchTopicsAPI({ url: `/project_topics?assignment_id=${assignmentId}`, method: 'GET' }); - }, [fetchTopicsAPI]); - - useEffect(() => { - fetchAssignmentData(); - }, [fetchAssignmentData]); - - useEffect(() => { - if (assignmentResponse?.data) { - let targetAssignmentId: number; - if (assignmentId) { - targetAssignmentId = parseInt(assignmentId); - } else if (Array.isArray(assignmentResponse.data) && assignmentResponse.data.length > 0) { - targetAssignmentId = assignmentResponse.data[0].id; - } else { - targetAssignmentId = assignmentResponse.data.id; - } - fetchTopics(targetAssignmentId); - } - }, [assignmentResponse, assignmentId, fetchTopics]); - - useEffect(() => { - if (signUpResponse) { - setIsSigningUp(false); - const dbTopicId = (signUpResponse as any)?.data?.signed_up_team?.project_topic_id; - if (dbTopicId) setLastSignedDbTopicId(Number(dbTopicId)); - // Clear optimistic updates since we'll get real data - setOptimisticSlotChanges(new Map()); - if (assignmentResponse?.data) { - let targetAssignmentId: number; - if (assignmentId) { - targetAssignmentId = parseInt(assignmentId); - } else if (Array.isArray(assignmentResponse.data) && assignmentResponse.data.length > 0) { - targetAssignmentId = assignmentResponse.data[0].id; - } else { - targetAssignmentId = assignmentResponse.data.id; - } - fetchTopics(targetAssignmentId); - } - } - }, [signUpResponse, assignmentResponse, assignmentId, fetchTopics]); - - useEffect(() => { - if (signUpError) { - console.error('Error signing up for topic:', signUpError); - setIsSigningUp(false); - // Clear optimistic updates on error to restore actual values - setOptimisticSlotChanges(new Map()); - } - }, [signUpError]); - - useEffect(() => { - if (dropResponse) { - // Clear optimistic updates since we'll get real data - setOptimisticSlotChanges(new Map()); - if (assignmentResponse?.data) { - let targetAssignmentId: number; - if (assignmentId) { - targetAssignmentId = parseInt(assignmentId); - } else if (Array.isArray(assignmentResponse.data) && assignmentResponse.data.length > 0) { - targetAssignmentId = assignmentResponse.data[0].id; - } else { - targetAssignmentId = assignmentResponse.data.id; - } - fetchTopics(targetAssignmentId); - } - } - }, [dropResponse, assignmentResponse, assignmentId, fetchTopics]); - - useEffect(() => { - if (dropError) { - console.error('Error dropping topic:', dropError); - // Clear optimistic updates on error to restore actual values - setOptimisticSlotChanges(new Map()); - setPendingDeselections(new Set()); - } - }, [dropError]); - - const isUserOnTopic = useCallback((topic: any) => { - if (!topic) return false; - const matches = (teams: any[]) => Array.isArray(teams) - ? teams.some((team: any) => - Array.isArray(team.members) && - team.members.some((m: any) => String(m.id) === String(currentUser?.id))) - : false; - return matches(topic.confirmed_teams) || matches(topic.waitlisted_teams); - }, [currentUser?.id]); - - const topics = useMemo(() => { - if (topicsError || !topicsResponse?.data) return []; - const topicsData = Array.isArray(topicsResponse.data) ? topicsResponse.data : []; - return topicsData.map((topic: any) => { - const topicId = topic.topic_identifier || topic.id?.toString() || 'unknown'; - const dbId = Number(topic.id); - const baseSlots = topic.available_slots || 0; - const adjustedSlots = optimisticSlotChanges.has(topicId) - ? optimisticSlotChanges.get(topicId)! - : baseSlots; - // Determine if current user is on a team for this topic (confirmed or waitlisted) - const matches = (teams: any[]) => { - if (!currentUser?.id || !Array.isArray(teams)) return false; - return teams.some((team: any) => - Array.isArray(team.members) && - team.members.some((m: any) => String(m.id) === String(currentUser.id)) - ); - }; - const userWaitlisted = matches(topic.waitlisted_teams); - const userConfirmed = matches(topic.confirmed_teams); - const userOnTopic = userConfirmed || userWaitlisted; - const pendingDrop = pendingDeselections.has(topicId); - - const selectionOverride = optimisticSelection.get(topicId); - const isSelected = pendingDrop - ? false - : selectionOverride === 'selected' - ? true - : selectionOverride === 'deselected' - ? false - : uiSelectedTopic !== null - ? uiSelectedTopic === topicId - : userOnTopic; - return { - id: topicId, - databaseId: isNaN(dbId) ? undefined : dbId, - name: topic.topic_name || 'Unnamed Topic', - availableSlots: adjustedSlots, - waitlist: topic.waitlisted_teams?.length || 0, - isBookmarked: bookmarkedTopics.has(topicId), - isSelected, - isTaken: adjustedSlots <= 0, - isWaitlisted: userWaitlisted - }; - }); - }, [topicsResponse, topicsError, bookmarkedTopics, uiSelectedTopic, optimisticSlotChanges, optimisticSelection, pendingDeselections, currentUser?.id]); - - // Initialize or reconcile selectedTopic from backend data after fetch - useEffect(() => { - if (Array.isArray(topicsResponse?.data)) { - // Priority 1: if we have lastSignedDbTopicId, map it to identifier and select - if (lastSignedDbTopicId) { - const t = topicsResponse.data.find((x: any) => Number(x.id) === Number(lastSignedDbTopicId)); - const key = t?.topic_identifier || t?.id?.toString(); - if (key) setUiSelectedTopic(key); - setLastSignedDbTopicId(null); - return; - } - // Priority 2: use membership lists - if (uiSelectedTopic === null) { - const found = topicsResponse.data.find((topic: any) => { - const topicKey = topic.topic_identifier || topic.id?.toString(); - if (!topicKey || pendingDeselections.has(topicKey)) return false; - return isUserOnTopic(topic); - }); - if (found) { - const key = found.topic_identifier || found.id?.toString(); - if (key) setUiSelectedTopic(key); - } - } - } - if (optimisticSelection.size > 0) { - setOptimisticSelection(new Map()); - } - }, [topicsResponse?.data, currentUser?.id, uiSelectedTopic, lastSignedDbTopicId, optimisticSelection.size, pendingDeselections, isUserOnTopic]); - - useEffect(() => { - if (!Array.isArray(topicsResponse?.data)) return; - setPendingDeselections(prev => { - if (prev.size === 0) return prev; - const next = new Set(prev); - let changed = false; - prev.forEach(topicId => { - const topic = topicsResponse.data.find((t: any) => { - const key = t.topic_identifier || t.id?.toString(); - return key === topicId; - }); - const stillAssigned = topic ? isUserOnTopic(topic) : false; - if (!stillAssigned) { - next.delete(topicId); - changed = true; - } - }); - return changed ? next : prev; - }); - }, [topicsResponse?.data, isUserOnTopic]); - - const assignmentName = useMemo(() => { - if (!assignmentResponse?.data) return 'OSS project & documentation assignment'; - if (Array.isArray(assignmentResponse.data) && assignmentResponse.data.length > 0) { - return assignmentResponse.data[0].name || 'OSS project & documentation assignment'; - } else { - return assignmentResponse.data.name || 'OSS project & documentation assignment'; - } - }, [assignmentResponse]); - - // Check if bookmarks are allowed for this assignment - const allowBookmarks = useMemo(() => { - if (!assignmentResponse?.data) return false; - if (Array.isArray(assignmentResponse.data) && assignmentResponse.data.length > 0) { - return assignmentResponse.data[0].allow_bookmarks || false; - } else { - return assignmentResponse.data.allow_bookmarks || false; - } - }, [assignmentResponse]); - - const userSelectedTopics: Topic[] = useMemo(() => { - return topics.filter(topic => topic.isSelected); - }, [topics]); - - const handleBookmarkToggle = useCallback((topicId: string) => { - setBookmarkedTopics(prev => { - const newSet = new Set(prev); - if (newSet.has(topicId)) { - newSet.delete(topicId); - } else { - newSet.add(topicId); - } - return newSet; - }); - }, []); - - const handleTopicSelect = useCallback(async (topicId: string) => { - if (!currentUser?.id) return; - // Treat as deselect if either local selection matches or backend indicates selection - const topicEntry = topics.find(t => t.id === topicId); - const isCurrentlyOnThisTopic = !!topicEntry?.isSelected; - - if (uiSelectedTopic === topicId || (uiSelectedTopic === null && isCurrentlyOnThisTopic)) { - // Deselecting current topic - optimistically increment available slots when confirmed - if (topicEntry && !topicEntry.isWaitlisted) { - setOptimisticSlotChanges(prev => { - const newMap = new Map(prev); - newMap.set(topicId, topicEntry.availableSlots + 1); - return newMap; - }); - } - setPendingDeselections(prev => { - if (prev.has(topicId)) return prev; - const next = new Set(prev); - next.add(topicId); - return next; - }); - - setUiSelectedTopic(null); - setOptimisticSelection(prev => { - const next = new Map(prev); - next.set(topicId, 'deselected'); - return next; - }); - const dbId = topicEntry?.databaseId || topicsResponse?.data?.find((t: any) => t.topic_identifier === topicId || t.id?.toString() === topicId)?.id; - if (dbId) { - dropAPI({ - url: '/signed_up_teams/drop_topic', - method: 'DELETE', - data: { user_id: currentUser.id, topic_id: dbId } - }); - } - } else { - // Selecting new topic - optimistically decrement available slots - const topic = topics.find(t => t.id === topicId); - if (topic) { - setOptimisticSlotChanges(prev => { - const newMap = new Map(prev); - newMap.set(topicId, Math.max(0, topic.availableSlots - 1)); - - // If there's a previously selected topic, increment its slots - if (uiSelectedTopic) { - const prevTopic = topics.find(t => t.id === uiSelectedTopic); - if (prevTopic) { - newMap.set(uiSelectedTopic, prevTopic.availableSlots + 1); - } - } - - return newMap; - }); - } - - setOptimisticSelection(prev => { - const next = new Map(prev); - next.set(topicId, 'selected'); - if (uiSelectedTopic) { - next.set(uiSelectedTopic, 'deselected'); - } - return next; - }); - setPendingDeselections(prev => { - const next = new Set(prev); - next.delete(topicId); - if (uiSelectedTopic) { - next.add(uiSelectedTopic); - } - return next; - }); - - if (uiSelectedTopic) { - // Drop previous topic first - const prev = topics.find(t => t.id === uiSelectedTopic); - const prevDbId = prev?.databaseId || topicsResponse?.data?.find((t: any) => t.topic_identifier === uiSelectedTopic || t.id?.toString() === uiSelectedTopic)?.id; - if (prevDbId) { - dropAPI({ - url: '/signed_up_teams/drop_topic', - method: 'DELETE', - data: { user_id: currentUser.id, topic_id: prevDbId } - }); - } - } - - setUiSelectedTopic(topicId); - setIsSigningUp(true); - - const topicData = topics.find(t => t.id === topicId); - const dbId = topicData?.databaseId || topicsResponse?.data?.find((t: any) => t.topic_identifier === topicId || t.id?.toString() === topicId)?.id; - if (dbId) { - setTimeout(() => { - signUpAPI({ - url: '/signed_up_teams/sign_up_student', - method: 'POST', - data: { user_id: currentUser.id, topic_id: dbId } - }); - }, 100); - } else { - setIsSigningUp(false); - } - } - }, [currentUser?.id, dropAPI, uiSelectedTopic, signUpAPI, topics, topicsResponse?.data]); - - // Table columns (declare before any conditional returns to satisfy hooks rules) - const topicRows: TopicRow[] = useMemo(() => topics.map(t => ({ - id: t.id, - name: t.name, - availableSlots: t.availableSlots, - waitlistCount: t.waitlist, - isTaken: t.isTaken, - isBookmarked: t.isBookmarked, - isSelected: t.isSelected, - isWaitlisted: t.isWaitlisted, - })), [topics]); - - if (topicsLoading) { - return ( - - - Loading topics... - -

Loading topics...

-
- ); - } - - if (topicsError) { - return ( - - - Error Loading Topics -

- {typeof topicsError === 'string' - ? topicsError - : JSON.stringify(topicsError) - } -

-
-
- ); - } - - // removed duplicate columns definition placed after conditional returns - - return ( - - - -

Signup Sheet For {assignmentName}

- -
- - - -

- Your topic(s): {userSelectedTopics.length > 0 - ? userSelectedTopics.map((topic) => topic.isWaitlisted ? `${topic.name} (waitlisted)` : topic.name).join(", ") - : "No topics selected yet"} -

- -
- - - - {topics.length === 0 ? ( - - No Topics Available -

There are no topics available for this assignment yet.

-
- ) : ( - - )} - -
-
- ); -}; - -export default StudentTasks; +/** + * StudentTasks — the student's "My Assignments" dashboard. + * + * This component replaced an earlier version that rendered a topic sign-up sheet + * (with optimistic slot counters, bookmark state, waitlist logic, and 4+ API hooks). + * That implementation was scoped to a single assignment fetched via :assignmentId in + * the URL, so it couldn't show a student's full workload across all their courses. + * + * The current implementation: + * + * 1. Data fetching — one GET /student_tasks/list call returns all assignments the + * logged-in student participates in. Each item contains the participant record, + * current stage, review grade, stage deadline, and derived flags (submissionUpdated, notStarted). + * + * 2. Parsing — parseStudentTasks() normalises the raw API shape into a typed Task[]. + * Fields are read with ?? fallbacks to tolerate both flat and nested response shapes + * (the API embeds some fields directly on the item and others inside item.participant). + * + * 3. Grouping — tasksGroupedByCourse groups tasks by course name so each course gets + * its own section heading and Table instance. + * + * 4. Columns — filteredColumns builds the TanStack Table column definitions: + * - "Assignment" links to /student_task_detail/:participantId (full task detail page). + * - "Review Grade" renders a ToolTip if a grade exists; shows "NA" otherwise. + * - "Badges" column is conditionally included only when at least one task has badges. + * - "Show as Example?" is a client-side-only toggle (optimistic local state update). + * + * 5. Sidebar — StudentTasksBox (StudentTasksList) shows tasks not yet started, revisions + * due, and the list of students the current user has teamed with. It receives a + * Revision[] derived from the same tasks list via extractAssignments(). + */ +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Link } from "react-router-dom"; +import styles from "./StudentTasks.module.css"; +import StudentTasksBox, { Revision } from "./StudentTasksList"; +import { CellContext } from "@tanstack/react-table"; +import Table from "components/Table/Table"; +import { formatDate, capitalizeFirstWord } from "utils/dataFormatter"; +import axiosClient from "utils/axios_client"; +import ToolTip from "../../components/ToolTip"; +import { Container } from "react-bootstrap"; + +type Task = { + id: number; + assignmentId: number; + assignment: string; + course: string; + topic: string; + currentStage: string; + reviewGrade: string; + badges: string | boolean; + stageDeadline: string; + showAsExample: boolean; + submissionUpdated: boolean; + started: boolean; +}; + +const StudentTasks: React.FC = () => { + const [studentTasksData, setStudentTasksData] = useState([]); + const [tasks, setTasks] = useState([]); + + useEffect(() => { + const fetchStudentTasks = async () => { + try { + const response = await axiosClient.get(`/student_tasks/list`); + setStudentTasksData(response.data || []); + } catch (error) { + console.error("Error fetching student tasks:", error); + } + }; + fetchStudentTasks(); + }, []); + + useEffect(() => { + setTasks(parseStudentTasks(studentTasksData)); + }, [studentTasksData]); + + /** + * Normalises the raw /student_tasks/list response into typed Task objects. + * The API embeds some fields directly on the item and others inside item.participant, + * so ?? chaining is used to try the top-level key first and fall back to the nested one. + * assignmentId is used later to build the "Your feedbacks" link in StudentTaskDetail. + */ + function parseStudentTasks(rawList: any[]): Task[] { + return rawList.map((item) => { + const participant = item.participant || {}; + const courseName = typeof item.course === "string" ? item.course : "CSC 517"; + + return { + id: participant.id, + assignmentId: participant.parent_id ?? item.assignment_id ?? null, + assignment: item.assignment ?? "N/A", + course: courseName, + topic: item.topic ?? participant.topic ?? "N/A", + currentStage: item.current_stage ?? participant.current_stage ?? "N/A", + reviewGrade: item.review_grade ?? "N/A", + badges: item.badges ?? false, + stageDeadline: item.stage_deadline ?? participant.stage_deadline ?? "", + showAsExample: item.permission_granted ?? participant.permission_granted ?? false, + submissionUpdated: item.submission_updated ?? false, + started: item.started ?? false, + }; + }); + } + + /** + * Converts the Task list into the Revision[] shape expected by StudentTasksBox. + * Strips the ISO timestamp from stageDeadline so the sidebar shows only the date part. + * submissionUpdated and started flags are computed by the backend based on current stage and + * whether the participant has submitted any work. + */ + function extractAssignments(tasksList: Task[]): Revision[] { + return tasksList.map((task) => ({ + name: task.assignment, + dueDate: task.stageDeadline ? task.stageDeadline.split("T")[0] : "N/A", + submissionUpdated: task.submissionUpdated, + started: task.started, + currentStage: task.currentStage, + participantId: task.id, + })); + } + + const toggleShowAsExample = useCallback((id: number) => { + setTasks((prevTasks) => + prevTasks.map((task) => + task.id === id ? { ...task, showAsExample: !task.showAsExample } : task + ) + ); + }, []); + + // Only show the Badges column if at least one task has a badge value — avoids an empty + // column for assignments that don't use the badge feature. + const showBadges = tasks.some((task) => task.badges); + + const filteredColumns = useMemo(() => { + return [ + { + accessorKey: "assignment", + header: "Assignment", + cell: (info: CellContext) => { + const id = info.row.original.id; + return ( + + {info.getValue()} + + ); + }, + }, + { accessorKey: "topic", header: "Topic" }, + { accessorKey: "currentStage", header: "Current Stage" }, + { + accessorKey: "reviewGrade", + header: "Review Grade", + cell: (info: CellContext) => + info.getValue() === "N/A" ? ( + "NA" + ) : ( + + ), + }, + ...(showBadges ? [{ accessorKey: "badges", header: "Badges" }] : []), + { + accessorKey: "stageDeadline", + header: "Stage Deadline", + }, + { + accessorKey: "showAsExample", + header: "Show as Example?", + cell: (info: CellContext) => ( + toggleShowAsExample(Number(info.row.original.id))} + /> + ), + }, + ].map(({ header, ...rest }) => ({ + ...rest, + header: capitalizeFirstWord(header as string), + })); + }, [showBadges, toggleShowAsExample]); + + // Apply display-layer formatting (date localisation, capitalisation, fallbacks) separately + // from the raw Task data so the original Task[] stays clean and is usable by other derivations. + const formattedAssignments = useMemo(() => { + return tasks.map((task) => ({ + ...task, + topic: capitalizeFirstWord(task.topic) || "-", + course: capitalizeFirstWord(task.course), + reviewGrade: task.reviewGrade || "N/A", + badges: task.badges || "", + stageDeadline: formatDate(task.stageDeadline) || "No deadline", + showAsExample: task.showAsExample || false, + })); + }, [tasks]); + + // Group tasks by course so each course gets a separate heading + Table. + // Tasks with no course name fall into "Unassigned Courses". + const tasksGroupedByCourse = useMemo(() => { + const groups: { [key: string]: typeof formattedAssignments } = {}; + formattedAssignments.forEach((task) => { + const courseKey = task.course || "Unassigned Courses"; + if (!groups[courseKey]) { + groups[courseKey] = []; + } + groups[courseKey].push(task); + }); + return groups; + }, [formattedAssignments]); + + return ( +
+

Assignments

+
+ + +
+ {Object.entries(tasksGroupedByCourse).map(([courseName, courseTasks]) => ( + +
+ +

+ {courseName} +

+
+ + + + ))} + + {tasks.length === 0 &&

No assignments found.

} + + + +
+ + Help + + + Papers on Expertiza + +
+ + ); +}; + +export default StudentTasks; \ No newline at end of file diff --git a/src/pages/StudentTasks/StudentTasksList.module.css b/src/pages/StudentTasks/StudentTasksList.module.css new file mode 100644 index 00000000..629d30ce --- /dev/null +++ b/src/pages/StudentTasks/StudentTasksList.module.css @@ -0,0 +1,126 @@ +/* Stripes for odd rows in a table within .student-tasks */ +.student-tasks .table-striped>tbody>tr:nth-of-type(odd)>td, +.student-tasks .table-striped>tbody>tr:nth-of-type(odd)>th { + background-color: #ffffff; + --bs-table-bg-type: none +} + +/* Stripes for even rows in a table within .student-tasks */ +.student-tasks .table-striped>tbody>tr:nth-of-type(even)>td, +.student-tasks .table-striped>tbody>tr:nth-of-type(even)>th { + background-color: #f2f2f2; + --bs-table-bg-type: none; +} + +/* Styling for task boxes */ +.taskbox { + padding: 5px; + margin-bottom: 39px; + border: 1px dashed #999999; + float: left; + font-size: 12px; + background: none repeat scroll 0pt 0pt #fafaea; + width: 100%; +} + +/* Styling for the task number indicator */ +.tasknum { + color: #FFFFFF; + background-color: #B73204; +} + +/* Styling for the revision number indicator */ +.revnum { + color: #FFFFFF; + background-color: #999999; +} + +/* Styling for notification links */ +.notification a { + color:#0066CC +} + +/* Layout for the page containing the student tasks */ +.pageLayout { + display: flex; + margin: 16px; +} + +/* Fixed-width sidebar styling */ +.sidebar { + width: 200px; /* Width of the sidebar */ + margin-right: 20px; /* Spacing between sidebar and main content */ +} + +/* Main content area that grows to fill the space */ +.mainContent { + flex-grow: 1; + overflow: hidden; /* In case the content is too wide */ +} + +/* Styling for the page header */ +.header { + margin-bottom: 20px; /* Space below the header */ +} + +/* Full-width table styling */ +.tasksTable { + width: 100%; /* Full width of the main content area */ + /* Add more styling for your table */ +} + +/* Margin styling for sections */ +.section { + margin-bottom: 20px; /* Space between sections */ +} + +/* Header styling within sections */ +.section-header { + font-size: 18px; /* Larger font size for visibility */ + font-weight: bold; /* Bold text for section headers */ + color: #333; /* Darker text for better readability */ + margin-bottom: 10px; /* Space below section header */ +} + +/* Styling for individual items within a section */ +.section-item { + margin-left: 20px; /* Indent for items in the list */ + margin-bottom: 5px; /* Space between items */ + color: #555; /* Slightly lighter text for items */ +} + +/* Standard badge styling */ +.badge { + display: inline-block; + padding: 0.25em 0.4em; + font-size: 75%; + font-weight: 700; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 0; + padding: 1; + background-color: #a52a2a; + color: white; +} + +/* Revision link styling */ +.revisionLink { + color: #986633; + text-decoration: none; +} + +/* Grey badge variant */ +.greyBadge{ + padding: 0.25em 0.4em; + font-size: 75%; + font-weight: 700; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 0; + background-color: rgb(159, 156, 156); + color: white; +} diff --git a/src/pages/StudentTasks/StudentTasksList.tsx b/src/pages/StudentTasks/StudentTasksList.tsx new file mode 100644 index 00000000..4f8561ef --- /dev/null +++ b/src/pages/StudentTasks/StudentTasksList.tsx @@ -0,0 +1,125 @@ +/** + * StudentTasksList (rendered as the sidebar "StudentTasksBox") — shows three sections: + * + * 1. "Tasks not yet started" — assignments where the participant has not submitted + * anything yet (started=false, set by the backend). Shows days remaining. + * + * 2. "Revisions" — assignments currently in a revision stage (submissionUpdated flag). Each + * entry links to /student_review/list/:participantId so the student can act on feedback. + * + * 3. "Students who have teamed with you" — fetched independently via GET /student_tasks/team, + * which returns { course_name: [full_name, ...] }. Grouped by course with a count badge. + * This mirrors the old Expertiza "teamed_students" sidebar panel. + * + * The Revision[] prop is derived from the parent's Task[] via extractAssignments() so this + * component stays stateless with respect to assignment data — it only owns teammate state. + */ +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import styles from './StudentTasksList.module.css'; +import axiosClient from 'utils/axios_client'; + +export type Revision = { + name: string; + dueDate: string; + submissionUpdated: boolean; + started: boolean; + currentStage: string; + participantId: number; +}; + +// Students teamed with data structure: course name -> list of teammate full names +type StudentsTeamedWith = { + [course: string]: string[]; +}; + +interface StudentTasksListProps { + revisions: Revision[]; +} + +const StudentTasksList: React.FC = ({ revisions }) => { + const [studentsTeamedWith, setStudentsTeamedWith] = useState({}); + const [loadingTeammates, setLoadingTeammates] = useState(true); + + useEffect(() => { + axiosClient + .get('/student_tasks/team') + .then((res) => setStudentsTeamedWith(res.data || {})) + .catch((err) => console.error('Error fetching teammates:', err)) + .finally(() => setLoadingTeammates(false)); + }, []); + + const totalStudents = Object.values(studentsTeamedWith).reduce( + (sum, students) => sum + students.length, + 0 + ); + + // Returns days remaining until dueDate. Returns 0 (not negative) for past dates. + const calculateDaysLeft = (dueDate: string) => { + const today = new Date(); + const due = new Date(dueDate); + const timeDiff = due.getTime() - today.getTime(); + const daysDiff = Math.ceil(timeDiff / (1000 * 3600 * 24)); + return daysDiff > 0 ? daysDiff : 0; + }; + + const submissionUpdateddTasks = revisions.filter((r) => r.submissionUpdated); + const notStartedTasks = revisions.filter((r) => !r.started); + + return ( +
+
+ {notStartedTasks.length}  + Tasks not yet started + {notStartedTasks.map((task, index) => { + const daysLeft = calculateDaysLeft(task.dueDate); + return ( +
+ » {task.name} {task.currentStage} ({daysLeft} day{daysLeft !== 1 ? 's' : ''} left) +
+ ); + })} +
+ +
+ {submissionUpdateddTasks.length}  + Revisions + {submissionUpdateddTasks.map((task, index) => { + const daysLeft = calculateDaysLeft(task.dueDate); + return ( +
+ »{' '} + + {task.name} {task.currentStage} + + {' '}({daysLeft} day{daysLeft !== 1 ? 's' : ''} left) +
+ ); + })} +
+ +
+ {totalStudents}  + Students who have teamed with you +
+ + {loadingTeammates ? ( +
Loading teammates...
+ ) : Object.keys(studentsTeamedWith).length === 0 ? ( +
No teammates found.
+ ) : ( + Object.entries(studentsTeamedWith).map(([course, students], index) => ( +
+ {students.length}  + {course} + {students.map((student, studentIndex) => ( +
» {student}
+ ))} +
+ )) + )} +
+ ); +}; + +export default StudentTasksList; diff --git a/src/pages/ViewTeamGrades/App.tsx b/src/pages/ViewTeamGrades/App.tsx index 3b63a8fe..2c96adf6 100644 --- a/src/pages/ViewTeamGrades/App.tsx +++ b/src/pages/ViewTeamGrades/App.tsx @@ -7,12 +7,21 @@ export interface TeamMember { username: string; } +// Sentinel type returned by the backend when a questionnaire item is a SectionHeader. +// The backend injects { type: "header", txt: "..." } at the correct positions in the +// per-round scores array so the frontend can render labelled section dividers between rows. +export interface SectionHeaderData { + type: "header"; + txt: string; +} + // Interface defining the structure of ReviewData export interface ReviewData { itemNumber: string; itemText: string; itemType?: string; // Type of item (Scale, Criterion, TextArea, etc.) reviews: { + name?: string; // reviewer name (populated by convertBackendRoundArray) score?: number; comment?: string; textResponse?: string; // For TextArea/TextField diff --git a/src/pages/ViewTeamGrades/Data/authorFeedback.json b/src/pages/ViewTeamGrades/Data/authorFeedback.json deleted file mode 100644 index ed3317ae..00000000 --- a/src/pages/ViewTeamGrades/Data/authorFeedback.json +++ /dev/null @@ -1,84 +0,0 @@ -[ - [ - { - "questionNumber": "1", - "questionText": "This reviewer appeared to understand my work.", - "reviews": [ - { "name": "John", "score": 5, "comment": "The reviewer demonstrated a deep understanding of the work, providing insightful feedback." }, - { "name": "Alice", "score": 3, "comment": "While the reviewer grasped the main points, some aspects could have been clarified further." }, - { "name": "Bob", "score": 4, "comment": "The reviewer's understanding was evident, although certain nuances could have been explored more." }, - { "name": "Emma", "score": 5, "comment": "The reviewer showcased a comprehensive understanding of the work, offering valuable insights." }, - { "name": "Mike", "score": 4, "comment": "The reviewer's comprehension was evident, but a few minor details could have been addressed." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "2", - "questionText": "This reviewer's comments helped me improve my work.", - "reviews": [ - { "name": "John", "score": 5, "comment": "The reviewer's comments were insightful and contributed significantly to the improvement of the work." }, - { "name": "Alice", "score": 5, "comment": "The reviewer's feedback was instrumental in refining various aspects of the work." }, - { "name": "Bob", "score": 5, "comment": "The reviewer's constructive criticism was invaluable in enhancing the quality of the work." }, - { "name": "Emma", "score": 5, "comment": "The reviewer's suggestions were practical and directly led to improvements in the work." }, - { "name": "Mike", "score": 5, "comment": "The reviewer's feedback played a crucial role in refining the work and addressing potential shortcomings." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "3", - "questionText": "The tone of this review was respectful.", - "reviews": [ - { "name": "John", "score": 5, "comment": "The reviewer maintained a respectful tone throughout the review, fostering a positive exchange of ideas." }, - { "name": "Alice", "score": 5, "comment": "The review was conducted in a respectful manner, acknowledging the efforts put into the work." }, - { "name": "Bob", "score": 5, "comment": "The reviewer's tone was consistently respectful, contributing to a constructive review process." }, - { "name": "Emma", "score": 5, "comment": "The tone of the review remained respectful, focusing on constructive criticism." }, - { "name": "Mike", "score": 5, "comment": "Throughout the review, the tone remained respectful and professional." } - ], - "RowAvg": 0, - "maxScore": 5 - } - ], - [ - { - "questionNumber": "1", - "questionText": "This reviewer appeared to understand my work.", - "reviews": [ - { "name": "John", "score": 4, "comment": "The reviewer demonstrated a good understanding of the work, providing valuable insights." }, - { "name": "Alice", "score": 5, "comment": "The reviewer's understanding of the work was evident, contributing to meaningful feedback." }, - { "name": "Bob", "score": 3, "comment": "While the reviewer grasped the main points, certain aspects could have been explained more clearly." }, - { "name": "Emma", "score": 4, "comment": "The reviewer showcased a solid understanding of the work, offering constructive feedback." }, - { "name": "Mike", "score": 5, "comment": "The reviewer's comprehension was evident, providing insightful comments." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "2", - "questionText": "This reviewer's comments helped me improve my work.", - "reviews": [ - { "name": "John", "score": 4, "comment": "The reviewer's comments offered valuable insights that contributed to improving the work." }, - { "name": "Alice", "score": 5, "comment": "The reviewer provided constructive criticism that directly led to enhancements in the work." }, - { "name": "Bob", "score": 3, "comment": "While the reviewer's feedback was helpful, certain suggestions could have been elaborated further." }, - { "name": "Emma", "score": 4, "comment": "The reviewer's suggestions were practical and contributed to refining various aspects of the work." }, - { "name": "Mike", "score": 5, "comment": "The reviewer's feedback played a crucial role in refining the work and addressing potential weaknesses." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "3", - "questionText": "The tone of this review was respectful.", - "reviews": [ - { "name": "John", "score": 4, "comment": "While the review was generally respectful, there were a few instances where the tone could have been more considerate." }, - { "name": "Alice", "score": 5, "comment": "The reviewer maintained a respectful tone throughout the review, fostering a positive exchange of ideas." }, - { "name": "Bob", "score": 3, "comment": "While most of the review was conducted with a respectful tone, certain sections could have been phrased more diplomatically." }, - { "name": "Emma", "score": 4, "comment": "The review was conducted with a respectful tone, focusing on constructive criticism." }, - { "name": "Mike", "score": 5, "comment": "Throughout the review, the tone remained respectful and professional." } - ], - "RowAvg": 0, - "maxScore": 5 - } - ] -] diff --git a/src/pages/ViewTeamGrades/Data/dummyData.json b/src/pages/ViewTeamGrades/Data/dummyData.json deleted file mode 100644 index 3963694a..00000000 --- a/src/pages/ViewTeamGrades/Data/dummyData.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "team": "Straw Hat Pirates", - "members": [ - { - "name": "Aniket Singh Shaktawat", - "username": "ashaktaw" - }, - { - "name": "Pankhi Saini", - "username": "psaini" - }, - { - "name": "Siddharth Shah", - "username": "sshah" - }, - { - "name": "Riya Gori", - "username": "rgori" - } - ], - "grade": "Grade for submission", - "comment": "Comment for submission", - "late_penalty": 0 -} \ No newline at end of file diff --git a/src/pages/ViewTeamGrades/Data/heatMapData.json b/src/pages/ViewTeamGrades/Data/heatMapData.json deleted file mode 100644 index 7ea51e2b..00000000 --- a/src/pages/ViewTeamGrades/Data/heatMapData.json +++ /dev/null @@ -1,371 +0,0 @@ -[ - [ - { - "questionNumber": "1", - "questionText": "What is the main purpose of this feature?", - "reviews": [ - { "name": "John", "score": 4, "comment": "Great work on this aspect!" }, - { "name": "Alice", "score": 3, "comment": "Could use some improvement here." }, - { "name": "Bob", "score": 4, "comment": "The presentation was well-organized and clear. However, some points could have been elaborated further to provide a deeper understanding of the topic." }, - { "name": "Emma", "score": 5, "comment": "The speaker demonstrated a profound understanding of the subject matter, making the session engaging and informative." }, - { "name": "Mike", "score": 4, "comment": "The visuals were compelling and helped in understanding complex concepts easily. However, there were a few slides with too much text, which made it hard to follow at times." }, - { "name": "Sophia", "score": 5, "comment": "The use of real-world examples made the concepts more relatable and easier to grasp. Additionally, the speaker was engaging and kept the audience hooked throughout." }, - { "name": "David", "score": 4, "comment": "The interactive exercises were beneficial in reinforcing the learning. However, there were a few technical glitches that disrupted the flow." }, - { "name": "Olivia", "score": 5, "comment": "The hands-on activities were the highlight of the session, providing practical experience that complemented the theoretical learning." }, - { "name": "William", "score": 4, "comment": "The guest speaker brought a fresh perspective to the topic, offering valuable insights that sparked further discussions among the participants." }, - { "name": "Sophia", "score": 5, "comment": "The case studies presented were enlightening, providing practical examples that showcased the application of theoretical concepts." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "2", - "questionText": "How user-friendly is this feature?", - "reviews": [ - { "name": "John", "score": 4, "comment": "The interface was intuitive and easy to navigate." }, - { "name": "Alice", "score": 2, "comment": "There were some confusing elements that could be simplified." }, - { "name": "Bob", "score": 5, "comment": "The feature was straightforward to use, with clear instructions." }, - { "name": "Emma", "score": 2, "comment": "The user experience could be improved, especially for new users." }, - { "name": "Mike", "score": 3, "comment": "Some aspects were user-friendly, but others required a learning curve." }, - { "name": "Sophia", "score": 2, "comment": "More tooltips or hints could enhance the user-friendliness." }, - { "name": "David", "score": 3, "comment": "Overall, the feature was easy to grasp, but minor improvements could enhance the user experience." }, - { "name": "Olivia", "score": 4, "comment": "The feature was generally user-friendly, with a few areas for improvement." }, - { "name": "William", "score": 3, "comment": "Certain functions were straightforward, while others could use simplification." }, - { "name": "Sophia", "score": 2, "comment": "The feature would benefit from clearer labels and instructions." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "3", - "questionText": "Does this feature meet the project requirements?", - "reviews": [ - { "name": "John", "score": 1}, - { "name": "Alice", "score": 1}, - { "name": "Bob", "score": 1}, - { "name": "Emma", "score": 0}, - { "name": "Mike", "score": 1}, - { "name": "Sophia", "score": 0}, - { "name": "David", "score": 1}, - { "name": "Olivia", "score": 1}, - { "name": "William", "score": 1}, - { "name": "Sophia", "score": 0} - ], - "RowAvg": 0, - "maxScore": 1 - }, - { - "questionNumber": "4", - "questionText": "How would you rate the performance of this feature?", - "reviews": [ - { "name": "John", "score": 4, "comment": "The feature performs adequately under normal conditions." }, - { "name": "Alice", "score": 2, "comment": "Performance could be improved, especially for larger datasets." }, - { "name": "Bob", "score": 4, "comment": "The feature's performance is generally satisfactory." }, - { "name": "Emma", "score": 1, "comment": "Performance issues were encountered during testing." }, - { "name": "Mike", "score": 3, "comment": "Overall, the feature performs well, but some optimizations could enhance speed." }, - { "name": "Sophia", "score": 2, "comment": "The feature's performance is acceptable but could be faster." }, - { "name": "David", "score": 3, "comment": "The feature handles most tasks efficiently, but a few functions could be optimized." }, - { "name": "Olivia", "score": 4, "comment": "Performance is stable and meets expectations for regular use." }, - { "name": "William", "score": 3, "comment": "The feature's performance meets the needs for the intended tasks." }, - { "name": "Sophia", "score": 2, "comment": "There were occasional lags in performance during heavy usage." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "5", - "questionText": "What are your thoughts on the design of this feature?", - "reviews": [ - { "name": "John", "score": 4, "comment": "The design is sleek and modern, enhancing usability." }, - { "name": "Alice", "score": 3, "comment": "Some design elements could be more cohesive." }, - { "name": "Bob", "score": 5, "comment": "The feature's design is intuitive and visually appealing." }, - { "name": "Emma", "score": 2, "comment": "The design could be more user-centric." }, - { "name": "Mike", "score": 4, "comment": "Overall, the design facilitates ease of use." }, - { "name": "Sophia", "score": 3, "comment": "Certain design choices enhance functionality, while others could be refined." }, - { "name": "David", "score": 4, "comment": "The design aligns well with the feature's purpose." }, - { "name": "Olivia", "score": 5, "comment": "The design elements contribute to a seamless user experience." }, - { "name": "William", "score": 4, "comment": "Design considerations are apparent, benefiting user interaction." }, - { "name": "Sophia", "score": 2, "comment": "Some design aspects may confuse new users." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "6", - "questionText": "Were the documentation and help resources helpful?", - "reviews": [ - { "name": "John", "score": 3, "comment": "The design is average, with room for improvement." }, - { "name": "Alice", "score": 5, "comment": "Certain design elements enhance usability effectively." }, - { "name": "Bob", "score": 5, "comment": "The design stands out with its intuitive layout." }, - { "name": "Emma", "score": 3, "comment": "Some design aspects could be more cohesive." }, - { "name": "Mike", "score": 5, "comment": "The feature's design is modern and visually appealing." }, - { "name": "Sophia", "score": 5, "comment": "Design considerations are apparent, benefiting user interaction." }, - { "name": "David", "score": 3, "comment": "The design offers room for improvement in certain areas." }, - { "name": "Olivia", "score": 4, "comment": "The feature's design is clean and uncluttered." }, - { "name": "William", "score": 5, "comment": "Certain design elements contribute significantly to usability." }, - { "name": "Sophia", "score": 3, "comment": "The design could be more user-centric." } - ], - - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "7", - "questionText": "Did the feature perform well under stress/load?", - "reviews": [ - { "name": "John", "score": 5, "comment": "The design is sleek and modern, enhancing usability." }, - { "name": "Alice", "score": 5, "comment": "The feature's design is intuitive and visually appealing." }, - { "name": "Bob", "score": 3, "comment": "Some design elements could be streamlined for clarity." }, - { "name": "Emma", "score": 5, "comment": "Overall, the design facilitates ease of use." }, - { "name": "Mike", "score": 5, "comment": "The design elements contribute to a seamless user experience." }, - { "name": "Sophia", "score": 3, "comment": "Certain design choices enhance functionality, while others could be refined." }, - { "name": "David", "score": 5, "comment": "The design aligns well with the feature's purpose." }, - { "name": "Olivia", "score": 5, "comment": "The design delights users with its attention to detail." }, - { "name": "William", "score": 3, "comment": "Some design aspects may confuse new users." }, - { "name": "Sophia", "score": 4, "comment": "The design balances aesthetics with functionality effectively." } - ], - - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "8", - "questionText": "How satisfied are you with the support provided for this feature?", - "reviews": [ - { "name": "John", "score": 3 }, - { "name": "Alice", "score": 4 }, - { "name": "Bob", "score": 5 }, - { "name": "Emma", "score": 2 }, - { "name": "Mike", "score": 4 }, - { "name": "Sophia", "score": 3 }, - { "name": "David", "score": 4 }, - { "name": "Olivia", "score": 5 }, - { "name": "William", "score": 4 }, - { "name": "Sophia", "score": 2 } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "9", - "questionText": "Would you recommend this feature to others?", - "reviews": [ - { "name": "John", "score": 5, "comment": "The design is exceptional, exceeding expectations." }, - { "name": "Alice", "score": 3, "comment": "Certain design aspects could be polished further." }, - { "name": "Bob", "score": 5, "comment": "Overall, the design enhances user experience effectively." }, - { "name": "Emma", "score": 5, "comment": "The feature's design is top-notch, setting a new standard." }, - { "name": "Mike", "score": 3, "comment": "Some design elements could be more intuitive." }, - { "name": "Sophia", "score": 5, "comment": "The design offers a pleasant user journey." }, - { "name": "David", "score": 5, "comment": "Design considerations are evident, making tasks straightforward." }, - { "name": "Olivia", "score": 3, "comment": "Certain design choices may require further refinement." }, - { "name": "William", "score": 5, "comment": "The design adapts well to different screen sizes and devices." }, - { "name": "Sophia", "score": 5, "comment": "The feature's design is a joy to interact with." } - ], - - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "10", - "questionText": "Overall, how would you rate this feature?", - "reviews": [ - { "name": "John", "score": 4, "comment": "The design is polished and professional." }, - { "name": "Alice", "score": 5, "comment": "Certain design elements make the feature a joy to use." }, - { "name": "Bob", "score": 3, "comment": "Some design aspects could benefit from refinement." }, - { "name": "Emma", "score": 5, "comment": "The design is user-friendly, with intuitive navigation." }, - { "name": "Mike", "score": 5, "comment": "Certain design elements enhance user interaction effectively." }, - { "name": "Sophia", "score": 3, "comment": "Some design aspects may require further attention." }, - { "name": "David", "score": 5, "comment": "The design encourages exploration and discovery." }, - { "name": "Olivia", "score": 5, "comment": "The feature's design sets a new standard for user interfaces." }, - { "name": "William", "score": 3, "comment": "Certain design elements are confusing and could be clarified." }, - { "name": "Sophia", "score": 5, "comment": "The design offers an inviting and engaging experience." } - ], - - "RowAvg": 0, - "maxScore": 5 - } - ], - [ - { - "questionNumber": "1", - "questionText": "What is the main purpose of this feature?", - "reviews": [ - { "name": "John", "score": 4, "comment": "The design is polished and professional." }, - { "name": "Alice", "score": 5, "comment": "Certain design elements make the feature a joy to use." }, - { "name": "Bob", "score": 3, "comment": "Some design aspects could benefit from refinement." }, - { "name": "Emma", "score": 4, "comment": "The design is user-friendly, with intuitive navigation." }, - { "name": "Mike", "score": 5, "comment": "Certain design elements enhance user interaction effectively." }, - { "name": "Sophia", "score": 4, "comment": "Some design aspects may require further attention." }, - { "name": "David", "score": 4, "comment": "The design encourages exploration and discovery." }, - { "name": "Olivia", "score": 5, "comment": "The feature's design sets a new standard for user interfaces." }, - { "name": "William", "score": 4, "comment": "Certain design elements are confusing and could be clarified." }, - { "name": "Sophia", "score": 4, "comment": "The design offers an inviting and engaging experience." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "2", - "questionText": "How user-friendly is this feature?", - "reviews": [ - { "name": "John", "score": 5, "comment": "The design is flawless, making tasks effortless." }, - { "name": "Alice", "score": 2, "comment": "Some design aspects could be more user-oriented." }, - { "name": "Bob", "score": 4, "comment": "The feature's design is inviting and approachable." }, - { "name": "Emma", "score": 5, "comment": "Certain design elements provide a delightful user journey." }, - { "name": "Mike", "score": 2, "comment": "Some design choices could enhance user engagement." }, - { "name": "Sophia", "score": 4, "comment": "The design adapts well to varying user needs." }, - { "name": "David", "score": 5, "comment": "The feature's design excels in simplicity and effectiveness." }, - { "name": "Olivia", "score": 3, "comment": "Certain design elements could benefit from more visual hierarchy." }, - { "name": "William", "score": 4, "comment": "The design offers a pleasing aesthetic while being functional." }, - { "name": "Sophia", "score": 5, "comment": "The feature's design is intuitive and user-centric." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "3", - "questionText": "Does this feature meet the project requirements?", - "reviews": [ - { "name": "John", "score": 1}, - { "name": "Alice", "score": 1}, - { "name": "Bob", "score": 1}, - { "name": "Emma", "score": 0}, - { "name": "Mike", "score": 1}, - { "name": "Sophia", "score": 0}, - { "name": "David", "score": 1}, - { "name": "Olivia", "score": 0}, - { "name": "William", "score": 0}, - { "name": "Sophia", "score": 1 } - ], - "RowAvg": 0, - "maxScore": 1 - }, - { - "questionNumber": "4", - "questionText": "How would you rate the performance of this feature?", - "reviews": [ - { "name": "John", "score": 5, "comment": "The design is exceptional, exceeding expectations." }, - { "name": "Alice", "score": 5, "comment": "Certain design aspects could be polished further." }, - { "name": "Bob", "score": 4, "comment": "Overall, the design enhances user experience effectively." }, - { "name": "Emma", "score": 5, "comment": "The feature's design is top-notch, setting a new standard." }, - { "name": "Mike", "score": 0, "comment": "Some design elements could be more intuitive." }, - { "name": "Sophia", "score": 4, "comment": "The design offers a pleasant user journey." }, - { "name": "David", "score": 5, "comment": "Design considerations are evident, making tasks straightforward." }, - { "name": "Olivia", "score": 5, "comment": "Certain design choices may require further refinement." }, - { "name": "William", "score": 5, "comment": "The design adapts well to different screen sizes and devices." }, - { "name": "Sophia", "score": 5, "comment": "The feature's design is a joy to interact with." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "5", - "questionText": "What are your thoughts on the design of this feature?", - "reviews": [ - { "name": "John", "score": 4, "comment": "The design is polished and professional." }, - { "name": "Alice", "score": 5, "comment": "Certain design elements make the feature a joy to use." }, - { "name": "Bob", "score": 3, "comment": "Some design aspects could benefit from refinement." }, - { "name": "Emma", "score": 4, "comment": "The design is user-friendly, with intuitive navigation." }, - { "name": "Mike", "score": 5, "comment": "Certain design elements enhance user interaction effectively." }, - { "name": "Sophia", "score": 2, "comment": "Some design aspects may require further attention." }, - { "name": "David", "score": 4, "comment": "The design encourages exploration and discovery." }, - { "name": "Olivia", "score": 5, "comment": "The feature's design sets a new standard for user interfaces." }, - { "name": "William", "score": 4, "comment": "Certain design elements are confusing and could be clarified." }, - { "name": "Sophia", "score": 4, "comment": "The design offers an inviting and engaging experience." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "6", - "questionText": "Were the documentation and help resources helpful?", - "reviews": [ - { "name": "John", "score": 5, "comment": "The design is flawless, making tasks effortless." }, - { "name": "Alice", "score": 3, "comment": "Some design aspects could be more user-oriented." }, - { "name": "Bob", "score": 5, "comment": "The feature's design is inviting and approachable." }, - { "name": "Emma", "score": 5, "comment": "Certain design elements provide a delightful user journey." }, - { "name": "Mike", "score": 3, "comment": "Some design choices could enhance user engagement." }, - { "name": "Sophia", "score": 4, "comment": "The design adapts well to varying user needs." }, - { "name": "David", "score": 5, "comment": "The feature's design excels in simplicity and effectiveness." }, - { "name": "Olivia", "score": 1, "comment": "Certain design elements could benefit from more visual hierarchy." }, - { "name": "William", "score": 5, "comment": "The design offers a pleasing aesthetic while being functional." }, - { "name": "Sophia", "score": 5, "comment": "The feature's design is intuitive and user-centric." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "7", - "questionText": "Did the feature perform well under stress/load?", - "reviews": [ - { "name": "John", "score": 4, "comment": "The design is sleek and modern, enhancing usability." }, - { "name": "Alice", "score": 5, "comment": "The feature's design is intuitive and visually appealing." }, - { "name": "Bob", "score": 3, "comment": "Some design elements could be streamlined for clarity." }, - { "name": "Emma", "score": 4, "comment": "Overall, the design facilitates ease of use." }, - { "name": "Mike", "score": 5, "comment": "The design elements contribute to a seamless user experience." }, - { "name": "Sophia", "score": 2, "comment": "Certain design choices enhance functionality, while others could be refined." }, - { "name": "David", "score": 4, "comment": "The design aligns well with the feature's purpose." }, - { "name": "Olivia", "score": 5, "comment": "The design delights users with its attention to detail." }, - { "name": "William", "score": 2, "comment": "Some design aspects may confuse new users." }, - { "name": "Sophia", "score": 4, "comment": "The design balances aesthetics with functionality effectively." } - ], - - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "8", - "questionText": "How satisfied are you with the support provided for this feature?", - "reviews": [ - { "name": "John", "score": 5, "comment": "The design is exceptional, exceeding expectations." }, - { "name": "Alice", "score": 4, "comment": "Certain design aspects could be polished further." }, - { "name": "Bob", "score": 4, "comment": "Overall, the design enhances user experience effectively." }, - { "name": "Emma", "score": 5, "comment": "The feature's design is top-notch, setting a new standard." }, - { "name": "Mike", "score": 2, "comment": "Some design elements could be more intuitive." }, - { "name": "Sophia", "score": 4, "comment": "The design offers a pleasant user journey." }, - { "name": "David", "score": 5, "comment": "Design considerations are evident, making tasks straightforward." }, - { "name": "Olivia", "score": 3, "comment": "Certain design choices may require further refinement." }, - { "name": "William", "score": 4, "comment": "The design adapts well to different screen sizes and devices." }, - { "name": "Sophia", "score": 5, "comment": "The feature's design is a joy to interact with." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "9", - "questionText": "Would you recommend this feature to others?", - "reviews": [ - { "name": "John", "score": 5, "comment": "The design is flawless, making tasks effortless." }, - { "name": "Alice", "score": 3, "comment": "Some design aspects could be more user-oriented." }, - { "name": "Bob", "score": 4, "comment": "The feature's design is inviting and approachable." }, - { "name": "Emma", "score": 5, "comment": "Certain design elements provide a delightful user journey." }, - { "name": "Mike", "score": 2, "comment": "Some design choices could enhance user engagement." }, - { "name": "Sophia", "score": 4, "comment": "The design adapts well to varying user needs." }, - { "name": "David", "score": 5, "comment": "The feature's design excels in simplicity and effectiveness." }, - { "name": "Olivia", "score": 2, "comment": "Certain design elements could benefit from more visual hierarchy." }, - { "name": "William", "score": 4, "comment": "The design offers a pleasing aesthetic while being functional." }, - { "name": "Sophia", "score": 5, "comment": "The feature's design is intuitive and user-centric." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "10", - "questionText": "Overall, how would you rate this feature?", - "reviews": [ - { "name": "John", "score": 4, "comment": "The design is sleek and modern, enhancing usability." }, - { "name": "Alice", "score": 5, "comment": "The feature's design is intuitive and visually appealing." }, - { "name": "Bob", "score": 3, "comment": "Some design elements could be streamlined for clarity." }, - { "name": "Emma", "score": 4, "comment": "Overall, the design facilitates ease of use." }, - { "name": "Mike", "score": 3, "comment": "The design elements contribute to a seamless user experience." }, - { "name": "Sophia", "score": 3, "comment": "Certain design choices enhance functionality, while others could be refined. Overall scope of improvement" }, - { "name": "David", "score": 4, "comment": "The design aligns well with the feature's purpose." }, - { "name": "Olivia", "score": 5, "comment": "The design delights users with its attention to detail." }, - { "name": "William", "score": 4, "comment": "Some design aspects may confuse new users." }, - { "name": "Sophia", "score": 4, "comment": "The design balances aesthetics with functionality effectively." } - ], - "RowAvg": 0, - "maxScore": 5 - } - ] -] \ No newline at end of file diff --git a/src/pages/ViewTeamGrades/Data/teammateData.json b/src/pages/ViewTeamGrades/Data/teammateData.json deleted file mode 100644 index 7b6a20a8..00000000 --- a/src/pages/ViewTeamGrades/Data/teammateData.json +++ /dev/null @@ -1,124 +0,0 @@ - - [ - { - "questionNumber": "1", - "questionText": "How many times was this person late to meetings?", - "reviews": [ - { "name": "John", "score": 5, "comment": "He was never late for any meeting." }, - { "name": "Alice", "score": 4, "comment": "She was late once due to traffic." }, - { "name": "Bob", "score": 5, "comment": "He was punctual for all meetings." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "2", - "questionText": "How many times did this person fail to show up?", - "reviews": [ - { "name": "John", "score": 5, "comment": "This never happened." }, - { "name": "Alice", "score": 4, "comment": "She missed one meeting due to illness." }, - { "name": "Bob", "score": 5, "comment": "He never failed to show up." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "3", - "questionText": "What fraction of the work assigned to this person did (s)he do?", - "reviews": [ - { "name": "John", "score": 5, "comment": "He completed all assigned tasks on time." }, - { "name": "Alice", "score": 4, "comment": "She completed 70% of the assigned work." }, - { "name": "Bob", "score": 5, "comment": "He completed 100% of his assigned work." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "4", - "questionText": "Did this person do assigned work on time?", - "reviews": [ - { "name": "John", "score": 5, "comment": "Yes, he did all the work on time." }, - { "name": "Alice", "score": 4, "comment": "She completed most of the work on time." }, - { "name": "Bob", "score": 5, "comment": "He always completed his work on time." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "5", - "questionText": "How much initiative did this person take on this project?", - "reviews": [ - { "name": "John", "score": 5, "comment": "He always took initiatives and brought fresh ideas." }, - { "name": "Alice", "score": 4, "comment": "She occasionally proposed new ideas for improvement." }, - { "name": "Bob", "score": 5, "comment": "He consistently showed initiative and creativity." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "6", - "questionText": "Did this person shirk any task that was necessary for completing the project?", - "reviews": [ - { "name": "John", "score": 5, "comment": "This never happened." }, - { "name": "Alice", "score": 4, "comment": "She occasionally avoided tasks she found challenging." }, - { "name": "Bob", "score": 5, "comment": "He always fulfilled his responsibilities." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "7", - "questionText": "Did you need to clean up any of the code written by this team member?", - "reviews": [ - { "name": "John", "score": 5, "comment": "No, the code was clean and easy to read." }, - { "name": "Alice", "score": 4, "comment": "Some parts of her code needed minor cleanup." }, - { "name": "Bob", "score": 5, "comment": "His code was well-structured and required no cleanup." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "8", - "questionText": "Did this person make any unneeded modifications that did not benefit the project, but may have increased their score on the Github metrics?", - "reviews": [ - { "name": "John", "score": 5, "comment": "Not at all, he always had good explanation for his part of the work." }, - { "name": "Alice", "score": 4, "comment": "She made a few unnecessary changes to increase her commit count." }, - { "name": "Bob", "score": 5, "comment": "He only made changes that were necessary for project improvement." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "9", - "questionText": "Did this person exhibit any behaviors that could cause dissension on the team?", - "reviews": [ - { "name": "John", "score": 5, "comment": "No, he is fun to work with." }, - { "name": "Alice", "score": 4, "comment": "She occasionally disagreed with team decisions but was respectful." }, - { "name": "Bob", "score": 5, "comment": "He was cooperative and maintained a positive attitude." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "10", - "questionText": "What fraction of the documentation did this person write? (Note: Contribution of all team members should not add up to more than 100%)", - "reviews": [ - { "name": "John", "score": 4, "comment": "He did 50% of the work, He made the basic structure for the documentation and took most of the responsibility related to it." }, - { "name": "Alice", "score": 5, "comment": "She contributed 40% to the documentation, focusing on detailed explanations." }, - { "name": "Bob", "score": 4, "comment": "His contribution to documentation was 30%, mainly focused on formatting and organization." } - ], - "RowAvg": 0, - "maxScore": 5 - }, - { - "questionNumber": "11", - "questionText": "How important was this person to the team?", - "reviews": [ - { "name": "John", "score": 5, "comment": "He brought great ideas for the project and thus was very important." }, - { "name": "Alice", "score": 4, "comment": "She played a significant role in decision-making and problem-solving." }, - { "name": "Bob", "score": 5, "comment": "His contributions were invaluable to the success of the project." } - ], - "RowAvg": 0, - "maxScore": 5 - } - ] diff --git a/src/pages/ViewTeamGrades/FeedbackTable.tsx b/src/pages/ViewTeamGrades/FeedbackTable.tsx new file mode 100644 index 00000000..685d7b41 --- /dev/null +++ b/src/pages/ViewTeamGrades/FeedbackTable.tsx @@ -0,0 +1,288 @@ +import React from "react"; +import { ReviewData, SectionHeaderData } from "./App"; +import { getColorClass, isHeader, RoundRow } from "./heatgridUtils"; +import { useSelector } from "react-redux"; +import { RootState } from "../../store/store"; + +interface FeedbackTableProps { + /** All rounds of review data — each round is a mixed array of ReviewData and SectionHeaderData */ + data: RoundRow[][]; + /** + * -1 = show all rounds + * 0 = round 1 only + * 1 = round 2 only + */ + roundSelected: number; +} + +const STICKY_NO_WIDTH = 68; // px — wide enough for two-digit item numbers + weight badge on one line +const STICKY_Q_WIDTH = 340; // px — the Question column + +const cellBase: React.CSSProperties = { + padding: "8px 10px", + verticalAlign: "top", + border: "1px solid #ddd", + fontSize: "13px", + whiteSpace: "normal", + wordBreak: "break-word", +}; + +const stickyNo: React.CSSProperties = { + ...cellBase, + position: "sticky", + left: 0, + zIndex: 3, + background: "#fff", + width: STICKY_NO_WIDTH, + minWidth: STICKY_NO_WIDTH, + maxWidth: STICKY_NO_WIDTH, + textAlign: "center", + fontWeight: "bold", + // no right border here — the Question column's left border provides the single divider + borderRight: "none", +}; + +const stickyQ: React.CSSProperties = { + ...cellBase, + position: "sticky", + left: STICKY_NO_WIDTH, + zIndex: 3, + background: "#fff", + width: STICKY_Q_WIDTH, + minWidth: STICKY_Q_WIDTH, + maxWidth: STICKY_Q_WIDTH, + borderLeft: "1px solid #ddd", // single line between # and Question + borderRight: "2px solid #aaa", // strong separator before reviewer columns +}; + +const reviewerCell: React.CSSProperties = { + ...cellBase, + minWidth: 260, + maxWidth: 380, + verticalAlign: "top", +}; + +/** Colour-coded score badge */ +const ScoreBadge: React.FC<{ score: number; maxScore: number }> = ({ score, maxScore }) => ( + + {score} + +); + +/** One per-round feedback table */ +const RoundFeedbackTable: React.FC<{ roundData: RoundRow[]; roundIndex: number; totalRounds: number; isStudent: boolean }> = ({ + roundData, + roundIndex, + totalRounds, + isStudent, +}) => { + if (!roundData || roundData.length === 0) return null; + // Find the first scored row (skip any leading SectionHeader) to get reviewer count + const firstScored = roundData.find(r => !isHeader(r)) as ReviewData | undefined; + const numReviewers = firstScored?.reviews.length ?? 0; + + return ( +
+

Round {roundIndex + 1}

+ + {/* Outer wrapper with horizontal scroll */} +
+
+ + + {/* Sticky header: # — z-index 5 so it stays above body sticky cells (z-index 3) */} + + + {/* Sticky header: Question */} + + + {/* One column per reviewer */} + {Array.from({ length: numReviewers }, (_, i) => { + const reviewerName = (firstScored?.reviews[i] as any)?.name || `Review ${i + 1}`; + const displayName = isStudent ? `Review ${i + 1}` : reviewerName; + return ( + + ); + })} + + + + + {(() => { + let scoredRowIdx = 0; + return roundData.map((row, idx) => { + // SectionHeader sentinel → heading row with sticky label. + // Split into two cells so the label stays fixed on horizontal scroll: + // cell 1 — sticky, covers the # col + question col (68 + 340 px) + // cell 2 — colSpan for all reviewer columns, scrolls away + if (isHeader(row)) { + return ( + + + + ); + } + + const rowIdx = scoredRowIdx++; + const bg = rowIdx % 2 === 0 ? "#fff" : "#f5f5f5"; + return ( + + {/* Sticky: # — explicit opaque background prevents scrolling rows bleeding through */} + + + {/* Sticky: Question text */} + + + {/* Reviewer answer columns */} + {row.reviews.map((review, revIdx) => ( + + ))} + + ); + }); + })()} + +
+ # + + Question + + {displayName} +
+ {row.txt} + +
+ {row.itemNumber} + + {row.itemText} + + {review.score !== undefined ? ( + <> +
+ + + / {row.maxScore} + +
+ {review.comment && ( +
+ {review.comment} +
+ )} + + ) : review.textResponse ? ( +
+ {review.textResponse} +
+ ) : review.selections ? ( +
    + {review.selections.map((s, si) => ( +
  • {s}
  • + ))} +
+ ) : review.selectedOption ? ( +
{review.selectedOption}
+ ) : review.fileName ? ( +
+ {review.fileUrl ? ( + + 📎 {review.fileName} + + ) : ( + 📎 {review.fileName} + )} +
+ ) : ( + — + )} +
+
+
+ ); +}; + +const FeedbackTable: React.FC = ({ data, roundSelected }) => { + const auth = useSelector( + (state: RootState) => state.authentication, + (prev, next) => prev.isAuthenticated === next.isAuthenticated + ); + const isStudent = auth.user.role === "Student"; + + if (!data || data.length === 0) { + return
No feedback data available.
; + } + + return ( +
+ {data.map((roundData: RoundRow[], roundIndex: number) => { + // Filter based on roundSelected (-1 = all, 0 = round 0, 1 = round 1, etc.) + if (roundSelected === 1 && roundIndex === 1) return null; + if (roundSelected === 2 && roundIndex === 0) return null; + return ( + + ); + })} +
+ ); +}; + +export default FeedbackTable; diff --git a/src/pages/ViewTeamGrades/ReviewTable.tsx b/src/pages/ViewTeamGrades/ReviewTable.tsx index a35b3f5c..49ef02d7 100644 --- a/src/pages/ViewTeamGrades/ReviewTable.tsx +++ b/src/pages/ViewTeamGrades/ReviewTable.tsx @@ -1,16 +1,43 @@ -import React, { useEffect, useState, useRef } from "react"; +/** + * ReviewTable — the main page component for "View Team Grades" (student-facing). + * + * Key design decisions made during this implementation: + * + * 1. Single API call: the original implementation made 6+ sequential API calls + * (view_our_scores, then participants/user/:id, teams_participants/:id/list_participants, + * and one /users/:id per team member). All that waterfall was replaced by embedding + * `team_members` directly in the `view_our_scores` response on the backend, so the + * entire page now loads with one GET /grades/:id/view_our_scores. + * + * 2. Scores / Feedback toggle: the original page had a separate review list below the + * heatgrid. These were merged into a single content area with a toggle button group — + * "Scores" renders the colour-coded heatgrid per round; "Feedback" renders FeedbackTable + * which shows full question text and reviewer comments side-by-side. + * + * 3. Sticky columns on the score heatgrid: the old layout had an optional "Show item + * prompts" checkbox that inserted a second column. When checked, the tooltip on the + * first column was obscured by the new column. The toggle was removed entirely and the + * layout was redesigned to match FeedbackTable — sticky # column (52 px) + sticky + * Question column (340 px) with horizontally scrollable reviewer columns. + * + * 4. CSS Modules: the old side-effect import `import "./grades.scss"` was converted to + * `import styles from "./ViewTeamGrades.module.scss"`. Classes used as plain strings by + * other components (c1–c5, score, review-block, etc.) are wrapped in `:global {}` so + * they keep their original names after hashing. + * + * 5. Reviewer anonymisation: `authUser` from Redux is still used to check whether the + * logged-in user is a Student — if so, reviewer names are replaced with "Review N". + */ +import React, { useEffect, useState } from "react"; import ReviewTableRow from "./ReviewTableRow"; import RoundSelector from "./RoundSelector"; import axiosClient from "../../utils/axios_client"; -import { calculateAverages, normalizeReviewDataArray, convertBackendRoundArray } from "./utils"; +import { calculateAverages, normalizeReviewDataArray, convertBackendRoundArray, isHeader, RoundRow } from "./heatgridUtils"; import { TeamMember } from "./App"; -import "./grades.scss"; +import styles from "./ViewTeamGrades.module.scss"; import { Link, useSearchParams } from "react-router-dom"; -import Filters from "./Filters"; -import ShowReviews from "./ShowReviews"; +import FeedbackTable from "./FeedbackTable"; import { useSelector } from "react-redux"; -import { getAuthToken } from "../../utils/auth"; -import jwtDecode from "jwt-decode"; // Truncatable text component const TruncatableText: React.FC<{ text: string; wordLimit?: number }> = ({ text, wordLimit = 10 }) => { @@ -45,9 +72,8 @@ const TruncatableText: React.FC<{ text: string; wordLimit?: number }> = ({ text, const ReviewTable: React.FC = () => { const [searchParams] = useSearchParams(); const [currentRound, setCurrentRound] = useState(-1); - const [showToggleQuestion, setShowToggleQuestion] = useState(false); const [teamMembers, setTeamMembers] = useState([]); - const [roundsData, setRoundsData] = useState(null); + const [roundsData, setRoundsData] = useState(null); // Get assignment ID from URL query parameter, default to 1 const assignmentIdFromUrl = searchParams.get("assignmentId"); @@ -60,351 +86,220 @@ const ReviewTable: React.FC = () => { const [teamComment, setTeamComment] = useState(""); const [submissionLinks, setSubmissionLinks] = useState(null); const [teamFetchError, setTeamFetchError] = useState(null); - const [lastParticipantsResp, setLastParticipantsResp] = useState(null); - const [lastTeamResp, setLastTeamResp] = useState(null); - const [showReviews, setShowReviews] = useState(false); - const [ShowAuthorFeedback, setShowAuthorFeedback] = useState(false); - const [roundSelected, setRoundSelected] = useState(-1); - const [targetReview, setTargetReview] = useState<{roundIndex: number, reviewIndex: number} | null>(null); + const [viewMode, setViewMode] = useState<'scores' | 'feedback'>('scores'); const [averageFinalScore, setAverageFinalScore] = useState(null); const authUser = useSelector((state: any) => state.authentication?.user); - // Ref for the reviews section - const reviewsSectionRef = useRef(null); - // Auto-fetch assignment from URL parameter or default to 1 on mount useEffect(() => { fetchBackend(assignmentId); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // When roundsData is loaded, automatically select the first round - useEffect(() => { - if (roundsData && roundsData.length > 0 && roundSelected === -1) { - setRoundSelected(0); - } - }, [roundsData, roundSelected]); + /** + * Fetches all data for the page in a single request. + * The backend embeds `team_members` (name, grade, comment, submission links, member list) + * directly in the response, eliminating the previous multi-step waterfall that called + * /participants/user/:id → /teams_participants/:id/list_participants → /users/:id per member. + */ const fetchBackend = async (id: number) => { setIsLoading(true); setFetchError(null); try { - let res; - try { - res = await axiosClient.get(`/grades/${id}/view_our_scores`); - } catch (e: any) { - if (e?.response?.status === 404 || e?.response?.status === 403) { - try { - res = await axiosClient.get(`/grades/${id}/view_all_scores`); - if (res && res.data && res.data.team_scores && Object.keys(res.data.team_scores).length > 0) { - const maybe = res.data.team_scores; - if (maybe.reviews_of_our_work) { - res = { data: maybe }; - } else { - const firstKey = Object.keys(maybe)[0]; - if (firstKey) { - res = { data: maybe[firstKey] }; - } - } - } - } catch (e2: any) { - res = null; - } - } else { - throw e; - } - } - if (res && res.data && res.data.reviews_of_our_work) { + const res = await axiosClient.get(`/grades/${id}/view_our_scores`); + + if (res?.data?.reviews_of_our_work) { + // Populate heatgrid / feedback table const backendRoundsObj = res.data.reviews_of_our_work; - const orderedRounds = Object.keys(backendRoundsObj) - .sort() - .map((k) => backendRoundsObj[k]); - const converted = convertBackendRoundArray(orderedRounds); - setRoundsData(converted); - - // Set average final score from API response - console.log("=== API Response Data ==="); - console.log("Full res.data:", res.data); - console.log("avg_score_of_our_work value:", res.data.avg_score_of_our_work); - console.log("Type:", typeof res.data.avg_score_of_our_work); - - if (res.data.avg_score_of_our_work !== undefined && res.data.avg_score_of_our_work !== null) { - console.log("Setting averageFinalScore to:", res.data.avg_score_of_our_work); + const orderedRounds = Object.keys(backendRoundsObj).sort().map((k) => backendRoundsObj[k]); + setRoundsData(convertBackendRoundArray(orderedRounds)); + + // Average score + if (res.data.avg_score_of_our_work != null) { setAverageFinalScore(res.data.avg_score_of_our_work); - } else { - console.log("avg_score_of_our_work is not available in response"); } - - try { - const token = getAuthToken(); - if (!token || token === "EXPIRED") { - setTeamFetchError("No valid auth token found — team name and members require login.\nPlease log in and try again."); - } else { - const userId = getCurrentUserId(); - if (!userId) { - setTeamFetchError("Unable to determine current user from token. Team metadata cannot be loaded."); - } else { - setTeamFetchError(null); - await fetchTeamMetadata(id, userId); - } - } - } catch (err) { - console.warn("Failed to load team metadata:", err); + + // Team metadata — embedded by the backend, no follow-up requests needed + const tm = res.data.team_members; + if (tm) { + setTeamName(tm.team_name || ""); + setTeamGrade(tm.grade ?? ""); + setTeamComment(tm.comment ?? ""); + setSubmissionLinks(tm.submission_links?.length > 0 ? tm.submission_links : null); + setTeamMembers(tm.members || []); } - setIsLoading(false); - return; + } else { + setFetchError("No review data returned by backend."); } - setIsLoading(false); - setFetchError("No review data returned by backend."); } catch (err: any) { - setIsLoading(false); const status = err?.response?.status; if (status === 404) { - setFetchError("No review data found for this assignment (404). You may not be a participant for this assignment or the assignment does not exist."); + setFetchError("No review data found for this assignment (404). You may not be a participant, or the assignment does not exist."); } else if (status === 403) { - setFetchError("You are not authorized to view reviews for this assignment (403). Try using a user with instructor privileges or check the assignment ID."); + setFetchError("You are not authorized to view reviews for this assignment (403)."); } else { setFetchError(err?.message || "Failed to fetch backend data"); } + } finally { + setIsLoading(false); } }; - const getCurrentUserId = (): number | null => { - if (authUser && authUser.id) return authUser.id; - const token = getAuthToken(); - if (!token) return null; - try { - const decoded: any = jwtDecode(token as string); - return decoded?.id || decoded?.user_id || null; - } catch (err) { - return null; - } - }; - - const fetchTeamMetadata = async (assignmentIdParam: number, userId: number) => { - try { - const participantsResp = await axiosClient.get(`/participants/user/${userId}`); - setLastParticipantsResp(participantsResp?.data || participantsResp); - const participants = participantsResp?.data || []; - - const myParticipant = participants.find((p: any) => { - return Number(p.parent_id) === Number(assignmentIdParam) || Number(p.assignment_id) === Number(assignmentIdParam); - }); - - if (!myParticipant) { - setTeamFetchError("You are not a participant in this assignment (no participant record found for the current user and assignment).\nIf you expect to be a participant, confirm the assignment ID and that you're logged in as the correct user."); - return; - } - - const teamId = myParticipant.team_id || myParticipant.team?.id; - if (!teamId) { - setTeamFetchError("Participant found but no team_id set on participant. Team metadata cannot be loaded."); - return; - } - - let teamResp; - let teamObj; - let teamParticipants: any[] = []; - try { - teamResp = await axiosClient.get(`/teams_participants/${teamId}/list_participants`); - setLastTeamResp(teamResp?.data || teamResp); - teamObj = teamResp?.data?.team; - teamParticipants = teamResp?.data?.team_participants || []; - } catch (e: any) { - const status = e?.response?.status; - setLastTeamResp(e?.response?.data || e?.response || e); - if (status === 403) { - setTeamFetchError("teams_participants endpoint returned 403 — attempting fallback using /participants/assignment/:assignment_id"); - try { - const assignPartsResp = await axiosClient.get(`/participants/assignment/${assignmentIdParam}`); - const assignParts = assignPartsResp?.data || []; - const matching = assignParts.filter((p: any) => (p.team_id || (p.team && p.team.id)) === Number(teamId)); - const userIdsFromAssign = matching.map((p: any) => p.user_id || (p.participant && p.participant.user_id)).filter(Boolean); - const uniqueUserIds2 = Array.from(new Set(userIdsFromAssign)); - const userFetches2 = uniqueUserIds2.map((uid) => axiosClient.get(`/users/${uid}`)); - const usersResp2 = await Promise.allSettled(userFetches2); - const members2 = usersResp2 - .map((r) => (r.status === "fulfilled" ? r.value.data : null)) - .filter(Boolean) - .map((u: any) => ({ name: u.full_name || u.fullName || u.name, username: u.name })); - if (members2.length > 0) { - setTeamMembers(members2); - setTeamFetchError(null); - } else { - setTeamFetchError("Fallback succeeded but no user records found for team members."); - } - } catch (e2: any) { - setTeamFetchError(`Fallback via participants/assignment failed: ${e2?.message || String(e2)}`); - } - } else { - setTeamFetchError(`Failed to fetch team participants: ${e?.message || String(e)}`); - } - } - - if (teamObj) { - setTeamName(teamObj.name || teamObj.team_name || teamObj.display_name || teamName); - setTeamGrade(teamObj.grade_for_submission ?? teamGrade); - setTeamComment(teamObj.comment_for_submission ?? teamComment); - const links: string[] = []; - if (teamObj.hyperlinks && Array.isArray(teamObj.hyperlinks)) { - teamObj.hyperlinks.forEach((l: any) => links.push(String(l))); - } - if (teamObj.submitted_hyperlinks) { - try { - const parsed = JSON.parse(teamObj.submitted_hyperlinks); - if (Array.isArray(parsed)) parsed.forEach((l: any) => links.push(String(l))); - } catch (e) { - const str = String(teamObj.submitted_hyperlinks); - const urlRegex = /(https?:\/\/[^\s]+)/g; - const found = str.match(urlRegex) || []; - found.forEach((u: string) => links.push(u)); - } - } - setSubmissionLinks(links.length > 0 ? Array.from(new Set(links)) : null); - } - - const sourceParticipants = teamParticipants && teamParticipants.length > 0 ? teamParticipants : []; - const userIds: number[] = sourceParticipants - .map((tp: any) => tp.user_id || tp.userId || (tp.participant && tp.participant.user_id)) - .filter(Boolean); - - const uniqueUserIds = Array.from(new Set(userIds)); - - let members: any[] = []; - if (uniqueUserIds.length > 0) { - const userFetches = uniqueUserIds.map((uid) => axiosClient.get(`/users/${uid}`)); - const usersResp = await Promise.allSettled(userFetches); - - members = usersResp - .map((r) => (r.status === "fulfilled" ? r.value.data : null)) - .filter(Boolean) - .map((u: any) => ({ name: u.full_name || u.fullName || u.name, username: u.name })); - } - - if (members.length === 0 && sourceParticipants.length > 0) { - members = sourceParticipants.map((p: any) => ({ name: p.handle || `user_${p.user_id || p.id}`, username: String(p.user_id || p.id || "") })); - setTeamMembers(members); - setTeamFetchError("Team participants resolved but user details couldn't be fetched; showing participant handles instead."); - } else if (members.length > 0) { - setTeamMembers(members); - setTeamFetchError(null); - } - } catch (err: any) { - console.warn("Failed to fetch team metadata", err?.message || err); - setTeamFetchError(`Failed to fetch team metadata: ${err?.message || String(err)}`); - } - }; - - const toggleShowReviews = () => { - setShowReviews((prev) => !prev); - }; - - const selectRound = (r: number) => { - setRoundSelected(r); - }; - - const toggleAuthorFeedback = () => { - setShowAuthorFeedback((prev) => !prev); - }; - const handleRoundChange = (roundIndex: number) => { setCurrentRound(roundIndex); }; - const toggleShowQuestion = () => { - setShowToggleQuestion(!showToggleQuestion); - }; - - // Handle clicking on a review cell in the table - const handleReviewClick = (roundIndex: number, reviewIndex: number) => { - // Show reviews section if not already shown - if (!showReviews) { - setShowReviews(true); - } - - // Set the target review to expand - setTargetReview({ roundIndex, reviewIndex }); - - // Scroll to reviews section after a short delay to allow state to update - setTimeout(() => { - if (reviewsSectionRef.current) { - reviewsSectionRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, 100); - }; - - const renderTable = (roundData: any, roundIndex: number) => { + // Column widths shared between the header (defined here) and body rows (defined in ReviewTableRow). + // These must stay in sync — the table uses tableLayout:"fixed" so widths are set once in the header. + const STICKY_NO_WIDTH = 68; // px — wide enough for two-digit item numbers + weight badge on one line + const STICKY_Q_WIDTH = 340; // px — question text column + + /** + * Renders the score heatgrid for a single round. + * Layout mirrors FeedbackTable exactly: overflowX scroll wrapper, borderCollapse separate, + * sticky # and Question columns, scrollable colour-coded reviewer columns. + * borderCollapse:"separate" + borderSpacing:0 is required so sticky cells keep an + * opaque background and don't bleed through when the table scrolls horizontally. + */ + const renderTable = (roundData: RoundRow[], roundIndex: number) => { const normalizedData = normalizeReviewDataArray(roundData); - - const { averagePeerReviewScore, sortedData } = calculateAverages( - normalizedData, - "none" - ); + const { averagePeerReviewScore, sortedData } = calculateAverages(normalizedData, "none"); const roundsSource = roundsData || []; + // Find the first non-header row to determine reviewer count + const firstScored = normalizedData.find(r => !isHeader(r)) as any; + const numReviewers = firstScored?.reviews?.length || 0; + return ( -
+

- Review (Round: {roundIndex + 1} of {roundsSource.length}) + Round {roundIndex + 1}

- - - - - {showToggleQuestion && ( - + + {/* Reviewer columns */} + {Array.from({ length: numReviewers }, (_, i) => { + const reviewerName = (firstScored as any)?.reviews[i]?.name || `Review ${i + 1}`; + const isStudent = authUser?.role === "Student"; + const displayName = isStudent ? `Review ${i + 1}` : reviewerName; + return ( + + ); + })} + + + + {(() => { + let scoredRowIdx = 0; + return sortedData.map((row, index) => { + // Render SectionHeader sentinel as a heading row. + // Split into two cells so the label stays sticky on horizontal scroll: + // cell 1 — sticky, covers the # col + question col (68 + 340 px) + // cell 2 — colSpan for all reviewer columns, scrolls away + if (isHeader(row)) { + return ( + + + + ); + } + // Scored row — use its own index for alternating background + return ; + }); + })()} + +
- Item no. - - Item + + {/* Horizontally scrollable wrapper — identical to FeedbackTable */} +
+ + + + {/* Sticky: # */} + - )} - {Array.from({ length: roundData[0].reviews.length }, (_, i) => { - const reviewerName = roundData[0].reviews[i]?.name || `Review ${i + 1}`; - const isStudent = authUser?.role === "Student"; - const displayName = isStudent ? `Review ${i + 1}` : reviewerName; - return ( - - ); - })} - - - - {sortedData.map((row, index) => ( - handleReviewClick(roundIndex, reviewIndex)} - /> - ))} - -
+ # handleReviewClick(roundIndex, i)} - title={isStudent ? "Click to view full review" : `Review by ${reviewerName} - Click to view full`} - > - {displayName} -
-
-
- Average peer review score:{" "} - {averagePeerReviewScore} -
-
+ {/* Sticky: Question */} +
+ Question + + {displayName} +
+ {row.txt} + +
+
+ +
+ Average peer review score:{" "}{averagePeerReviewScore} +
); }; + // Convert 0-indexed currentRound to the 1-indexed roundSelected expected by FeedbackTable + // (-1 = all rounds, 0 → 1, 1 → 2, etc.) + const feedbackRoundSelected = currentRound === -1 ? -1 : currentRound + 1; + return ( -
-

Summary Report: Program 2

-
Team: {teamName || "Loading..."}
+
+

Summary Report: Program 2

+
Team: {teamName || "Loading..."}
{fetchError && (
{fetchError} @@ -420,92 +315,87 @@ const ReviewTable: React.FC = () => { ))}
- Average final score: {averageFinalScore || "N/A"} +
Average final score: {averageFinalScore || "N/A"}
-
Submission links
+
Submission links
{submissionLinks && submissionLinks.length > 0 ? ( ) : ( -
- No submission links found for this team. -
+ No submission links found for this team. )} {teamFetchError && ( -
- {teamFetchError} -
+
{teamFetchError}
)}

- - -
- - + {/* Round selector + Scores/Feedback toggle in one toolbar row */} +
+ + + {/* Scores / Feedback toggle — height matches the round dropdown (36px) */} +
+ {(["scores", "feedback"] as const).map((mode, i) => ( + + ))} +
+ {/* Main content area — toggled by Scores/Feedback */} {roundsData && roundsData.length > 0 ? ( - currentRound === -1 - ? roundsData.map((roundData: any, index: number) => renderTable(roundData, index)) - : renderTable(roundsData[currentRound], currentRound) + viewMode === 'scores' ? ( + currentRound === -1 + ? roundsData.map((roundData: any, index: number) => renderTable(roundData, index)) + : renderTable(roundsData[currentRound], currentRound) + ) : ( + + ) ) : (
{isLoading ? "Loading review data..." : "No review data available. Please load an assignment."}
)} -
- -
- -
- {showReviews && roundsData && roundsData.length > 0 && ( -
-

Reviews

- setTargetReview(null)} - /> -
- )} - {ShowAuthorFeedback && ( -
-

Author Feedback

-

Author feedback feature coming soon.

-
- )} -
- - {teamGrade || teamComment ? ( + {(teamGrade || teamComment) && (

Grade and Comment for Submission

{teamGrade &&

Grade: {teamGrade}

} - {teamComment &&

Comment:

} + {teamComment &&

Comment:

}
- ) : null} + )} Back
diff --git a/src/pages/ViewTeamGrades/ReviewTableRow.tsx b/src/pages/ViewTeamGrades/ReviewTableRow.tsx index 87099ff9..e120c197 100644 --- a/src/pages/ViewTeamGrades/ReviewTableRow.tsx +++ b/src/pages/ViewTeamGrades/ReviewTableRow.tsx @@ -1,125 +1,140 @@ -import React, { useState, useEffect } from "react"; -import { getColorClass } from "./utils"; // Importing utility functions -import { ReviewData } from "./App"; // Importing the ReviewData interface from App +/** + * ReviewTableRow — renders one rubric item as a table row inside the score heatgrid. + * + * Layout (identical to FeedbackTable rows): + * - Column 1 (sticky, 52 px): item number + weight badge (circled max-score). + * The weight badge is omitted for binary items (maxScore === 1) — a ✓ tick is shown instead. + * - Column 2 (sticky, 340 px): full question text. No truncation. + * - Columns 3..N: one cell per reviewer, colour-coded by score via getColorClass(). + * Color classes (c1–c5, cf) are declared :global in ViewTeamGrades.module.scss so they + * work as plain strings returned by getColorClass(). + * + * The `rowIndex` prop drives alternating row background (#fff / #f5f5f5). + * Sticky cells explicitly set `background` to match the row — otherwise scrolling rows + * bleed through behind the sticky column (a side-effect of borderCollapse:"separate"). + */ +import React from "react"; +import { getColorClass } from "./heatgridUtils"; +import { ReviewData } from "./App"; -// Truncatable text component -const TruncatableText: React.FC<{ text: string; wordLimit?: number }> = ({ text, wordLimit = 10 }) => { - const [isExpanded, setIsExpanded] = useState(false); +interface ReviewTableRowProps { + row: ReviewData; + rowIndex: number; + onReviewClick?: (reviewIndex: number) => void; +} - // Handle empty or undefined text - if (!text || typeof text !== 'string') { - console.log('TruncatableText: Empty or invalid text', text); - return ; - } +const STICKY_NO_WIDTH = 68; // wide enough for two-digit item numbers + weight badge on one line +const STICKY_Q_WIDTH = 340; - const words = text.trim().split(/\s+/); - const shouldTruncate = words.length > wordLimit; - const displayText = isExpanded || !shouldTruncate - ? text - : words.slice(0, wordLimit).join(" "); +const cellBase: React.CSSProperties = { + padding: "4px 10px", + verticalAlign: "top", + border: "1px solid #ddd", + fontSize: "13px", + whiteSpace: "normal", + wordBreak: "break-word", + position: "relative", // needed for hover tooltip in SCSS +}; - console.log('TruncatableText:', { text: text.substring(0, 50), wordCount: words.length, wordLimit, shouldTruncate }); +const stickyNo = (bg: string): React.CSSProperties => ({ + ...cellBase, + position: "sticky", + left: 0, + zIndex: 3, + background: bg, + width: STICKY_NO_WIDTH, + minWidth: STICKY_NO_WIDTH, + maxWidth: STICKY_NO_WIDTH, + textAlign: "center", + fontWeight: "bold", + borderRight: "none", +}); - return ( - - {displayText} - {shouldTruncate && ( - { - e.stopPropagation(); - setIsExpanded(!isExpanded); - console.log('Truncatable text clicked, isExpanded:', !isExpanded); - }} - style={{ - color: "#b00404", - cursor: "pointer", - fontWeight: "bold", - marginLeft: "4px" - }} - > - {isExpanded ? " [show less]" : "..."} - - )} - - ); +const stickyQ = (bg: string): React.CSSProperties => ({ + ...cellBase, + position: "sticky", + left: STICKY_NO_WIDTH, + zIndex: 3, + background: bg, + width: STICKY_Q_WIDTH, + minWidth: STICKY_Q_WIDTH, + maxWidth: STICKY_Q_WIDTH, + borderLeft: "1px solid #ddd", + borderRight: "2px solid #aaa", +}); + +const reviewerCell: React.CSSProperties = { + ...cellBase, + textAlign: "center", + minWidth: 110, + width: 110, }; -// Props interface for ReviewTableRow component -interface ReviewTableRowProps { - row: ReviewData; // Data for the row - showToggleQuestion: boolean; // Flag to toggle the item column - onReviewClick?: (reviewIndex: number) => void; // Add click handler -} +const ReviewTableRow: React.FC = ({ row, rowIndex, onReviewClick }) => { + const bg = rowIndex % 2 === 0 ? "#fff" : "#f5f5f5"; -// Functional component ReviewTableRow -const ReviewTableRow: React.FC = ({ row, showToggleQuestion, onReviewClick }) => { - return ( - - {/* Item Number with weight to the right */} - -
- {row.itemNumber} - - {row.maxScore !== 1 ? ( - {row.maxScore} - ) : ( - ✓ - )} - -
- - {/* Toggle Item */} - {showToggleQuestion && ( - - - - )} + let cellContent; + // The score cells — build one per reviewer + const reviewCells = row.reviews.map((review, idx) => { + let bgClass = 'cf'; - {/* Review Cells - Now clickable */} - {row.reviews.map((review, idx) => { - // Determine cell content based on item type - let cellContent; - let bgClass = 'cf'; + if (review.score !== undefined) { + bgClass = getColorClass(review.score, row.maxScore); + cellContent = ( + + {review.score} + + ); + } else if (review.textResponse) { + bgClass = 'cf'; + cellContent = {review.textResponse}; + } else if (review.selections && review.selections.length > 0) { + cellContent = ✓ ({review.selections.length}); + } else if (review.selectedOption) { + cellContent = {review.selectedOption}; + } else if (review.fileName) { + cellContent = 📎 {review.fileName}; + } else { + cellContent = -; + } - if (review.score !== undefined) { - // Scored items (Scale, Criterion) - bgClass = getColorClass(review.score, row.maxScore); - cellContent = ( - - {review.score} - - ); - } else if (review.textResponse) { - // Text items (TextArea, TextField) - cellContent = {review.textResponse.substring(0, 15)}...; - } else if (review.selections && review.selections.length > 0) { - // Multi-select items (Checkbox) - cellContent = ✓ ({review.selections.length}); - } else if (review.selectedOption) { - // Single-select items (Dropdown, Radio) - cellContent = {review.selectedOption}; - } else if (review.fileName) { - // File upload - cellContent = 📎 {review.fileName.substring(0, 10)}; - } else { - cellContent = -; - } + return ( + onReviewClick && onReviewClick(idx)} + > + {cellContent} + + ); + }); - return ( - onReviewClick && onReviewClick(idx)} - title={onReviewClick ? "Click to view full review" : ""} - > - {cellContent} - - ); - })} + return ( + + +
+ {row.itemNumber} + {row.maxScore !== 1 && ( + + {row.maxScore} + + )} +
+ + {row.itemText} + {reviewCells} ); }; -export default ReviewTableRow; // Exporting the ReviewTableRow component as default \ No newline at end of file +export default ReviewTableRow; diff --git a/src/pages/ViewTeamGrades/RoundSelector.tsx b/src/pages/ViewTeamGrades/RoundSelector.tsx index 1e5af17f..42436e67 100644 --- a/src/pages/ViewTeamGrades/RoundSelector.tsx +++ b/src/pages/ViewTeamGrades/RoundSelector.tsx @@ -1,3 +1,15 @@ +/** + * RoundSelector — a styled is styled to match the Scores/Feedback toggle height (36 px) and + * brand colour (#b00404). `appearance: none` removes the OS-default dropdown arrow so + * the custom ▼ caret can be positioned consistently across browsers. + */ import React from "react"; interface RoundSelectorProps { @@ -6,36 +18,62 @@ interface RoundSelectorProps { roundsData?: any[] | null; } -// RoundSelector component to display buttons for selecting rounds +const dropdownStyle: React.CSSProperties = { + appearance: "none", + WebkitAppearance: "none", + MozAppearance: "none", + padding: "6px 32px 6px 14px", + fontWeight: "bold", + fontSize: "14px", + fontFamily: "verdana, arial, helvetica, sans-serif", + border: "2px solid #b00404", + borderRadius: "0.375rem", + background: "#fff", + color: "#b00404", + cursor: "pointer", + outline: "none", + height: "36px", + minWidth: "130px", +}; + +const wrapperStyle: React.CSSProperties = { + position: "relative", + display: "inline-block", +}; + +const caretStyle: React.CSSProperties = { + position: "absolute", + right: "10px", + top: "50%", + transform: "translateY(-50%)", + pointerEvents: "none", + color: "#b00404", + fontSize: "10px", +}; + const RoundSelector: React.FC = ({ currentRound, handleRoundChange, roundsData }) => { const rounds = roundsData || []; - + if (rounds.length === 0) { - return null; // Don't render if no rounds available + return null; } + // value encoding: -1 = all rounds, 0 = round 1, 1 = round 2, ... + const handleChange = (e: React.ChangeEvent) => { + handleRoundChange(parseInt(e.target.value, 10)); + }; + return ( -
-
- - - {rounds.map((round, index) => ( -