@@ -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 */}
+
+ 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 (
+
+ {displayName}
+
+ );
+ })}
+
+
+
+
+ {(() => {
+ 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 (
+
+
+ {row.txt}
+
+
+
+ );
+ }
+
+ const rowIdx = scoredRowIdx++;
+ const bg = rowIdx % 2 === 0 ? "#fff" : "#f5f5f5";
+ return (
+
+ {/* Sticky: # — explicit opaque background prevents scrolling rows bleeding through */}
+
+ {row.itemNumber}
+
+
+ {/* Sticky: Question text */}
+
+ {row.itemText}
+
+
+ {/* Reviewer answer columns */}
+ {row.reviews.map((review, revIdx) => (
+
+ {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 ? (
+
+ ) : (
+ —
+ )}
+
+ ))}
+
+ );
+ });
+ })()}
+
+
+
+
+ );
+};
+
+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}
-
-
-
-
- Item no.
-
- {showToggleQuestion && (
-
- 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 (
- handleReviewClick(roundIndex, i)}
- title={isStudent ? "Click to view full review" : `Review by ${reviewerName} - Click to view full`}
- >
- {displayName}
-
- );
- })}
-
-
-
- {sortedData.map((row, index) => (
- handleReviewClick(roundIndex, reviewIndex)}
- />
- ))}
-
-
-
-
- Average peer review score:{" "}
- {averagePeerReviewScore}
-
-
+ {/* Sticky: Question */}
+
+ Question
+
+
+ {/* 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 (
+
+ {displayName}
+
+ );
+ })}
+
+
+
+ {(() => {
+ 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 (
+
+
+ {row.txt}
+
+
+
+ );
+ }
+ // Scored row — use its own index for alternating background
+ return ;
+ });
+ })()}
+
+
+
+
+
+ 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 ? (
{submissionLinks.map((l, i) => (
-
- {l}
-
+ {l}
))}
) : (
-
- No submission links found for this team.
-
+
No submission links found for this team.
)}
{teamFetchError && (
-
- {teamFetchError}
-
+
{teamFetchError}
)}
-
-
-
-
-
{showToggleQuestion ? "Hide item prompts" : "Show item prompts"}
+ {/* 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) => (
+ setViewMode(mode)}
+ style={{
+ padding: "0 18px",
+ height: "100%",
+ fontWeight: "bold",
+ fontSize: "14px",
+ fontFamily: "verdana, arial, helvetica, sans-serif",
+ cursor: "pointer",
+ border: "none",
+ borderLeft: i === 1 ? "2px solid #b00404" : "none",
+ background: viewMode === mode ? "#b00404" : "transparent",
+ color: viewMode === mode ? "white" : "#b00404",
+ transition: "background 0.2s, color 0.2s",
+ minWidth: "90px",
+ }}
+ >
+ {mode.charAt(0).toUpperCase() + mode.slice(1)}
+
+ ))}
+
+ {/* 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 dropdown for choosing which round to display.
+ *
+ * Replaced the original static button row that hard-coded "Round 1" / "Round 2".
+ * Options are now generated dynamically from `roundsData.length`, so the selector
+ * automatically adapts to any number of rounds returned by the backend.
+ *
+ * Value encoding: -1 = "All Rounds", 0 = round index 0 (Round 1), 1 = round index 1 (Round 2), …
+ * The native 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 (
-
-
-
handleRoundChange(-1)}
- style={{ borderRadius: '0.375rem' }}
- >
- All Rounds
-
-
- {rounds.map((round, index) => (
-
handleRoundChange(index)}
- style={{ borderRadius: '0.375rem' }}
- >
+
+
+ All Rounds
+ {rounds.map((_, index) => (
+
Round {index + 1}
-
+
))}
-
+
+ â–¼
);
};
diff --git a/src/pages/ViewTeamGrades/ShowReviews.tsx b/src/pages/ViewTeamGrades/ShowReviews.tsx
index 9ee7a9aa..0e83a30d 100644
--- a/src/pages/ViewTeamGrades/ShowReviews.tsx
+++ b/src/pages/ViewTeamGrades/ShowReviews.tsx
@@ -1,5 +1,5 @@
import React, { useState } from "react";
-import { getColorClass } from "./utils";
+import { getColorClass } from "./heatgridUtils";
import { RootState } from "../../store/store";
import { useSelector } from "react-redux";
@@ -47,7 +47,8 @@ interface ReviewComment {
interface Review {
questionNumber: string;
- questionText: string;
+ questionText?: string;
+ itemText?: string; // alias used by ReviewData from App.tsx
itemType?: string;
reviews: ReviewComment[];
RowAvg: number;
@@ -203,7 +204,7 @@ const CollapsibleReview: React.FC<{
{roundData.map((question, j) => (
- {j + 1}. {question.questionText}
+ {j + 1}. {question.itemText || question.questionText}
{question.reviews[reviewIndex].score !== undefined ? (
@@ -219,14 +220,14 @@ const CollapsibleReview: React.FC<{
{question.reviews[reviewIndex].comment && (
-
+
)}
>
) : question.reviews[reviewIndex].textResponse ? (
// Text items (TextArea, TextField)
-
+
) : question.reviews[reviewIndex].selections ? (
// Multi-select items (Checkbox)
diff --git a/src/pages/ViewTeamGrades/Statistics.tsx b/src/pages/ViewTeamGrades/Statistics.tsx
index 34116285..5fc142f8 100644
--- a/src/pages/ViewTeamGrades/Statistics.tsx
+++ b/src/pages/ViewTeamGrades/Statistics.tsx
@@ -1,7 +1,7 @@
// Statistics.tsx
import React, { useEffect } from "react";
-import { calculateAverages, normalizeReviewDataArray } from "./utils";
-import "./grades.scss";
+import { calculateAverages, normalizeReviewDataArray } from "./heatgridUtils";
+import styles from "./ViewTeamGrades.module.scss"
//props for statistics component
interface StatisticsProps {
@@ -23,7 +23,8 @@ const Statistics: React.FC
= ({ roundsSource = null }) => {
normalizedData,
"asc"
);
- const rowAvgArray = sortedData.map((item) => item.RowAvg);
+ // Filter out SectionHeader sentinels before accessing ReviewData-only fields
+ const rowAvgArray = sortedData.filter(item => !('type' in item)).map((item: any) => item.RowAvg);
console.log(rowAvgArray);
}, [roundsSource]);
diff --git a/src/pages/ViewTeamGrades/grades.scss b/src/pages/ViewTeamGrades/ViewTeamGrades.module.scss
similarity index 62%
rename from src/pages/ViewTeamGrades/grades.scss
rename to src/pages/ViewTeamGrades/ViewTeamGrades.module.scss
index 40df975d..a908133a 100644
--- a/src/pages/ViewTeamGrades/grades.scss
+++ b/src/pages/ViewTeamGrades/ViewTeamGrades.module.scss
@@ -1,26 +1,26 @@
/* ViewTeamGrades Page Typography Guidelines */
/* Apply font family to the entire page container */
-.p-4 {
+.page-wrapper {
font-family: verdana, arial, helvetica, sans-serif;
}
/* Main headings (h2) */
-.p-4 h2 {
+.page-wrapper h2 {
font-family: verdana, arial, helvetica, sans-serif;
}
/* Subheadings (h5) */
-.p-4 h5 {
+.page-wrapper h5 {
font-size: 1.2em;
line-height: 18px;
font-family: verdana, arial, helvetica, sans-serif;
}
/* Standard text elements - excluding review sections */
-.p-4>div:not([class*="review"]) p,
-.p-4>div:not([class*="review"]) span,
-.p-4>div:not([class*="review"]) label,
-.p-4 label {
+.page-wrapper>div:not([class*="review"]) p,
+.page-wrapper>div:not([class*="review"]) span,
+.page-wrapper>div:not([class*="review"]) label,
+.page-wrapper label {
font-size: 13px;
line-height: 30px;
}
@@ -84,40 +84,43 @@
}
/* Colors used for coloring score cells within the heatgrid for the grades view */
+/* These classes are returned as plain strings by getColorClass() and used globally
+ in FeedbackTable.tsx and ReviewTableRow.tsx, so they must not be locally scoped. */
+:global {
+ /* Null space in the table */
+ .c0 {
+ background-color: #d3d3d3;
+ }
-/* Null space in the table */
-.c0 {
- background-color: #d3d3d3;
-}
-
-/* Red, indicative of a poor score */
-.c1 {
- background-color: #ff8080;
-}
+ /* Red, indicative of a poor score */
+ .c1 {
+ background-color: #ff8080;
+ }
-/* Orange */
-.c2 {
- background-color: #FD992D;
-}
+ /* Orange */
+ .c2 {
+ background-color: #FD992D;
+ }
-/* Yellow, indicative of a median score */
-.c3 {
- background-color: #FFEC8B;
-}
+ /* Yellow, indicative of a median score */
+ .c3 {
+ background-color: #FFEC8B;
+ }
-/* Light green */
-.c4 {
- background-color: #BCED91;
-}
+ /* Light green */
+ .c4 {
+ background-color: #BCED91;
+ }
-/* Green, indicative of a good score */
-.c5 {
- background-color: #2DE636;
-}
+ /* Green, indicative of a good score */
+ .c5 {
+ background-color: #2DE636;
+ }
-/* Default background color */
-.cf {
- background-color: #FFFFFF;
+ /* Default background color */
+ .cf {
+ background-color: #FFFFFF;
+ }
}
/* Style for the grades in the summary report */
@@ -139,18 +142,18 @@
/* Styling for the heatgrid table */
.tbl_heat {
- border: 1px solid black;
+ border: 1px solid #ddd;
width: 100%;
font-size: 15px;
text-align: center;
table-layout: fixed;
- min-width: 600px; // Minimum width before scrolling kicks in
+ min-width: 600px; /* Minimum width before scrolling kicks in */
}
.tbl_heat td {
cursor: pointer;
padding: 2px;
- border: 1px black solid;
+ border: 1px solid #ddd;
width: auto;
font-size: 15px;
line-height: 1.428em;
@@ -168,46 +171,36 @@
padding: 8px 12px;
border-radius: 4px;
bottom: 130%;
- left: 0%;
+ /* Centre the tooltip over the cell; clamp prevents left/right overflow */
+ left: 50%;
+ transform: translateX(-50%);
white-space: normal;
- max-width: 400px;
+ max-width: 360px;
width: max-content;
- z-index: 100;
+ z-index: 200;
font-size: 13px;
line-height: 1.4;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
word-wrap: break-word;
}
+/* Last-column tooltip: anchor right so it doesn't overflow the right edge */
+.tbl_heat td[data-question]:last-child:hover::after {
+ left: auto;
+ right: 0;
+ transform: none;
+}
+
+/* Elevate the hovered cell above sticky columns so the tooltip is never obscured */
+.tbl_heat td[data-question]:hover {
+ z-index: 20;
+}
+
/* Ensure the tooltip is positioned relative to the weight circle when used */
.tbl_heat .weight-circle[data-question] {
position: relative;
}
-/* Tooltip for item prompt cells */
-.item-prompt-cell[data-tooltip] {
- position: relative;
-
- &:hover::after {
- content: attr(data-tooltip);
- position: absolute;
- background-color: rgba(0, 0, 0, 0.95);
- color: #ffffff;
- padding: 8px 12px;
- border-radius: 4px;
- bottom: 100%;
- left: 0;
- white-space: normal;
- max-width: 400px;
- width: max-content;
- z-index: 100;
- font-size: 13px;
- line-height: 1.4;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
- word-wrap: break-word;
- margin-bottom: 4px;
- }
-}
/* Styling for table headers */
.tbl_heat th {
@@ -220,49 +213,12 @@
table-layout: fixed;
}
-/* Sticky Item (prompt) column header - second column */
-.tbl_heat th:nth-child(2) {
- position: sticky;
- left: 0;
- z-index: 10;
- background-color: #f2f2f2;
-}
-
-/* Left-align item prompt header and cells */
-.item-prompt-header {
- text-align: left !important;
- padding: 3px 12px 3px 12px;
- /* add left/right padding for breathing room */
-}
-
-.item-prompt-cell {
- text-align: left !important;
- padding: 6px 12px 6px 12px;
- /* increase left padding so item text isn't flush to the cell edge */
-}
/* Hides padding for specific rows */
.hiddenRow {
padding: 0 !important;
}
-/* Sticky Item (prompt) column cells - second column */
-.tbl_heat td:nth-child(2) {
- position: sticky;
- left: 0;
- z-index: 9;
- background-color: #ffffff;
- padding-left: 12px;
- /* ensure sticky cell retains left padding */
-}
-
-/* Sticky Item (prompt) column in the average row */
-.no-bg td:nth-child(2) {
- position: sticky;
- left: 0;
- z-index: 9;
- background-color: #ffffff;
-}
/* Tooltip span styling */
.spn_tooltip {
@@ -368,68 +324,68 @@
}
-.round-heading {
- font-weight: bold;
- margin-top: 20px;
- font-size: 30px;
-}
-
-.review-heading {
- font-weight: bold;
- margin-top: 10px;
-}
-
-.review-block {
- border: 1px solid #ccc;
- padding: 10px;
- margin-bottom: 0;
-}
-
-.question {
- font-weight: bold;
-}
-
-.score {
- border-radius: 50%;
- width: 24px;
- height: 24px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: black;
- margin-right: 10px;
- font-weight: bold;
-}
+/* Classes used as plain strings in ShowReviews.tsx and FeedbackTable.tsx —
+ must not be locally scoped. */
+:global {
+ .round-heading {
+ font-weight: bold;
+ margin-top: 20px;
+ font-size: 30px;
+ }
-.comment {
- flex-grow: 1;
- /* Ensures comment fills the rest of the container */
- padding-top: 3px;
-}
+ .review-heading {
+ font-weight: bold;
+ margin-top: 10px;
+ }
-/* Style for even-numbered review blocks */
-.review-block:nth-child(even) {
- background-color: #d9edf7;
+ .review-block {
+ border: 1px solid #ccc;
+ padding: 10px;
+ margin-bottom: 0;
+ }
-}
+ /* Style for even-numbered review blocks */
+ .review-block:nth-child(even) {
+ background-color: #d9edf7;
+ }
-/* Style for odd-numbered review blocks */
-.review-block:nth-child(odd) {
- background-color: #fcf8e3;
+ /* Style for odd-numbered review blocks */
+ .review-block:nth-child(odd) {
+ background-color: #fcf8e3;
+ }
-}
+ .question {
+ font-weight: bold;
+ }
-.score-container {
- display: flex;
- align-items: center;
- margin-top: 5px;
- padding-top: 10px;
+ .score {
+ border-radius: 50%;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: black;
+ margin-right: 10px;
+ font-weight: bold;
+ }
-}
+ .comment {
+ flex-grow: 1;
+ /* Ensures comment fills the rest of the container */
+ padding-top: 3px;
+ }
+ .score-container {
+ display: flex;
+ align-items: center;
+ margin-top: 5px;
+ padding-top: 10px;
+ }
-.review-container {
- margin-bottom: 200px;
+ .review-container {
+ margin-bottom: 200px;
+ }
}
@@ -441,9 +397,9 @@
}
-// Styling the checkbox if needed
+ /* Styling the checkbox if needed */
.toggle-container input[type="checkbox"] {
margin: 0;
padding: 0;
- // Reset any unwanted inherited styles
+ /* Reset any unwanted inherited styles */
}
\ No newline at end of file
diff --git a/src/pages/ViewTeamGrades/__tests__/ReviewTable.test.tsx b/src/pages/ViewTeamGrades/__tests__/ReviewTable.test.tsx
deleted file mode 100644
index 228733d1..00000000
--- a/src/pages/ViewTeamGrades/__tests__/ReviewTable.test.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import React from 'react';
-import '@testing-library/jest-dom';
-import { render, screen } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import ReviewTable from '../ReviewTable';
-
-// Mock axios client and react-redux hooks used by ReviewTable
-vi.mock('../../utils/axios_client', () => ({ get: vi.fn(), post: vi.fn() }));
-vi.mock('react-redux', () => ({ useSelector: () => ({ user: { id: 1, role: 'Student' } }) }));
-
-describe('ReviewTable top-level behaviors', () => {
- it('uses "item prompts" wording for the toggle and shows team members with username', () => {
- render(
-
-
-
- );
-
- // Toggle label should show Show item prompts initially
- const toggle = screen.getByRole('checkbox', { name: /show item prompts/i });
- expect(toggle).toBeInTheDocument();
-
- // Team members text exists and contains parentheses for username
- const tm = screen.getByText(/Team members:/i);
- expect(tm).toBeInTheDocument();
- });
-});
diff --git a/src/pages/ViewTeamGrades/__tests__/ReviewTableRow.test.tsx b/src/pages/ViewTeamGrades/__tests__/ReviewTableRow.test.tsx
deleted file mode 100644
index 90a9451c..00000000
--- a/src/pages/ViewTeamGrades/__tests__/ReviewTableRow.test.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import React from 'react';
-import { render, screen, fireEvent } from '@testing-library/react';
-import ReviewTableRow from '../ReviewTableRow';
-import '@testing-library/jest-dom';
-import type { Mock } from 'vitest';
-
-const sampleRow = {
- itemNumber: '1',
- itemText: 'This is the item prompt that is a little long',
- itemType: 'TextArea',
- reviews: [
- { name: 'Alice', textResponse: 'This is a longer response that should be truncated in the table cell' },
- { name: 'Bob', textResponse: 'Short' }
- ],
- RowAvg: 4.5,
- maxScore: 5
-};
-
-describe('ReviewTableRow', () => {
- // Suppress specific deprecation warning from react-dom test utils about act
- const originalConsoleError = console.error;
- beforeAll(() => {
- vi.spyOn(console, 'error').mockImplementation((...args: any[]) => {
- const msg = args?.[0] ? String(args[0]) : '';
- if (msg.includes('ReactDOMTestUtils.act') || msg.includes('is deprecated in favor of `React.act`')) {
- return;
- }
- originalConsoleError(...args);
- });
- });
- afterAll(() => {
- (console.error as Mock).mockRestore();
- });
-
- it('renders truncated text and expands on click of dots', () => {
- render();
-
- // item prompt truncated
- const promptCell = screen.getByText(/This is the item prompt/);
- expect(promptCell).toBeInTheDocument();
-
- // truncated response should show ellipses
- const dots = screen.getAllByText('...')[0];
- expect(dots).toBeInTheDocument();
- fireEvent.click(dots);
- // after click, it should show [show less]
- expect(screen.getByText('[show less]')).toBeInTheDocument();
- });
-
- it('sets data-question attribute on review cells for tooltip use and clicks call handler', () => {
- const onClick = vi.fn();
- render();
- const cells = screen.getAllByRole('cell');
- // verify there is at least one cell with data-question attribute
- const hasDataQuestion = cells.some((c) => c.getAttribute('data-question') !== null);
- expect(hasDataQuestion).toBe(true);
-
- // With showToggleQuestion=false, cell ordering is: item-number (0), review0 (1), review1 (2), ..., average (n)
- // click the second review (index 1)
- const secondReviewCell = cells[1 + 1]; // offset by 1 for item-number
- fireEvent.click(secondReviewCell);
- expect(onClick).toHaveBeenCalledWith(1);
- });
-});
diff --git a/src/pages/ViewTeamGrades/__tests__/ShowReviews.test.tsx b/src/pages/ViewTeamGrades/__tests__/ShowReviews.test.tsx
deleted file mode 100644
index cd162d25..00000000
--- a/src/pages/ViewTeamGrades/__tests__/ShowReviews.test.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import React from 'react';
-import { render, screen, fireEvent } from '@testing-library/react';
-import ShowReviews from '../ShowReviews';
-import '@testing-library/jest-dom';
-
-// Mock axios client to avoid importing ESM axios during tests
-vi.mock('../../utils/axios_client', () => ({
- get: vi.fn(),
- post: vi.fn(),
-}));
-
-// Mock react-redux hooks so we don't need redux-mock-store
-vi.mock('react-redux', () => ({
- useSelector: () => ({ user: { role: 'Instructor', id: 1 }, isAuthenticated: true }),
- useDispatch: () => () => {},
-}));
-
-const sampleData = [
- [
- { questionText: 'Q1', reviews: [{ score: 4, comment: 'ok' }, { score: 3 }] , maxScore: 5 },
- { questionText: 'Q2', reviews: [{ textResponse: 'long response from a student here' }, { textResponse: 'short' }], maxScore: 1 }
- ],
- [
- { questionText: 'Q1', reviews: [{ score: 5 }, { score: 4 }], maxScore: 5 }
- ]
-];
-
-describe('ShowReviews', () => {
- it('toggles expand all and individual review expansion', async () => {
- render( );
-
- // Show all reviews button present
- const button = screen.getByRole('button', { name: /Show all reviews|Hide all reviews/ });
- expect(button).toBeInTheDocument();
- fireEvent.click(button);
- // After clicking, button text toggles
- expect(button.textContent?.toLowerCase()).toContain('hide');
-
- // Click to expand first round (button contains 'Round 1')
- const roundToggle = await screen.findByText(/Round 1/);
- fireEvent.click(roundToggle);
- // After expansion, review 1 button should be visible
- expect(await screen.findByText(/Review 1/)).toBeInTheDocument();
- });
-
- it('auto-expands target review when provided', async () => {
- render( );
-
- // The target review should auto expand its round and review
- expect(await screen.findByText(/Review 2/)).toBeInTheDocument();
- });
-});
diff --git a/src/pages/ViewTeamGrades/__tests__/utils.test.ts b/src/pages/ViewTeamGrades/__tests__/utils.test.ts
deleted file mode 100644
index f7001bc5..00000000
--- a/src/pages/ViewTeamGrades/__tests__/utils.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { convertBackendRoundArray } from '../utils';
-
-describe('convertBackendRoundArray', () => {
- it('parses multiple item types correctly', () => {
- const backendRounds = [
- [
- [
- { reviewer_name: 'Alice', answer: 4, txt: 'Item 1 text', item_type: 'Criterion' },
- { reviewer_name: 'Bob', answer: 3, txt: 'Item 1 text', item_type: 'Criterion' }
- ],
- [
- { reviewer_name: 'Alice', answer: ['A', 'B'], txt: 'Item 2 text', item_type: 'Checkbox' },
- { reviewer_name: 'Bob', answer: ['B'], txt: 'Item 2 text', item_type: 'Checkbox' }
- ],
- [
- { reviewer_name: 'Alice', answer: 'Some long text response here', txt: 'Item 3 text', item_type: 'TextArea' }
- ],
- [
- { reviewer_name: 'Alice', answer: 'Option 1', txt: 'Item 4 text', item_type: 'Dropdown' }
- ],
- [
- { reviewer_name: 'Alice', fileName: 'file.pdf', fileUrl: 'http://example.com/file.pdf', txt: 'Item 5 text', item_type: 'File' }
- ]
- ]
- ];
-
- const converted = convertBackendRoundArray(backendRounds);
- expect(Array.isArray(converted)).toBe(true);
- const round = converted[0];
- // item counts
- expect(round.length).toBe(5);
- // Criterion -> scores
- expect(round[0].reviews[0].score).toBe(4);
- // Checkbox -> selections
- expect(round[1].reviews[0].selections).toEqual(['A', 'B']);
- // Text area -> textResponse
- expect(round[2].reviews[0].textResponse).toContain('long text');
- // Dropdown -> selectedOption
- expect(round[3].reviews[0].selectedOption).toBe('Option 1');
- // File -> fileName and fileUrl
- expect(round[4].reviews[0].fileName).toBe('file.pdf');
- expect(round[4].reviews[0].fileUrl).toBe('http://example.com/file.pdf');
- });
-});
diff --git a/src/pages/ViewTeamGrades/utils.ts b/src/pages/ViewTeamGrades/heatgridUtils.ts
similarity index 62%
rename from src/pages/ViewTeamGrades/utils.ts
rename to src/pages/ViewTeamGrades/heatgridUtils.ts
index f5afd7a7..5a78eec0 100644
--- a/src/pages/ViewTeamGrades/utils.ts
+++ b/src/pages/ViewTeamGrades/heatgridUtils.ts
@@ -1,4 +1,11 @@
-import { ReviewData } from './App';
+import { ReviewData, SectionHeaderData } from './App';
+
+// Type alias for a mixed array that may contain scored rows or section heading sentinels
+export type RoundRow = ReviewData | SectionHeaderData;
+
+/** Returns true when a RoundRow is a SectionHeaderData sentinel (not a scored row) */
+export const isHeader = (row: RoundRow): row is SectionHeaderData =>
+ (row as SectionHeaderData).type === "header";
// Helper function to normalize data from old format (questionNumber/questionText) to new format (itemNumber/itemText)
export const normalizeReviewData = (data: any): ReviewData => {
@@ -12,17 +19,29 @@ export const normalizeReviewData = (data: any): ReviewData => {
};
};
-// Function to normalize an array of review data
-export const normalizeReviewDataArray = (dataArray: any[]): ReviewData[] => {
- return dataArray.map(normalizeReviewData);
+// Normalize an array of review data, passing SectionHeader sentinels through unchanged.
+export const normalizeReviewDataArray = (dataArray: any[]): RoundRow[] => {
+ return dataArray.map(item => {
+ if (item && item.type === "header") return item as SectionHeaderData;
+ return normalizeReviewData(item);
+ });
};
-// Convert backend rounds array (array of arrays of answer objects) to frontend round format
-export const convertBackendRoundArray = (backendRounds: any[][]): ReviewData[][] => {
+// Convert backend rounds array (array of arrays of answer objects) to frontend round format.
+// Each element in a round may be either an array of reviewer answers (a scored item) or a
+// { type: "header", txt: "..." } sentinel injected by the backend for SectionHeader items.
+// Sentinel objects are passed through as-is; scored items are converted to ReviewData.
+export const convertBackendRoundArray = (backendRounds: any[][]): RoundRow[][] => {
if (!Array.isArray(backendRounds)) return [];
return backendRounds.map((backendRound) => {
if (!Array.isArray(backendRound)) return [];
- return backendRound.map((answersArray: any[], idx: number) => {
+ let scoredItemCount = 0;
+ return backendRound.map((answersArray: any) => {
+ // Pass SectionHeader sentinels through unchanged — do NOT increment the counter
+ if (answersArray && !Array.isArray(answersArray) && answersArray.type === "header") {
+ return answersArray as SectionHeaderData;
+ }
+ scoredItemCount += 1;
const firstAnswer = answersArray?.[0];
const itemType = firstAnswer?.item_type || firstAnswer?.itemType;
@@ -67,7 +86,7 @@ export const convertBackendRoundArray = (backendRounds: any[][]): ReviewData[][]
const maxScore = reviews.every((r: any) => r.score === 0 || r.score === 1) ? 1 : 5;
return {
- itemNumber: String(idx + 1),
+ itemNumber: String(scoredItemCount),
itemText: (answersArray && answersArray[0] && answersArray[0].txt) || '',
itemType,
reviews,
@@ -96,15 +115,20 @@ export const getColorClass = (score: number, maxScore: number) => {
else return 'cf';
};
-// Function to calculate averages for rows and columns
+// Calculate row/column averages. Accepts a mixed RoundRow[] (which may include
+// SectionHeaderData sentinels) — headers are skipped so they don't skew the averages.
+// sortedData in the return value preserves header positions when sortOrderRow === 'none'.
export const calculateAverages = (
- currentRoundData: ReviewData[],
+ currentRoundData: RoundRow[],
sortOrderRow: 'asc' | 'desc' | 'none'
) => {
+ // Work only on scored rows for numeric calculations
+ const scoredRows = currentRoundData.filter(r => !isHeader(r)) as ReviewData[];
+
let totalAvg = 0;
let itemCount = 0;
let totalMaxScore = 0;
- currentRoundData.forEach((row) => {
+ scoredRows.forEach((row) => {
const sum = row.reviews.reduce((acc, val) => acc + (val.score || 0), 0);
row.RowAvg = sum / row.reviews.length;
totalAvg = row.RowAvg + totalAvg;
@@ -117,9 +141,12 @@ export const calculateAverages = (
? (((totalAvg / totalMaxScore) * 100) > 0 ? ((totalAvg / totalMaxScore) * 100).toFixed(2) : '0.00')
: '0.00';
- const columnAverages: number[] = Array.from({ length: currentRoundData[0].reviews.length }, () => 0);
+ const firstScored = scoredRows[0];
+ const columnAverages: number[] = firstScored
+ ? Array.from({ length: firstScored.reviews.length }, () => 0)
+ : [];
- currentRoundData.forEach((row) => {
+ scoredRows.forEach((row) => {
row.reviews.forEach((val, index) => {
columnAverages[index] += (val.score || 0);
});
@@ -129,12 +156,25 @@ export const calculateAverages = (
columnAverages[index] = (sum / totalMaxScore) * 5;
});
- let sortedData = [...currentRoundData];
-
- if (sortOrderRow === 'asc') {
- sortedData = currentRoundData.slice().sort((a, b) => a.RowAvg - b.RowAvg);
- } else if (sortOrderRow === 'desc') {
- sortedData = currentRoundData.slice().sort((a, b) => b.RowAvg - a.RowAvg);
+ // When sorting, headers stay in place (only scored rows are reordered).
+ // For 'none' the full mixed array (with headers) is returned as-is.
+ let sortedData: RoundRow[];
+ if (sortOrderRow === 'none') {
+ sortedData = [...currentRoundData];
+ } else {
+ const sorted = scoredRows.slice().sort((a, b) =>
+ sortOrderRow === 'asc' ? a.RowAvg - b.RowAvg : b.RowAvg - a.RowAvg
+ );
+ // Re-insert headers at their original positions
+ sortedData = [];
+ let scoredIdx = 0;
+ currentRoundData.forEach(row => {
+ if (isHeader(row)) {
+ sortedData.push(row);
+ } else {
+ sortedData.push(sorted[scoredIdx++]);
+ }
+ });
}
return { averagePeerReviewScore, columnAverages, sortedData };
diff --git a/src/utils/dataFormatter.ts b/src/utils/dataFormatter.ts
new file mode 100644
index 00000000..7584dc19
--- /dev/null
+++ b/src/utils/dataFormatter.ts
@@ -0,0 +1,28 @@
+/**
+ * Capitalizes the first letter of a sentence and converts the rest to lowercase.
+ *
+ * @param str - The input string to capitalize.
+ * @returns A string with the first letter capitalized and the rest in lowercase.
+ */
+export const capitalizeFirstWord = (str: string) => {
+ return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
+};
+
+/**
+ * Formats a date string into a readable format (e.g., "Oct 12, 2023, 3:45 PM").
+ *
+ * @param dateString - The input date string.
+ * @returns A formatted date string in "MMM DD, YYYY, hh:mm AM/PM" format.
+ */
+export const formatDate = (dateString: string): string => {
+ const date = new Date(dateString);
+ const options: Intl.DateTimeFormatOptions = {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "numeric",
+ hour12: true,
+ };
+ return date.toLocaleString("en-US", options);
+};