diff --git a/src/App.tsx b/src/App.tsx index 27736ba3..20c4f818 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,9 +37,11 @@ import Email_the_author from "./pages/Email_the_author/email_the_author"; import CreateTeams from "pages/Assignments/CreateTeams"; import AssignReviewer from "pages/Assignments/AssignReviewer"; import ViewSubmissions from "pages/Assignments/ViewSubmissions"; +import AssignGrades, { assignGradesLoader } from "pages/Assignments/AssignGrades"; import ViewScores from "pages/Assignments/ViewScores"; import ViewReports from "pages/Assignments/ViewReports"; import ViewDelayedJobs from "pages/Assignments/ViewDelayedJobs"; +import SubmissionHistoryView from "./pages/Submissions/SubmissionHistoryView"; function App() { const router = createBrowserRouter([ { @@ -75,6 +77,11 @@ function App() { element: , loader: loadAssignment, }, + { + path: "/assignments/:assignmentId/assign-grades", + element: , + loader: assignGradesLoader + }, { path: "assignments/edit/:id/viewscores", element: , @@ -90,6 +97,10 @@ function App() { element: , loader: loadAssignment, }, + { + path: "submissions/history/:id", + element: , + }, { path: "assignments", element: } leastPrivilegeRole={ROLE.TA} />, @@ -138,6 +149,8 @@ function App() { }, ], }, + // Legacy route redirect: keep supporting old student_tasks path + { path: "student_tasks", element: }, { path: "profile", element: } />, diff --git a/src/hooks/useAPI.ts b/src/hooks/useAPI.ts index 47ba7ee5..644cabef 100644 --- a/src/hooks/useAPI.ts +++ b/src/hooks/useAPI.ts @@ -29,6 +29,87 @@ const useAPI = () => { setIsLoading(true); setError(""); + + // Development mock handlers: allow working without a backend + if (process.env.NODE_ENV === "development") { + const url = (requestConfig.url || "").toString(); + const method = (requestConfig.method || "get").toString().toLowerCase(); + + // Simple in-memory mock data + const mockAssignments = [ + { + id: 1, + name: "Mock Assignment", + directory_path: "mock/path", + spec_location: "", + private: false, + show_template_review: false, + require_quiz: false, + has_badge: false, + staggered_deadline: false, + is_calibrated: false, + course_id: 1, + }, + ]; + const mockCourses = [{ id: 1, name: "Mock Course" }]; + + const makeResponse = (data: any, status = 200) => { + const resp: AxiosResponse = { + data: data, + status: status, + statusText: status === 200 ? "OK" : "Created", + headers: {}, + config: requestConfig, + } as AxiosResponse; + return resp; + }; + + // Simulate network latency + setTimeout(() => { + try { + if (url === "/assignments" && method === "get") { + setData(makeResponse(mockAssignments)); + setIsLoading(false); + return; + } + + const assignmentIdMatch = url.match(/^\/assignments\/(\d+)/); + if (assignmentIdMatch && method === "get") { + const id = parseInt(assignmentIdMatch[1], 10); + const found = mockAssignments.find((a) => a.id === id) || mockAssignments[0]; + setData(makeResponse(found)); + setIsLoading(false); + return; + } + + if (url === "/assignments" && (method === "post" || method === "put")) { + // create or update - echo back created assignment with id + let payload: any = requestConfig.data || {}; + try { + if (typeof payload === "string") payload = JSON.parse(payload); + } catch (e) { + // ignore + } + const created = { id: Math.floor(Math.random() * 10000) + 2, ...payload }; + setData(makeResponse(created, 201)); + setIsLoading(false); + return; + } + + if (url === "/courses" && method === "get") { + setData(makeResponse(mockCourses)); + setIsLoading(false); + return; + } + + // Default: fall through to real network call if not matched + } catch (err) { + setError((err as Error).message || "Mock error"); + setIsLoading(false); + } + }, 200); + } + let errorMessage = ""; axios(requestConfig) @@ -51,8 +132,8 @@ const useAPI = () => { } if (errorMessage) setError(errorMessage); - }); - setIsLoading(false); + }) + .finally(() => setIsLoading(false)); }, []); return { data, setData, isLoading, error, sendRequest }; diff --git a/src/layout/Header.tsx b/src/layout/Header.tsx index 5b278dd8..3c03fbe4 100644 --- a/src/layout/Header.tsx +++ b/src/layout/Header.tsx @@ -143,7 +143,7 @@ const Header: React.FC = () => { )} - + Assignments diff --git a/src/pages/Assignments/AssignGrades.tsx b/src/pages/Assignments/AssignGrades.tsx new file mode 100644 index 00000000..48d2250b --- /dev/null +++ b/src/pages/Assignments/AssignGrades.tsx @@ -0,0 +1,359 @@ +import React, { useMemo, useState } from "react"; +import { Container, Row, Col, Button, Alert } from "react-bootstrap"; +import { useLoaderData, useNavigate, useSearchParams } from "react-router-dom"; +import { calculateAverages, getColorClass } from "../ViewTeamGrades/utils"; +import "./assignments.scss"; + +// ----------- types ----------- +type Reviewer = { id: number; name: string }; +type RubricRow = { questionNo: number; scores: Record }; +type LinkItem = { name: string; url: string }; + +type LoaderData = { + assignment: { id: number; name: string }; + team: { id: number; name: string }; + reviewers: Reviewer[]; + rubric: RubricRow[]; + links: LinkItem[]; + existing?: { grade?: number; comment?: string }; +}; + +// ---------- mock loader data for now ---------- +const USE_MOCK = true; +function makeMock(assignmentId: number, teamId: number): LoaderData { + const reviewers: Reviewer[] = [ + { id: 201, name: "Srinidhi Shivakumarasa" }, + { id: 202, name: "Aryel" }, + ]; + // Include each base score 0..5 but with random decimals. + const clamp = (x: number, lo = 0, hi = 5) => Math.max(lo, Math.min(hi, x)); + const jitter = (base: number) => { + const r = Math.random(); // [0, 1) + if (base === 0) return clamp(base + r); + if (base === 5) return clamp(base - r); + const sign = Math.random() < 0.5 ? -1 : 1; + return clamp(base + sign * r); // wiggle within [0,5] + }; + const cycle = [0, 1, 2, 3, 4, 5]; + const rubric: RubricRow[] = Array.from({ length: 12 }, (_, i) => { + const v = cycle[i % cycle.length]; + const s1 = jitter(v); + const s2 = jitter(5 - v); + return { + questionNo: i + 1, + scores: { 201: s1, 202: s2 }, + }; + }); + return { + assignment: { id: assignmentId, name: "Program 1" }, + team: { id: teamId, name: "Ash, Srinidhi Team" }, + reviewers, + rubric, + links: [ + { name: "Submission ZIP", url: "https://example.com/submission.zip" }, + { name: "GitHub repo", url: "https://github.com/example/repo" }, + ], + existing: {}, + }; +} + +export async function assignGradesLoader({ params, request }: any): Promise { + const url = new URL(request.url); + const teamId = Number(url.searchParams.get("team_id") || 0); + const assignmentId = Number(params.assignmentId || 0); + if (USE_MOCK) return makeMock(assignmentId, teamId); + + const res = await fetch(`/api/v1/assignments/${assignmentId}/teams/${teamId}/summary`); + if (!res.ok) throw new Error("Failed to load"); + return (await res.json()) as LoaderData; +} + +// ---------- helpers ---------- +type RowSort = "none" | "asc" | "desc"; + +const AssignGrades: React.FC = () => { + const data = useLoaderData() as LoaderData; + const navigate = useNavigate(); + const [search] = useSearchParams(); + + const [showSubmission, setShowSubmission] = useState(false); + const [grade, setGrade] = useState((data.existing?.grade ?? "").toString()); + const [comment, setComment] = useState(data.existing?.comment ?? ""); + const [saving, setSaving] = useState(false); + const [savedMsg, setSavedMsg] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + // Mirror ReviewTable behavior: optional "Question" column + sort by Avg + const [showToggleQuestion, setShowToggleQuestion] = useState(false); + const [sortOrderRow, setSortOrderRow] = useState("none"); + // dummy UI state for the two checkboxes + const [gt10Words, setGt10Words] = useState(false); + const [gt20Words, setGt20Words] = useState(false); + const toggleSortOrderRow = () => { + setSortOrderRow(prev => + prev === "asc" ? "desc" : prev === "desc" ? "none" : "asc" + ); + }; + + // Use the same data shape & helpers as ReviewTable + const MAX_PER_REVIEW = 5; + const currentRoundData = useMemo(() => { + if (!Array.isArray(data.rubric) || !Array.isArray(data.reviewers)) return []; + return data.rubric.map((r) => ({ + questionNo: r.questionNo, + maxScore: MAX_PER_REVIEW, + reviews: data.reviewers.map((rv) => ({ score: Number(r.scores[rv.id] ?? 0) })), + })); + }, [data.rubric, data.reviewers]); + + const { averagePeerReviewScore, columnAverages, sortedData } = useMemo(() => { + const clone = currentRoundData.map(row => ({ ...row, reviews: row.reviews.map(x => ({ ...x })) })); + return calculateAverages(clone as any, sortOrderRow); + }, [currentRoundData, sortOrderRow]); + + return ( + + + +

Summary Report for assignment: {data.assignment.name}

+ +
+ + + +
Team: {data.team.name}
+
There are no reviews for this assignment
+ + +
+ + {showSubmission && ( + + +
    + {data.links.map((l, i) => ( +
  • + {l.name} +
  • + ))} +
+ +
+ )} + + {savedMsg && ( + setSavedMsg(null)}> + {savedMsg} + + )} + {errorMsg && ( + setErrorMsg(null)}> + {errorMsg} + + )} + + + + {/* Heading + inline legend/toggles to match screenshot */} + +
+ + +
+ + {/* Recreated table with the same structure/CSS as ReviewTable */} +
+

Teammate Review

+ + + {/* white header row (no gray background) */} + + + {showToggleQuestion && ( + + )} + {data.reviewers.map((r) => ( + + ))} + + + + + {sortedData.map((row: any) => ( + + + {showToggleQuestion && ( + + )} + {row.reviews.map((rv: any, i: number) => ( + + ))} + + + ))} + + + {showToggleQuestion && } + {columnAverages.map((avg: number, index: number) => ( + + ))} + + +
+ Question + + Question + + {r.name} + + Avg{" "} + {sortOrderRow === "none" ? ▲▼ : sortOrderRow === "asc" ? : } +
+ {row.questionNo} + + {/* No question text in our loader shape; leave blank / plug in if available */} + + {Number(rv.score ?? 0).toFixed(2)} + + {Number(row.RowAvg ?? 0).toFixed(2)} +
+ Avg + + {avg.toFixed(2)} +
+
+ +
+
+ +
+ + {/* Grade & Comments */} + + +

Grade and comment for submission

+
{ + e.preventDefault(); + const g = grade === "" ? NaN : Number(grade); + if (Number.isNaN(g) || g < 0 || g > 100) { + setErrorMsg("Grade must be a number between 0 and 100."); + return; + } + setSaving(true); + (async () => { + try { + if (USE_MOCK) await new Promise(r => setTimeout(r, 350)); + setSavedMsg("Saved!"); + } catch (err: any) { + setErrorMsg(err?.message || "Failed to save"); + } finally { + setSaving(false); + } + })(); + }}> +
+ + setGrade(e.target.value)} + placeholder="Grade" + /> +
+
+ +