Files
BoostAI/Frontend/src/components/assignment/teacher/assignment-teacher-review.tsx
2026-05-25 17:05:06 +01:00

355 lines
12 KiB
TypeScript

// Path: Frontend/src/components/assignment/teacher/assignment-teacher-review.tsx
import type { Component } from "solid-js";
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
import { useNavigate } from "@solidjs/router";
import type { TeacherAssignmentReviewPageData, TeacherReviewDraftFields, TeacherReviewNotice, TeacherReviewQuestion } from "./assignment-teacher-review.types";
import { closeTeacherAssignment, updateTeacherAnswerReview } from "./assignment-teacher-review.data";
import {
applyReviewDrafts,
type AssignmentReviewDraftStore,
countPendingQuestionDrafts,
EMPTY_DRAFT_STORE,
type StudentReviewDraft,
readDraftStore,
reviewFieldsEqual,
reviewFieldsFromQuestion,
sanitizeReviewFields,
writeDraftStore,
} from "./assignment-teacher-review.drafts";
import styles from "./assignment-teacher-review.module.scss";
import { TeacherReviewQuestionCard, TeacherReviewSaveProgressCard, TeacherReviewSidebar } from "./assignment-teacher-review.sections";
import { getTeacherAssignmentNextStepHref } from "../../../lib/routes";
type Props = {
data: TeacherAssignmentReviewPageData;
onSelectStudent: (studentId: number) => void;
onRefresh: () => Promise<unknown> | unknown;
};
const AssignmentTeacherReview: Component<Props> = (props) => {
const navigate = useNavigate();
let questionReviewSectionRef: HTMLElement | undefined;
const [draftStore, setDraftStore] = createSignal<AssignmentReviewDraftStore>(EMPTY_DRAFT_STORE);
const [savingAll, setSavingAll] = createSignal(false);
const [closingAssignment, setClosingAssignment] = createSignal(false);
const [notice, setNotice] = createSignal<TeacherReviewNotice>(null);
const [closeNotice, setCloseNotice] = createSignal<TeacherReviewNotice>(null);
const [questionNavExpanded, setQuestionNavExpanded] = createSignal(false);
const currentStudentKey = createMemo(() => (props.data.selectedStudentId ? String(props.data.selectedStudentId) : null));
const currentStudentDraft = createMemo(() => {
const studentKey = currentStudentKey();
if (!studentKey) return null;
return draftStore().students[studentKey] ?? null;
});
const displayQuestions = createMemo(() => applyReviewDrafts(props.data.questions, currentStudentDraft()));
const pendingChangeCount = createMemo(() => countPendingQuestionDrafts(draftStore()));
createEffect(() => {
setDraftStore(readDraftStore(props.data.assignmentId));
});
createEffect(() => {
writeDraftStore(props.data.assignmentId, draftStore());
});
createEffect(() => {
setNotice(null);
});
createEffect(() => {
setCloseNotice(null);
});
const hasQueue = createMemo(() => props.data.reviewQueue.length > 0);
const canMoveToNextStep = createMemo(() => {
return props.data.selectedStudentSubmittedQuestions > 0 || props.data.selectedStudentReviewedQuestions > 0;
});
const getBaseQuestion = (questionId: number) => props.data.questions.find((entry) => entry.id === questionId);
const getSelectedStudentQuestions = () => props.data.questions.filter((question) => question.answerId);
const getQuestionCardId = (questionId: number) => `teacher-review-question-${questionId}`;
const handleSelectQuestion = (questionId: number) => {
const element = document.getElementById(getQuestionCardId(questionId));
element?.scrollIntoView({ behavior: "smooth", block: "start" });
setQuestionNavExpanded(false);
};
const handleScrollToTop = () => {
questionReviewSectionRef?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const updateStudentDraft = (studentId: number, updater: (draft: StudentReviewDraft) => StudentReviewDraft | null) => {
const studentKey = String(studentId);
setDraftStore((current) => {
const nextStudents = { ...current.students };
const nextDraft = updater(nextStudents[studentKey] ?? {});
if (!nextDraft) {
delete nextStudents[studentKey];
} else {
nextStudents[studentKey] = nextDraft;
}
return {
students: nextStudents,
};
});
};
const updateQuestionReviewDraft = (question: TeacherReviewQuestion, patch: Partial<TeacherReviewDraftFields>) => {
if (!question.answerId || !props.data.selectedStudentId) return;
setNotice(null);
const baseQuestion = getBaseQuestion(question.id) ?? question;
const baseFields = sanitizeReviewFields(reviewFieldsFromQuestion(baseQuestion));
updateStudentDraft(props.data.selectedStudentId, (draft) => {
const questionReviews = { ...(draft.questionReviews ?? {}) };
const currentFields = sanitizeReviewFields(
questionReviews[String(question.id)]
? {
...baseFields,
...questionReviews[String(question.id)],
}
: baseFields
);
const nextFields = sanitizeReviewFields({
...currentFields,
...patch,
});
if (reviewFieldsEqual(nextFields, baseFields)) {
delete questionReviews[String(question.id)];
} else {
questionReviews[String(question.id)] = {
answerId: question.answerId,
...nextFields,
};
}
const nextDraft: StudentReviewDraft = {
...draft,
};
if (Object.keys(questionReviews).length > 0) {
nextDraft.questionReviews = questionReviews;
} else {
delete nextDraft.questionReviews;
}
if (Object.keys(nextDraft.questionReviews ?? {}).length === 0) {
return null;
}
return nextDraft;
});
};
const resetQuestionDraft = (question: TeacherReviewQuestion) => {
const selectedStudentId = props.data.selectedStudentId;
if (!selectedStudentId) return;
setNotice(null);
updateStudentDraft(selectedStudentId, (draft) => {
const questionReviews = { ...(draft.questionReviews ?? {}) };
delete questionReviews[String(question.id)];
if (Object.keys(questionReviews).length === 0) {
return null;
}
return {
...draft,
questionReviews,
};
});
};
const markQuestionNeedsAttention = (question: TeacherReviewQuestion) => {
updateQuestionReviewDraft(question, {
reviewNeedsAttention: true,
});
};
const saveAllAndNextStep = async () => {
setNotice(null);
const selectedStudentId = props.data.selectedStudentId;
if (!selectedStudentId) return;
if (!canMoveToNextStep()) {
setNotice({
scope: "global",
tone: "error",
text: "Students need a submitted review before you can move them to the next step.",
});
return;
}
const operations: Array<() => Promise<unknown>> = [];
const selectedStudentDraft = draftStore().students[String(selectedStudentId)] ?? null;
const questionDrafts = selectedStudentDraft?.questionReviews ?? {};
for (const question of getSelectedStudentQuestions()) {
const draft = questionDrafts[String(question.id)];
if (!question.answerId) continue;
const baseFields = sanitizeReviewFields(reviewFieldsFromQuestion(question));
const effectiveReview = sanitizeReviewFields(
draft
? {
...baseFields,
...draft,
}
: baseFields
);
const nextStatus = effectiveReview.reviewNeedsAttention ? "submitted" : "reviewed";
const hasReviewFieldChanges = !reviewFieldsEqual(effectiveReview, baseFields);
if (question.answerStatus === nextStatus && !hasReviewFieldChanges) continue;
operations.push(() =>
updateTeacherAnswerReview(question.answerId!, {
status: nextStatus,
reviewTags: [...question.questionTags],
...effectiveReview,
})
);
}
setSavingAll(true);
try {
for (const operation of operations) {
await operation();
}
updateStudentDraft(selectedStudentId, () => null);
navigate(getTeacherAssignmentNextStepHref(props.data.assignmentId, selectedStudentId));
} catch (error) {
setNotice({
scope: "global",
tone: "error",
text: error instanceof Error ? error.message : "Could not save all review changes right now.",
});
} finally {
setSavingAll(false);
}
};
const handleCloseAssignment = async () => {
setCloseNotice(null);
if (!props.data.closeSummary.canClose) {
setCloseNotice({
scope: "assignment",
tone: "error",
text: props.data.closeSummary.summary,
});
return;
}
setClosingAssignment(true);
try {
await closeTeacherAssignment(props.data.assignmentId);
await props.onRefresh();
setCloseNotice({
scope: "assignment",
tone: "success",
text: "Assignment closed.",
});
} catch (error) {
setCloseNotice({
scope: "assignment",
tone: "error",
text: error instanceof Error ? error.message : "Could not close this assignment right now.",
});
} finally {
setClosingAssignment(false);
}
};
return (
<>
<div class={styles.contentGrid}>
<div class={styles.mainColumn}>
<section ref={questionReviewSectionRef} class={styles.section}>
<div class={styles.sectionHeader}>
<h2>Question review</h2>
<p>
<Show when={props.data.selectedStudentName} fallback={hasQueue() ? "Choose a student to begin reviewing." : "This assignment has no student review queue yet."}>
{props.data.selectedStudentName} · {props.data.questions.length} questions
</Show>
</p>
</div>
<Show when={props.data.selectedStudentId !== null && displayQuestions().length > 0}>
<section class={styles.mobileStickyNav}>
<div class={styles.mobileStickyNavHeader}>
<div>
<h3>Questions</h3>
<p>{displayQuestions().length} questions in this review</p>
</div>
<div class={styles.mobileStickyNavActions}>
<button type="button" class={styles.mobileStickyNavBackToTop} onClick={handleScrollToTop}>
Back to top
</button>
<button
type="button"
class={styles.mobileStickyNavToggle}
aria-expanded={questionNavExpanded()}
onClick={() => setQuestionNavExpanded((value) => !value)}
>
{questionNavExpanded() ? "Hide list" : "Open list"}
</button>
</div>
</div>
<div
classList={{
[styles.mobileStickyNavContent]: true,
[styles.mobileStickyNavContentExpanded]: questionNavExpanded(),
}}
>
<div class={styles.mobileQuestionNavList}>
<For each={displayQuestions()}>
{(question) => (
<button
type="button"
class={`${styles.mobileQuestionNavButton} ${question.answerId ? styles.mobileQuestionNavAnswered : ""}`.trim()}
onClick={() => handleSelectQuestion(question.id)}
>
<strong>Q{question.order}</strong>
<small>{question.statusLabel}</small>
</button>
)}
</For>
</div>
</div>
</section>
</Show>
<Show when={props.data.selectedStudentId !== null} fallback={<div class={styles.emptyState}>Select a student from the review queue to inspect answers and leave feedback.</div>}>
<div class={styles.questionList}>
<For each={displayQuestions()}>
{(question) => (
<TeacherReviewQuestionCard
cardId={getQuestionCardId(question.id)}
question={question}
notice={notice()}
hasPendingDraft={Boolean(currentStudentDraft()?.questionReviews?.[String(question.id)])}
savingAll={savingAll()}
onResetDraft={resetQuestionDraft}
onNeedsAttention={markQuestionNeedsAttention}
onReviewFieldChange={updateQuestionReviewDraft}
/>
)}
</For>
</div>
<TeacherReviewSaveProgressCard
canMoveToNextStep={canMoveToNextStep()}
pendingChangeCount={pendingChangeCount()}
savingAll={savingAll()}
closeState={props.data.closeSummary.state}
notice={notice()}
onSave={saveAllAndNextStep}
/>
</Show>
</section>
</div>
<TeacherReviewSidebar data={props.data} hasQueue={hasQueue()} closingAssignment={closingAssignment()} closeNotice={closeNotice()} onSelectStudent={props.onSelectStudent} onCloseAssignment={handleCloseAssignment} />
</div>
</>
);
};
export default AssignmentTeacherReview;