Boost Azure Demo
This commit is contained in:
@@ -17,7 +17,6 @@ build:
|
||||
SAVE ARTIFACT dist AS LOCAL ./dist
|
||||
|
||||
dev-image:
|
||||
ARG REGISTRY="registry.mangopig.tech"
|
||||
ARG IMAGE_NAME="boost-ai/demo-frontend-dev"
|
||||
ARG TAG="latest"
|
||||
|
||||
@@ -29,6 +28,38 @@ dev-image:
|
||||
EXPOSE 4321
|
||||
|
||||
SAVE IMAGE $IMAGE_NAME:$TAG
|
||||
|
||||
dev-image-push:
|
||||
ARG REGISTRY="registry.mangopig.tech"
|
||||
ARG IMAGE_NAME="boost-ai/demo-frontend-dev"
|
||||
ARG TAG="latest"
|
||||
|
||||
FROM +dev-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG
|
||||
SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG
|
||||
|
||||
prod-image:
|
||||
ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a"
|
||||
ARG TAG="latest"
|
||||
|
||||
FROM +deps
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["pnpm", "start", "--host", "0.0.0.0", "--port", "3000"]
|
||||
|
||||
SAVE IMAGE $IMAGE_NAME:$TAG
|
||||
|
||||
prod-image-push:
|
||||
ARG REGISTRY="registry.mangopig.tech"
|
||||
ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a"
|
||||
ARG TAG="latest"
|
||||
|
||||
FROM +prod-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG
|
||||
SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG
|
||||
|
||||
# image:
|
||||
|
||||
BIN
Frontend/public/brand/boost-ai-logo-purple.png
Normal file
BIN
Frontend/public/brand/boost-ai-logo-purple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 664 B After Width: | Height: | Size: 15 KiB |
@@ -1,32 +1,53 @@
|
||||
// Path: Frontend/src/app.tsx
|
||||
|
||||
import { Router, useLocation } from "@solidjs/router";
|
||||
import { Router, useLocation, useNavigate } from "@solidjs/router";
|
||||
import { FileRoutes } from "@solidjs/start/router";
|
||||
import { Suspense, Show, type ParentComponent } from "solid-js";
|
||||
import { Transition } from "solid-transition-group";
|
||||
import { Suspense, createEffect, type ParentComponent } from "solid-js";
|
||||
import { AuthProvider, useAuth } from "./context/auth/context";
|
||||
import { ThemeProvider } from "./context/theme/context";
|
||||
import { getPostAuthRedirectHref, isPublicRoute } from "./lib/routes";
|
||||
import "./styles/main.scss";
|
||||
|
||||
const AppRoot: ParentComponent = (props) => {
|
||||
const location = useLocation();
|
||||
const isViewportLockedRoute = () => location.pathname === "/" || location.pathname.startsWith("/auth/");
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const routeIsPublic = () => isPublicRoute(location.pathname);
|
||||
|
||||
createEffect(() => {
|
||||
if (!auth.isReady()) return;
|
||||
|
||||
const pathname = location.pathname;
|
||||
if (pathname === "/") return;
|
||||
|
||||
if (pathname.startsWith("/auth/")) {
|
||||
if (auth.user()) {
|
||||
navigate(getPostAuthRedirectHref(auth.user()!.role), { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!auth.user()) {
|
||||
navigate("/auth/login", { replace: true });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<div classList={{
|
||||
"app-route-shell": true,
|
||||
"app-route-shell-viewport-locked": isViewportLockedRoute(),
|
||||
}}>
|
||||
<Transition name="page-fade-slide">
|
||||
<Show when={location.pathname} keyed>
|
||||
{(path) => (
|
||||
<div class="page-transition-stage" data-route={path}>
|
||||
{props.children}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Transition>
|
||||
</div>
|
||||
{!auth.isReady() && !routeIsPublic() ? (
|
||||
<div class="app-route-shell app-route-shell-viewport-locked">
|
||||
<div style={{ display: "grid", "place-items": "center", padding: "2rem", color: "var(--text-muted)" }}>
|
||||
Loading your session…
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div classList={{
|
||||
"app-route-shell": true,
|
||||
"app-route-shell-viewport-locked": routeIsPublic(),
|
||||
}}>
|
||||
{props.children}
|
||||
</div>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -34,9 +55,11 @@ const AppRoot: ParentComponent = (props) => {
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Router root={AppRoot}>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
<AuthProvider>
|
||||
<Router root={AppRoot}>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { A, useLocation, useParams } from "@solidjs/router";
|
||||
import type { Component } from "solid-js";
|
||||
import styles from "../../routes/assignment/assignment-page.module.scss";
|
||||
|
||||
const AssignmentTabs: Component = () => {
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
|
||||
const reviewHref = () => `/assignment/${params.id}`;
|
||||
const workHref = () => `/assignment/${params.id}/work`;
|
||||
const isWork = () => location.pathname === workHref();
|
||||
|
||||
return (
|
||||
<nav class={styles.tabs} aria-label="Assignment views">
|
||||
<div class={styles.tabList}>
|
||||
<A href={reviewHref()} class={`${styles.tabLink} ${!isWork() ? styles.tabLinkActive : ""}`.trim()}>
|
||||
Review
|
||||
</A>
|
||||
<A href={workHref()} class={`${styles.tabLink} ${isWork() ? styles.tabLinkActive : ""}`.trim()}>
|
||||
Work
|
||||
</A>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignmentTabs;
|
||||
@@ -1,251 +0,0 @@
|
||||
import rawAssignments from "../../../../Mock-Data/assignments.json";
|
||||
import rawAssignmentAssignees from "../../../../Mock-Data/assignment_assignees.json";
|
||||
import rawAssignmentQuestions from "../../../../Mock-Data/assignment_questions.json";
|
||||
import rawQuestionBank from "../../../../Mock-Data/question_bank.json";
|
||||
import rawStudentAnswers from "../../../../Mock-Data/student_answers.json";
|
||||
import rawStudents from "../../../../Mock-Data/students.json";
|
||||
import rawClassroom from "../../../../Mock-Data/classroom.json";
|
||||
|
||||
type Assignment = {
|
||||
id: number;
|
||||
name: string;
|
||||
topic: string;
|
||||
due_date: number;
|
||||
status: "DRAFT" | "PUBLISHED" | "CLOSED";
|
||||
maximum_marks: number;
|
||||
};
|
||||
|
||||
type AssignmentAssignee = {
|
||||
id: number;
|
||||
assignment_id: number;
|
||||
student_id: number;
|
||||
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
|
||||
total_marks: number;
|
||||
started_at: number | null;
|
||||
submitted_at: number | null;
|
||||
};
|
||||
|
||||
type AssignmentQuestion = {
|
||||
id: number;
|
||||
assignment_id: number;
|
||||
question_bank_id: number;
|
||||
question_order: number;
|
||||
maximum_marks: number;
|
||||
};
|
||||
|
||||
type QuestionBankItem = {
|
||||
id: number;
|
||||
topic: string;
|
||||
sub_topic: string | null;
|
||||
difficulty: "EASY" | "MEDIUM" | "HARD";
|
||||
question_text: string;
|
||||
correct_answer: string;
|
||||
step_by_step_solution: string | null;
|
||||
};
|
||||
|
||||
type StudentAnswer = {
|
||||
assignee_id: number;
|
||||
assignment_question_id: number;
|
||||
extracted_answer: string;
|
||||
ai_reasoning: string;
|
||||
_solve_mode: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
|
||||
_is_correct: boolean;
|
||||
_time_on_task_seconds: number;
|
||||
};
|
||||
|
||||
type Student = {
|
||||
id: number;
|
||||
fullname: string;
|
||||
_persona: string;
|
||||
};
|
||||
|
||||
type ClassroomFile = {
|
||||
classroom: {
|
||||
name: string;
|
||||
target_level: number;
|
||||
};
|
||||
tutor: {
|
||||
fullname: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AssignmentPageData = {
|
||||
id: number;
|
||||
title: string;
|
||||
topic: string;
|
||||
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
|
||||
statusLabel: string;
|
||||
dueLabel: string;
|
||||
studentName: string;
|
||||
classroomName: string;
|
||||
tutorName: string;
|
||||
headline: string;
|
||||
description: string;
|
||||
primaryAction: string;
|
||||
primaryHref: string;
|
||||
stats: Array<{ label: string; value: string }>;
|
||||
coachCard: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
};
|
||||
questions: Array<{
|
||||
id: number;
|
||||
order: number;
|
||||
prompt: string;
|
||||
topic: string;
|
||||
subTopic: string | null;
|
||||
difficulty: "EASY" | "MEDIUM" | "HARD";
|
||||
marks: number;
|
||||
statusLabel: string;
|
||||
statusTone: "success" | "warning" | "muted";
|
||||
responseLabel: string;
|
||||
responseValue: string;
|
||||
feedback: string;
|
||||
solveModeLabel?: string;
|
||||
initialAnswer?: string;
|
||||
initialSolveMode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
|
||||
showAnswerKey: boolean;
|
||||
correctAnswer: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const assignments = rawAssignments as Assignment[];
|
||||
const assignmentAssignees = rawAssignmentAssignees as AssignmentAssignee[];
|
||||
const assignmentQuestions = rawAssignmentQuestions as AssignmentQuestion[];
|
||||
const questionBank = rawQuestionBank as QuestionBankItem[];
|
||||
const studentAnswers = rawStudentAnswers as StudentAnswer[];
|
||||
const students = rawStudents as Student[];
|
||||
const classroomFile = rawClassroom as ClassroomFile;
|
||||
|
||||
const defaultStudentId = 201;
|
||||
|
||||
const assignmentById = new Map(assignments.map((entry) => [entry.id, entry]));
|
||||
const questionById = new Map(questionBank.map((entry) => [entry.id, entry]));
|
||||
const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0];
|
||||
|
||||
const formatDate = (timestamp: number) =>
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(timestamp));
|
||||
|
||||
const formatSolveMode = (value: StudentAnswer["_solve_mode"]) => {
|
||||
switch (value) {
|
||||
case "just_answer":
|
||||
return "Just answer";
|
||||
case "step_by_step":
|
||||
return "Step by step";
|
||||
case "solve_together":
|
||||
return "Solve together";
|
||||
case "handwritten":
|
||||
return "Handwritten";
|
||||
}
|
||||
};
|
||||
|
||||
const personaHint = (persona: string) => {
|
||||
switch (persona) {
|
||||
case "fraction_inversion":
|
||||
return "Slow down on fraction rules and check each step before moving on.";
|
||||
case "place_value_gaps":
|
||||
return "Check place value carefully before you calculate the final answer.";
|
||||
case "rushed_careless":
|
||||
return "Pause before submitting so small slips do not cost easy marks.";
|
||||
case "solve_together_dependent":
|
||||
return "Try one independent attempt first, then ask for guided help if you need it.";
|
||||
case "word_problem_weak":
|
||||
return "Underline the key numbers and turn the sentence into a maths step first.";
|
||||
default:
|
||||
return "Work through one question at a time and keep your method tidy.";
|
||||
}
|
||||
};
|
||||
|
||||
export const getAssignmentPageData = (assignmentId: number): AssignmentPageData | null => {
|
||||
const assignment = assignmentById.get(assignmentId);
|
||||
if (!assignment) return null;
|
||||
|
||||
const assignee = assignmentAssignees.find((entry) => entry.assignment_id === assignment.id && entry.student_id === student.id);
|
||||
if (!assignee) return null;
|
||||
|
||||
const assignmentQuestionRows = assignmentQuestions
|
||||
.filter((entry) => entry.assignment_id === assignment.id)
|
||||
.sort((left, right) => left.question_order - right.question_order)
|
||||
.map((entry) => {
|
||||
const question = questionById.get(entry.question_bank_id);
|
||||
if (!question) throw new Error(`Missing question bank record ${entry.question_bank_id}`);
|
||||
|
||||
const answer = studentAnswers.find((studentAnswer) => studentAnswer.assignee_id === assignee.id && studentAnswer.assignment_question_id === entry.id);
|
||||
|
||||
return { entry, question, answer };
|
||||
});
|
||||
|
||||
const answeredCount = assignmentQuestionRows.filter((row) => !!row.answer).length;
|
||||
const correctCount = assignmentQuestionRows.filter((row) => row.answer?._is_correct).length;
|
||||
const accuracy = answeredCount > 0 ? Math.round((correctCount / answeredCount) * 100) : 0;
|
||||
|
||||
const statusLabel = assignee.status === "SUBMITTED" ? "Submitted" : assignee.status === "IN_PROGRESS" ? "In progress" : "Not started";
|
||||
const primaryAction = assignee.status === "SUBMITTED" ? "Review assignment" : assignee.status === "IN_PROGRESS" ? "Continue assignment" : "Start assignment";
|
||||
|
||||
const questions = assignmentQuestionRows.map(({ entry, question, answer }) => ({
|
||||
id: entry.id,
|
||||
order: entry.question_order,
|
||||
prompt: question.question_text,
|
||||
topic: question.topic,
|
||||
subTopic: question.sub_topic,
|
||||
difficulty: question.difficulty,
|
||||
marks: entry.maximum_marks,
|
||||
statusLabel: answer ? (answer._is_correct ? "Correct" : "Needs review") : "Not answered",
|
||||
statusTone: answer ? (answer._is_correct ? "success" : "warning") : "muted",
|
||||
responseLabel: answer ? "Your latest answer" : "Status",
|
||||
responseValue: answer ? answer.extracted_answer : "No attempt yet",
|
||||
feedback: answer ? answer.ai_reasoning : "This sample question is ready when you are.",
|
||||
solveModeLabel: answer ? formatSolveMode(answer._solve_mode) : undefined,
|
||||
initialAnswer: answer?.extracted_answer,
|
||||
initialSolveMode: answer?._solve_mode,
|
||||
showAnswerKey: assignee.status === "SUBMITTED",
|
||||
correctAnswer: question.correct_answer,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: assignment.id,
|
||||
title: assignment.name,
|
||||
topic: assignment.topic,
|
||||
status: assignee.status,
|
||||
statusLabel,
|
||||
dueLabel: formatDate(assignment.due_date),
|
||||
studentName: student.fullname,
|
||||
classroomName: classroomFile.classroom.name,
|
||||
tutorName: classroomFile.tutor.fullname,
|
||||
headline:
|
||||
assignee.status === "SUBMITTED"
|
||||
? `Review how you did in ${assignment.topic}`
|
||||
: assignee.status === "IN_PROGRESS"
|
||||
? `Keep going — you are already part way through`
|
||||
: `Start this assignment with a steady first pass`,
|
||||
description:
|
||||
assignee.status === "SUBMITTED"
|
||||
? `You scored ${assignee.total_marks}/${assignment.maximum_marks}. Use the sample questions below to revisit what felt easy and what still needs another try.`
|
||||
: assignee.status === "IN_PROGRESS"
|
||||
? `You have answered ${answeredCount} of ${assignmentQuestionRows.length} questions. Finish the rest while the topic is still fresh.`
|
||||
: `This assignment has ${assignmentQuestionRows.length} sample questions. Start with the easier wins, then work up to the harder ones.`,
|
||||
primaryAction,
|
||||
primaryHref: `/assignment/${assignment.id}/work`,
|
||||
stats: [
|
||||
{ label: "Status", value: statusLabel },
|
||||
{ label: "Due", value: formatDate(assignment.due_date) },
|
||||
{ label: "Questions", value: `${assignmentQuestionRows.length}` },
|
||||
{ label: assignee.status === "SUBMITTED" ? "Score" : "Answered", value: assignee.status === "SUBMITTED" ? `${assignee.total_marks}/${assignment.maximum_marks}` : `${answeredCount}/${assignmentQuestionRows.length}` },
|
||||
],
|
||||
coachCard: {
|
||||
title: "How to approach this one",
|
||||
description: personaHint(student._persona),
|
||||
items: [
|
||||
`${assignment.topic} focus`,
|
||||
`${accuracy}% accuracy so far`,
|
||||
`${correctCount} correct answers logged`,
|
||||
],
|
||||
},
|
||||
questions,
|
||||
};
|
||||
};
|
||||
@@ -1,14 +1,24 @@
|
||||
/* Path: Frontend/src/components/assignment/shared/assignment-header.module.scss */
|
||||
|
||||
.headerCard {
|
||||
display: grid;
|
||||
gap: 1.1rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 1.5rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end));
|
||||
color: var(--text-on-accent);
|
||||
border: 1px solid var(--border-overlay);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.headerCard {
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
}
|
||||
}
|
||||
|
||||
.headerTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -16,13 +26,21 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.headerTop {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.backLink,
|
||||
.statusPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-overlay-soft);
|
||||
border: 1px solid var(--border-overlay);
|
||||
color: var(--text-on-accent);
|
||||
@@ -47,6 +65,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.copy {
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.copy h1 {
|
||||
font-size: clamp(1.5rem, 1.15rem + 1.35vw, 2rem);
|
||||
line-height: 1.04;
|
||||
}
|
||||
|
||||
.copy > p:not(.eyebrow) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
@@ -1,17 +1,21 @@
|
||||
import type { Component } from "solid-js";
|
||||
// Path: Frontend/src/components/assignment/shared/assignment-header.tsx
|
||||
|
||||
import { A } from "@solidjs/router";
|
||||
import type { AssignmentPageData } from "./assignment.data";
|
||||
import type { Component } from "solid-js";
|
||||
import { getDashboardHomeHref } from "../../../lib/routes";
|
||||
import styles from "./assignment-header.module.scss";
|
||||
import type { AssignmentPageData } from "./assignment-types";
|
||||
|
||||
type Props = {
|
||||
data: AssignmentPageData;
|
||||
backHref?: string;
|
||||
};
|
||||
|
||||
const AssignmentHeader: Component<Props> = (props) => {
|
||||
return (
|
||||
<section class={styles.headerCard}>
|
||||
<div class={styles.headerTop}>
|
||||
<A href="/dashboard" class={styles.backLink}>
|
||||
<A href={props.backHref ?? getDashboardHomeHref("student")} class={styles.backLink}>
|
||||
Back to dashboard
|
||||
</A>
|
||||
<span class={styles.statusPill}>{props.data.statusLabel}</span>
|
||||
@@ -1,3 +1,5 @@
|
||||
/* Path: Frontend/src/components/assignment/shared/assignment-overview.module.scss */
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -7,7 +9,7 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.2rem;
|
||||
border-radius: 1.35rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -39,7 +41,7 @@
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.9rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-panel-strong);
|
||||
border: 1px solid var(--border-divider);
|
||||
|
||||
@@ -76,10 +78,35 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
|
||||
color: var(--action-primary-text);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--action-primary-shadow);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.feedbackBlock {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-panel-strong);
|
||||
border: 1px solid var(--border-divider);
|
||||
}
|
||||
|
||||
.feedbackLabel {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.feedbackCopy {
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
// Path: Frontend/src/components/assignment/shared/assignment-overview.tsx
|
||||
|
||||
import { A } from "@solidjs/router";
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import type { AssignmentPageData } from "./assignment.data";
|
||||
import styles from "./assignment-overview.module.scss";
|
||||
import type { AssignmentPageData } from "./assignment-types";
|
||||
|
||||
type Props = {
|
||||
data: AssignmentPageData;
|
||||
@@ -45,6 +47,29 @@ const AssignmentOverview: Component<Props> = (props) => {
|
||||
{props.data.primaryAction}
|
||||
</A>
|
||||
</section>
|
||||
|
||||
{(props.data.assignmentAiFeedback || props.data.assignmentTeacherFeedback) && (
|
||||
<section class={styles.panel}>
|
||||
<div class={styles.cardHeader}>
|
||||
<h2>Shared assignment feedback</h2>
|
||||
<p>{props.data.tutorName}</p>
|
||||
</div>
|
||||
|
||||
{props.data.assignmentAiFeedback && (
|
||||
<div class={styles.feedbackBlock}>
|
||||
<p class={styles.feedbackLabel}>AI feedback</p>
|
||||
<p class={styles.feedbackCopy}>{props.data.assignmentAiFeedback}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.data.assignmentTeacherFeedback && (
|
||||
<div class={styles.feedbackBlock}>
|
||||
<p class={styles.feedbackLabel}>Teacher feedback</p>
|
||||
<p class={styles.feedbackCopy}>{props.data.assignmentTeacherFeedback}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
/* Path: Frontend/src/components/assignment/shared/assignment-page.module.scss */
|
||||
|
||||
.page {
|
||||
min-height: 100dvh;
|
||||
padding: 1.25rem;
|
||||
@@ -18,7 +20,7 @@
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
|
||||
@media (min-width: 1080px) {
|
||||
@include respond(workspace) {
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(18rem, 0.75fr);
|
||||
align-items: start;
|
||||
}
|
||||
@@ -34,7 +36,7 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -52,7 +54,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.72rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
@@ -80,7 +82,7 @@
|
||||
}
|
||||
|
||||
.sideColumn {
|
||||
@media (min-width: 1080px) {
|
||||
@include respond(workspace) {
|
||||
position: sticky;
|
||||
top: 1.25rem;
|
||||
}
|
||||
@@ -90,7 +92,7 @@
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 2rem;
|
||||
border-radius: 1.5rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -119,7 +121,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
|
||||
color: var(--action-primary-text);
|
||||
text-decoration: none;
|
||||
@@ -30,7 +30,7 @@
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1.2rem;
|
||||
border-radius: 1.35rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
.statusPill {
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
span {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-panel-strong);
|
||||
border: 1px solid var(--border-divider);
|
||||
font-size: 0.82rem;
|
||||
@@ -96,11 +96,12 @@
|
||||
}
|
||||
|
||||
.responseBlock,
|
||||
.answerKey {
|
||||
.answerKey,
|
||||
.supportBlock {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-panel-strong);
|
||||
border: 1px solid var(--border-divider);
|
||||
|
||||
@@ -116,8 +117,12 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
span {
|
||||
span,
|
||||
pre {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// Path: Frontend/src/components/assignment/shared/assignment-question-list.tsx
|
||||
|
||||
import type { Component } from "solid-js";
|
||||
import { For, Show } from "solid-js";
|
||||
import type { AssignmentPageData } from "./assignment.data";
|
||||
import styles from "./assignment-question-list.module.scss";
|
||||
import type { AssignmentPageData } from "./assignment-types";
|
||||
|
||||
type Props = {
|
||||
data: AssignmentPageData;
|
||||
@@ -11,8 +13,8 @@ const AssignmentQuestionList: Component<Props> = (props) => {
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<div class={styles.header}>
|
||||
<h2>Sample questions</h2>
|
||||
<p>{props.data.questions.length} loaded from the mock dataset</p>
|
||||
<h2>Question review</h2>
|
||||
<p>{props.data.questions.length} questions in this assignment</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.list}>
|
||||
@@ -32,8 +34,12 @@ const AssignmentQuestionList: Component<Props> = (props) => {
|
||||
<Show when={question.subTopic}>
|
||||
<span>{question.subTopic}</span>
|
||||
</Show>
|
||||
<span>{question.difficulty}</span>
|
||||
<span>{question.marks} mark</span>
|
||||
<Show when={question.difficulty}>
|
||||
<span>{question.difficulty}</span>
|
||||
</Show>
|
||||
<Show when={question.marks !== null}>
|
||||
<span>{question.marks} mark</span>
|
||||
</Show>
|
||||
<Show when={question.solveModeLabel}>
|
||||
<span>{question.solveModeLabel}</span>
|
||||
</Show>
|
||||
@@ -42,12 +48,21 @@ const AssignmentQuestionList: Component<Props> = (props) => {
|
||||
<div class={styles.responseBlock}>
|
||||
<p>{question.responseLabel}</p>
|
||||
<strong>{question.responseValue}</strong>
|
||||
<span>{question.feedback}</span>
|
||||
<Show when={question.feedback}>
|
||||
<span>{question.feedback}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={question.workingSteps}>
|
||||
<div class={styles.supportBlock}>
|
||||
<p>Your steps and explanation</p>
|
||||
<pre>{question.workingSteps}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={question.showAnswerKey}>
|
||||
<div class={styles.answerKey}>
|
||||
<p>Answer key</p>
|
||||
<p>Correct answer</p>
|
||||
<strong>{question.correctAnswer}</strong>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -0,0 +1,39 @@
|
||||
// Path: Frontend/src/components/assignment/shared/assignment-tabs.tsx
|
||||
|
||||
import { A, useLocation, useParams } from "@solidjs/router";
|
||||
import type { Component } from "solid-js";
|
||||
import type { AppRole } from "../../../lib/routes";
|
||||
import { getAssignmentReviewHref, getAssignmentWorkHref } from "../../../lib/routes";
|
||||
import styles from "./assignment-page.module.scss";
|
||||
|
||||
type AssignmentTabsProps = {
|
||||
showWork?: boolean;
|
||||
role?: AppRole;
|
||||
};
|
||||
|
||||
const AssignmentTabs: Component<AssignmentTabsProps> = (props) => {
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const role = () => props.role ?? (location.pathname.includes("/teacher/") ? "teacher" : "student");
|
||||
|
||||
const reviewHref = () => getAssignmentReviewHref(role(), params.id as string);
|
||||
const workHref = () => getAssignmentWorkHref(params.id as string);
|
||||
const isWork = () => location.pathname === workHref();
|
||||
|
||||
return (
|
||||
<nav class={styles.tabs} aria-label="Assignment views">
|
||||
<div class={styles.tabList}>
|
||||
<A href={reviewHref()} class={`${styles.tabLink} ${!isWork() ? styles.tabLinkActive : ""}`.trim()}>
|
||||
Review
|
||||
</A>
|
||||
{props.showWork !== false ? (
|
||||
<A href={workHref()} class={`${styles.tabLink} ${isWork() ? styles.tabLinkActive : ""}`.trim()}>
|
||||
Work
|
||||
</A>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignmentTabs;
|
||||
@@ -0,0 +1,46 @@
|
||||
// Path: Frontend/src/components/assignment/shared/assignment-types.ts
|
||||
|
||||
export type AssignmentPageData = {
|
||||
id: number;
|
||||
title: string;
|
||||
topic: string;
|
||||
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
|
||||
statusLabel: string;
|
||||
dueLabel: string;
|
||||
studentName: string;
|
||||
classroomName: string;
|
||||
tutorName: string;
|
||||
headline: string;
|
||||
description: string;
|
||||
primaryAction: string;
|
||||
primaryHref: string;
|
||||
stats: Array<{ label: string; value: string }>;
|
||||
coachCard: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
};
|
||||
assignmentAiFeedback?: string;
|
||||
assignmentTeacherFeedback?: string;
|
||||
questions: Array<{
|
||||
id: number;
|
||||
order: number;
|
||||
prompt: string;
|
||||
topic: string;
|
||||
subTopic: string | null;
|
||||
difficulty: string | null;
|
||||
marks: number | null;
|
||||
statusLabel: string;
|
||||
statusTone: "success" | "warning" | "muted";
|
||||
responseLabel: string;
|
||||
responseValue: string;
|
||||
feedback: string;
|
||||
solveModeLabel?: string;
|
||||
workingSteps?: string;
|
||||
initialAnswer?: string;
|
||||
initialSolveMode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
|
||||
showAnswerKey: boolean;
|
||||
correctAnswer: string | null;
|
||||
isCorrect?: boolean | null;
|
||||
}>;
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
// Path: Frontend/src/components/assignment/student/assignment-review.data.ts
|
||||
|
||||
import { apiFetchJson } from "../../../lib/api";
|
||||
import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiClassroom, ApiListResponse, ApiUser } from "../../../lib/api-types";
|
||||
import { getAssignmentWorkHref } from "../../../lib/routes";
|
||||
import type { AssignmentPageData } from "../shared/assignment-types";
|
||||
|
||||
const formatDateLabel = (value: string | null) => {
|
||||
if (!value) return "No due date";
|
||||
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
|
||||
const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/i)?.[1]?.trim();
|
||||
if (fromInstructions) return fromInstructions;
|
||||
|
||||
return questions[0]?.subject ?? "Assignment";
|
||||
};
|
||||
|
||||
const deriveStudentStatus = (questions: ApiAssignmentStudentQuestionDetail[], assignmentStatus: ApiAssignment["status"]) => {
|
||||
const total = questions.length;
|
||||
const answered = questions.filter((question) => question.answer_id).length;
|
||||
const reviewed = questions.filter((question) => question.answer_status === "reviewed").length;
|
||||
|
||||
if (answered === 0) return "NOT_STARTED" as const;
|
||||
if (reviewed === total || assignmentStatus === "closed") return "SUBMITTED" as const;
|
||||
return "IN_PROGRESS" as const;
|
||||
};
|
||||
|
||||
const deriveStatusLabel = (status: AssignmentPageData["status"]) => {
|
||||
switch (status) {
|
||||
case "SUBMITTED":
|
||||
return "Reviewed";
|
||||
case "IN_PROGRESS":
|
||||
return "In progress";
|
||||
default:
|
||||
return "Not started";
|
||||
}
|
||||
};
|
||||
|
||||
const deriveHeadline = (status: AssignmentPageData["status"], topic: string) => {
|
||||
switch (status) {
|
||||
case "SUBMITTED":
|
||||
return `Review how you did in ${topic}`;
|
||||
case "IN_PROGRESS":
|
||||
return "Keep going — you already have momentum here";
|
||||
default:
|
||||
return "Start this assignment with a steady first pass";
|
||||
}
|
||||
};
|
||||
|
||||
const deriveDescription = (status: AssignmentPageData["status"], answered: number, reviewed: number, total: number, topic: string) => {
|
||||
switch (status) {
|
||||
case "SUBMITTED":
|
||||
return `${reviewed} of ${total} questions have teacher-reviewed answers. Use this review view to see what landed well and what still needs another pass in ${topic}.`;
|
||||
case "IN_PROGRESS":
|
||||
return `You have touched ${answered} of ${total} questions so far. Finish the remaining questions while ${topic.toLowerCase()} still feels familiar.`;
|
||||
default:
|
||||
return `This assignment has ${total} questions. Start with a calm first pass, then come back here for one shared review summary across the assignment.`;
|
||||
}
|
||||
};
|
||||
|
||||
const derivePrimaryAction = (status: AssignmentPageData["status"]) => {
|
||||
switch (status) {
|
||||
case "SUBMITTED":
|
||||
return "Open workspace";
|
||||
case "IN_PROGRESS":
|
||||
return "Continue assignment";
|
||||
default:
|
||||
return "Start assignment";
|
||||
}
|
||||
};
|
||||
|
||||
const mapQuestionStatus = (question: ApiAssignmentStudentQuestionDetail) => {
|
||||
if (question.is_correct === true) {
|
||||
return {
|
||||
statusLabel: "Correct",
|
||||
statusTone: "success" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (question.answer_status === "reviewed") {
|
||||
return {
|
||||
statusLabel: "Reviewed",
|
||||
statusTone: "success" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (question.answer_status === "submitted") {
|
||||
return {
|
||||
statusLabel: "Submitted",
|
||||
statusTone: "warning" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (question.answer_status === "in_progress") {
|
||||
return {
|
||||
statusLabel: "In progress",
|
||||
statusTone: "warning" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusLabel: "Not started",
|
||||
statusTone: "muted" as const,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAssignmentReviewPageData = async (assignmentId: number, studentId: number): Promise<AssignmentPageData | null> => {
|
||||
if (!Number.isFinite(assignmentId) || assignmentId <= 0) return null;
|
||||
|
||||
try {
|
||||
const assignment = await apiFetchJson<ApiAssignment>(`/api/assignments/${assignmentId}`);
|
||||
|
||||
const [student, teacher, classrooms, questionDetails] = await Promise.all([
|
||||
apiFetchJson<ApiUser>(`/api/users/${studentId}`),
|
||||
apiFetchJson<ApiUser>(`/api/users/${assignment.teacher_id}`),
|
||||
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${assignment.teacher_id}/classrooms`),
|
||||
apiFetchJson<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/api/assignments/${assignmentId}/students/${studentId}/questions`),
|
||||
]);
|
||||
|
||||
const classroom = classrooms.data.find((entry) => entry.id === assignment.classroom_id);
|
||||
const questions = questionDetails.data;
|
||||
const topic = extractTopic(assignment, questions);
|
||||
const answeredCount = questions.filter((question) => question.answer_id).length;
|
||||
const reviewedCount = questions.filter((question) => question.answer_status === "reviewed").length;
|
||||
const assignmentAiFeedback = questions[0]?.assignment_ai_feedback?.trim() || "";
|
||||
const assignmentTeacherFeedback = questions[0]?.assignment_teacher_feedback?.trim() || "";
|
||||
const status = deriveStudentStatus(questions, assignment.status);
|
||||
const statusLabel = deriveStatusLabel(status);
|
||||
|
||||
return {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
topic,
|
||||
status,
|
||||
statusLabel,
|
||||
dueLabel: formatDateLabel(assignment.due_at),
|
||||
studentName: student.full_name,
|
||||
classroomName: classroom?.name ?? "Classroom",
|
||||
tutorName: teacher.full_name,
|
||||
headline: deriveHeadline(status, topic),
|
||||
description: deriveDescription(status, answeredCount, reviewedCount, questions.length, topic),
|
||||
primaryAction: derivePrimaryAction(status),
|
||||
primaryHref: getAssignmentWorkHref(assignment.id),
|
||||
stats: [
|
||||
{ label: "Status", value: statusLabel },
|
||||
{ label: "Due", value: formatDateLabel(assignment.due_at) },
|
||||
{ label: "Questions", value: `${questions.length}` },
|
||||
{ label: status === "SUBMITTED" ? "Reviewed" : "Answered", value: `${status === "SUBMITTED" ? reviewedCount : answeredCount}/${questions.length}` },
|
||||
],
|
||||
coachCard: {
|
||||
title: "Review notes",
|
||||
description: `Use this review view to compare your latest answers with the shared AI and teacher feedback for ${topic.toLowerCase()}.`,
|
||||
items: [`${answeredCount} questions attempted`, `${reviewedCount} questions reviewed`, `${Math.max(questions.length - answeredCount, 0)} questions still untouched`],
|
||||
},
|
||||
assignmentAiFeedback: assignmentAiFeedback || undefined,
|
||||
assignmentTeacherFeedback: assignmentTeacherFeedback || undefined,
|
||||
questions: questions.map((question) => {
|
||||
const questionStatus = mapQuestionStatus(question);
|
||||
|
||||
return {
|
||||
id: question.question_id,
|
||||
order: question.position,
|
||||
prompt: question.prompt,
|
||||
topic: question.subject,
|
||||
subTopic: null,
|
||||
difficulty: "Backend",
|
||||
marks: null,
|
||||
statusLabel: questionStatus.statusLabel,
|
||||
statusTone: questionStatus.statusTone,
|
||||
responseLabel: question.answer_id ? "Latest answer" : "Status",
|
||||
responseValue: question.answer_text?.trim() || "No attempt yet",
|
||||
feedback: "",
|
||||
workingSteps: question.working_steps?.trim() || "",
|
||||
solveModeLabel:
|
||||
question.solve_mode === "step_by_step"
|
||||
? "Step by step"
|
||||
: question.solve_mode === "solve_together"
|
||||
? "Solve together"
|
||||
: question.solve_mode === "handwritten"
|
||||
? "Handwritten"
|
||||
: question.solve_mode === "just_answer"
|
||||
? "Just answer"
|
||||
: undefined,
|
||||
showAnswerKey: Boolean(question.correct_answer && question.answer_id),
|
||||
correctAnswer: question.correct_answer?.trim() || null,
|
||||
isCorrect: typeof question.is_correct === "boolean" ? question.is_correct : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "not_found") {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { A, useNavigate } from "@solidjs/router";
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
|
||||
import type { TeacherAssignmentPassStatus, TeacherAssignmentReviewPageData, TeacherNextStepOutcome } from "./assignment-teacher-review.data";
|
||||
import { updateAssignmentTeacherFeedback } from "./assignment-teacher-review.data";
|
||||
import { AssignmentFeedbackSection, type TeacherReviewNotice } from "./assignment-teacher-review.sections";
|
||||
import { getAssignmentReviewHref, getTeacherAssignmentRedoPlanHref } from "../../../lib/routes";
|
||||
import styles from "./assignment-teacher-review.module.scss";
|
||||
|
||||
type Props = {
|
||||
data: TeacherAssignmentReviewPageData;
|
||||
};
|
||||
|
||||
type NextStepDraft = {
|
||||
teacherFeedback?: string;
|
||||
decision?: TeacherNextStepOutcome;
|
||||
passStatusOverride?: TeacherAssignmentPassStatus | null;
|
||||
};
|
||||
|
||||
const PASS_STATUS_OVERRIDE_OPTIONS: Array<{ value: TeacherAssignmentPassStatus | null; label: string; help: string }> = [
|
||||
{ value: null, label: "Automatic", help: "Use the calculated status from the fixed pass rule." },
|
||||
{ value: "pass", label: "Pass", help: "Override the calculated result and mark this review as pass." },
|
||||
{ value: "no_pass", label: "No pass", help: "Override the calculated result and mark this review as no pass." },
|
||||
];
|
||||
|
||||
const nextStepStorageKey = (assignmentId: number, studentId: number) => `teacher-next-step-draft:${assignmentId}:${studentId}`;
|
||||
|
||||
const readNextStepDraft = (assignmentId: number, studentId: number): NextStepDraft => {
|
||||
if (typeof window === "undefined") return {};
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(nextStepStorageKey(assignmentId, studentId));
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? (parsed as NextStepDraft) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeNextStepDraft = (assignmentId: number, studentId: number, draft: NextStepDraft) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const key = nextStepStorageKey(assignmentId, studentId);
|
||||
if (!draft.teacherFeedback && !draft.decision && draft.passStatusOverride == null) {
|
||||
window.localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(key, JSON.stringify(draft));
|
||||
};
|
||||
|
||||
const OUTCOME_OPTIONS: Array<{ value: TeacherNextStepOutcome; title: string; description: string }> = [
|
||||
{ value: "redo", title: "Redo assignment", description: "Ask the student to revisit the assignment and try again with the latest review in mind." },
|
||||
{ value: "accept", title: "Accept and continue", description: "Mark this review as ready to move on and continue the student into the next piece of work." },
|
||||
{ value: "support", title: "Needs support", description: "Flag that the student needs extra coaching, a follow-up message, or a guided reteach step." },
|
||||
];
|
||||
|
||||
const AssignmentTeacherNextStep: Component<Props> = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const [teacherFeedbackDraft, setTeacherFeedbackDraft] = createSignal("");
|
||||
const [decisionDraft, setDecisionDraft] = createSignal<TeacherNextStepOutcome | null>(null);
|
||||
const [passStatusOverrideDraft, setPassStatusOverrideDraft] = createSignal<TeacherAssignmentPassStatus | null>(null);
|
||||
const [savingFeedback, setSavingFeedback] = createSignal(false);
|
||||
const [notice, setNotice] = createSignal<TeacherReviewNotice>(null);
|
||||
|
||||
const selectedStudentId = createMemo(() => props.data.selectedStudentId);
|
||||
const canChooseNextStep = createMemo(() => {
|
||||
return props.data.selectedStudentSubmittedQuestions > 0 || props.data.selectedStudentReviewedQuestions > 0;
|
||||
});
|
||||
const hasPendingAssignmentFeedback = createMemo(() => teacherFeedbackDraft() !== props.data.assignmentTeacherFeedback);
|
||||
const hasPendingDecision = createMemo(() => decisionDraft() !== props.data.nextStepOutcome);
|
||||
const hasPendingPassStatusOverride = createMemo(() => passStatusOverrideDraft() !== props.data.passStatusOverride);
|
||||
const overallScorePercent = createMemo(() => {
|
||||
if (props.data.overallScore == null) return null;
|
||||
return Math.round(props.data.overallScore * 10);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const studentId = selectedStudentId();
|
||||
if (!studentId) return;
|
||||
const draft = readNextStepDraft(props.data.assignmentId, studentId);
|
||||
setTeacherFeedbackDraft(draft.teacherFeedback ?? props.data.assignmentTeacherFeedback);
|
||||
setDecisionDraft(draft.decision ?? props.data.nextStepOutcome);
|
||||
setPassStatusOverrideDraft(draft.passStatusOverride ?? props.data.passStatusOverride);
|
||||
setNotice(null);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const studentId = selectedStudentId();
|
||||
if (!studentId) return;
|
||||
writeNextStepDraft(props.data.assignmentId, studentId, {
|
||||
teacherFeedback: hasPendingAssignmentFeedback() ? teacherFeedbackDraft() : undefined,
|
||||
decision: hasPendingDecision() ? decisionDraft() ?? undefined : undefined,
|
||||
passStatusOverride: hasPendingPassStatusOverride() ? passStatusOverrideDraft() : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const saveFeedback = async () => {
|
||||
const studentId = selectedStudentId();
|
||||
if (!studentId) return;
|
||||
if (!canChooseNextStep()) {
|
||||
setNotice({
|
||||
scope: "assignment",
|
||||
tone: "error",
|
||||
text: "Next step stays locked until the student has submitted work for review.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingFeedback(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
if (hasPendingAssignmentFeedback() || hasPendingPassStatusOverride() || hasPendingDecision()) {
|
||||
await updateAssignmentTeacherFeedback(props.data.assignmentId, studentId, {
|
||||
teacherFeedback: teacherFeedbackDraft().trim(),
|
||||
passStatusOverride: passStatusOverrideDraft(),
|
||||
nextStepOutcome: decisionDraft(),
|
||||
});
|
||||
}
|
||||
|
||||
writeNextStepDraft(props.data.assignmentId, studentId, {});
|
||||
if (decisionDraft() === "redo") {
|
||||
navigate(getTeacherAssignmentRedoPlanHref(props.data.assignmentId, studentId));
|
||||
return;
|
||||
}
|
||||
navigate(getAssignmentReviewHref("teacher", props.data.assignmentId));
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
scope: "assignment",
|
||||
tone: "error",
|
||||
text: error instanceof Error ? error.message : "Could not save teacher feedback right now.",
|
||||
});
|
||||
} finally {
|
||||
setSavingFeedback(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={styles.contentGrid}>
|
||||
<div class={styles.mainColumn}>
|
||||
<section class={styles.section}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h2>Choose the next step</h2>
|
||||
<p>
|
||||
<Show when={props.data.selectedStudentName} fallback="Choose what should happen after this review.">
|
||||
{props.data.selectedStudentName} has been reviewed. Choose what should happen next, then save your feedback at the bottom before returning to the review page.
|
||||
</Show>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={props.data.selectedStudentId !== null} fallback={<div class={styles.emptyState}>Pick a student from the review queue first so you can set the right next step.</div>}>
|
||||
<Show
|
||||
when={canChooseNextStep()}
|
||||
fallback={<div class={styles.emptyState}>This student has not submitted work yet, so next step is not available.</div>}
|
||||
>
|
||||
<section class={styles.nextStepCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Recommended outcome</h3>
|
||||
<p>Pick the next teaching move for {props.data.selectedStudentName ?? "this student"}. The selected outcome will be saved with this student's review.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.optionGrid}>
|
||||
<For each={OUTCOME_OPTIONS}>
|
||||
{(option) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.optionCard} ${decisionDraft() === option.value ? styles.optionCardActive : ""}`.trim()}
|
||||
onClick={() => setDecisionDraft(option.value)}
|
||||
>
|
||||
<strong>{option.title}</strong>
|
||||
<span>{option.description}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class={styles.draftHint}>
|
||||
<Show when={decisionDraft()} fallback={<span>No next-step outcome has been picked yet.</span>}>
|
||||
<span>Selected outcome: {OUTCOME_OPTIONS.find((option) => option.value === decisionDraft())?.title}. Save feedback to persist it for this student.</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={styles.reviewScoreGrid}>
|
||||
<div class={`${styles.reviewField} ${styles.scoreHighlight}`.trim()}>
|
||||
<label>Overall score</label>
|
||||
<div class={styles.scoreHighlightValue}>
|
||||
<Show when={overallScorePercent() != null} fallback={<span>Pending review</span>}>
|
||||
<strong>{overallScorePercent()}%</strong>
|
||||
</Show>
|
||||
</div>
|
||||
<small>
|
||||
<Show when={props.data.overallScore != null} fallback="The score will appear after the review has been completed.">
|
||||
Blended correctness and understanding score.
|
||||
</Show>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class={styles.reviewField}>
|
||||
<label>Pass status</label>
|
||||
<input
|
||||
type="text"
|
||||
class={styles.compactInput}
|
||||
value={props.data.passStatus === "no_pass" ? "No pass" : props.data.passStatus === "pass" ? "Pass" : "Pending"}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
<small>
|
||||
<Show when={props.data.passStatusOverride} fallback="Currently using the automatic result.">
|
||||
Teacher override is active.
|
||||
</Show>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class={styles.reviewField}>
|
||||
<label>Teacher override</label>
|
||||
<select
|
||||
class={styles.compactInput}
|
||||
value={passStatusOverrideDraft() ?? ""}
|
||||
onInput={(event) => {
|
||||
const value = event.currentTarget.value.trim();
|
||||
setPassStatusOverrideDraft(value === "pass" || value === "no_pass" || value === "pending" ? value : null);
|
||||
}}
|
||||
>
|
||||
<For each={PASS_STATUS_OVERRIDE_OPTIONS}>
|
||||
{(option) => (
|
||||
<option value={option.value ?? ""}>{option.label}</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<small>{PASS_STATUS_OVERRIDE_OPTIONS.find((option) => option.value === passStatusOverrideDraft())?.help ?? PASS_STATUS_OVERRIDE_OPTIONS[0]!.help}</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AssignmentFeedbackSection
|
||||
data={props.data}
|
||||
teacherFeedbackDraft={teacherFeedbackDraft()}
|
||||
hasPendingAssignmentFeedback={hasPendingAssignmentFeedback()}
|
||||
busy={savingFeedback()}
|
||||
notice={notice()}
|
||||
draftActionLabel="Save and return to review"
|
||||
onTeacherFeedbackInput={setTeacherFeedbackDraft}
|
||||
actions={
|
||||
<button type="button" class={styles.primaryAction} disabled={savingFeedback()} onClick={saveFeedback}>
|
||||
{savingFeedback()
|
||||
? decisionDraft() === "redo"
|
||||
? "Saving and opening redo plan..."
|
||||
: "Saving and returning..."
|
||||
: decisionDraft() === "redo"
|
||||
? "Save feedback and open redo plan"
|
||||
: "Save feedback and return to review"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class={styles.sideColumn}>
|
||||
<section class={styles.sideCard}>
|
||||
<p class={styles.sideEyebrow}>Student</p>
|
||||
<h2>{props.data.selectedStudentName ?? "Review summary"}</h2>
|
||||
<p>{props.data.selectedStudentEmail ?? props.data.coachCard.description}</p>
|
||||
<Show when={props.data.selectedStudentProgress}>
|
||||
<div class={styles.progressNote}>{props.data.selectedStudentProgress}</div>
|
||||
</Show>
|
||||
<ul class={styles.noteList}>
|
||||
<For each={props.data.coachCard.items}>{(item) => <li>{item}</li>}</For>
|
||||
</ul>
|
||||
<div class={styles.actionRow}>
|
||||
<A href={getAssignmentReviewHref("teacher", props.data.assignmentId)} class={styles.secondaryAction}>
|
||||
Back to review
|
||||
</A>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignmentTeacherNextStep;
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createTeacherAssignment, generateTeacherQuestions } from "../../dashboard/teacher/dashboard-teacher-assignments.data";
|
||||
import type { TeacherRedoPlanData, TeacherRedoPlanQuestion } from "./assignment-teacher-review.types";
|
||||
import type { RedoPlanGenerationResult, RedoPlanGroupedItem } from "./assignment-teacher-redo-plan.types";
|
||||
|
||||
const compareGroupedItems = (left: RedoPlanGroupedItem, right: RedoPlanGroupedItem) => {
|
||||
if (left.topic !== right.topic) return left.topic.localeCompare(right.topic);
|
||||
return left.difficulty.localeCompare(right.difficulty);
|
||||
};
|
||||
|
||||
export const groupRedoPlanQuestions = (questionSet: TeacherRedoPlanQuestion[]): RedoPlanGroupedItem[] => {
|
||||
const groupedItems = new Map<
|
||||
string,
|
||||
{
|
||||
topic: string;
|
||||
difficulty: "easy" | "medium" | "hard";
|
||||
count: number;
|
||||
tags: Set<string>;
|
||||
reasons: string[];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const item of questionSet) {
|
||||
const key = `${item.topicKey}::${item.difficultyKey}`;
|
||||
const existing = groupedItems.get(key);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
item.tags.forEach((tag) => existing.tags.add(tag));
|
||||
existing.reasons.push(item.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
groupedItems.set(key, {
|
||||
topic: item.topicKey,
|
||||
difficulty: item.difficultyKey,
|
||||
count: 1,
|
||||
tags: new Set(item.tags),
|
||||
reasons: [item.reason],
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(groupedItems.values())
|
||||
.map((item) => ({
|
||||
topic: item.topic,
|
||||
difficulty: item.difficulty,
|
||||
count: item.count,
|
||||
tags: Array.from(item.tags),
|
||||
reasons: item.reasons,
|
||||
}))
|
||||
.sort(compareGroupedItems);
|
||||
};
|
||||
|
||||
export const buildPlannedAreasMarkdown = (groupedItems: RedoPlanGroupedItem[]) =>
|
||||
groupedItems
|
||||
.map(
|
||||
(item, index) =>
|
||||
`- ${index + 1}. ${item.topic.replace(/_/g, " ")} (${item.difficulty}) x${item.count}${item.reasons.length > 0 ? ` — ${item.reasons.join("; ")}` : ""}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
export const createRedoAssignmentForStudent = async (input: {
|
||||
data: TeacherRedoPlanData;
|
||||
teacherId: number;
|
||||
}): Promise<RedoPlanGenerationResult> => {
|
||||
const groupedItems = groupRedoPlanQuestions(input.data.plan?.questionSet ?? []);
|
||||
const plannedAreas = buildPlannedAreasMarkdown(groupedItems);
|
||||
const generatedQuestionIds: number[] = [];
|
||||
const generatedDescriptions: string[] = [];
|
||||
|
||||
for (const item of groupedItems) {
|
||||
const result = await generateTeacherQuestions({
|
||||
topic: item.topic,
|
||||
difficulty: item.difficulty,
|
||||
count: item.count,
|
||||
source: "redo_plan_generated",
|
||||
});
|
||||
|
||||
generatedQuestionIds.push(...result.generatedQuestionIds);
|
||||
generatedDescriptions.push(`${item.topic.replace(/_/g, " ")} ${item.difficulty} ×${result.count} (seed ${result.seed})`);
|
||||
}
|
||||
|
||||
const assignment = await createTeacherAssignment({
|
||||
teacherId: input.teacherId,
|
||||
classroomId: input.data.classroomId,
|
||||
title: `${input.data.selectedStudentName} redo • ${input.data.title}`,
|
||||
instructions: [
|
||||
`Student-specific redo follow-up for ${input.data.selectedStudentName}.`,
|
||||
`Source assignment: ${input.data.title}`,
|
||||
"",
|
||||
`Plan rationale: ${input.data.plan?.rationale ?? ""}`,
|
||||
input.data.teacherFeedback ? `Teacher feedback: ${input.data.teacherFeedback}` : "",
|
||||
input.data.weaknessSummary.weakTags.length > 0 ? `Weak tags: ${input.data.weaknessSummary.weakTags.join(", ")}` : "",
|
||||
"",
|
||||
"Planned focus areas:",
|
||||
plannedAreas,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
dueAt: "",
|
||||
selectedQuestionIds: Array.from(new Set(generatedQuestionIds)),
|
||||
assignedStudentIds: [input.data.selectedStudentId],
|
||||
});
|
||||
|
||||
return {
|
||||
assignmentId: assignment.id,
|
||||
successMessage: `Created a redo assignment for ${input.data.selectedStudentName}. ${generatedDescriptions.join("; ")}.`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { For, Show } from "solid-js";
|
||||
import { getAssignmentReviewHref, getTeacherAssignmentNextStepHref } from "../../../lib/routes";
|
||||
import type { TeacherRedoPlanData } from "./assignment-teacher-review.types";
|
||||
import styles from "./assignment-teacher-review.module.scss";
|
||||
|
||||
type RedoPlanMainColumnProps = {
|
||||
data: TeacherRedoPlanData;
|
||||
};
|
||||
|
||||
export const RedoPlanMainColumn: Component<RedoPlanMainColumnProps> = (props) => (
|
||||
<div class={styles.mainColumn}>
|
||||
<section class={styles.section}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h2>Redo plan</h2>
|
||||
<p>
|
||||
AI prepared this redo plan for {props.data.selectedStudentName} based on the completed review, weakness summary, and teacher feedback.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={props.data.error}>
|
||||
<div class={`${styles.notice} ${styles.noticeError}`.trim()}>{props.data.error}</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={props.data.plan}
|
||||
fallback={<div class={styles.emptyState}>No redo plan is available yet. Save the next-step page with <strong>Redo assignment</strong> selected to generate one.</div>}
|
||||
>
|
||||
<section class={styles.nextStepCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Rationale</h3>
|
||||
<p>Why this student needs the planned mix of follow-up practice.</p>
|
||||
</div>
|
||||
<div class={styles.responseBlock}>
|
||||
<strong>{props.data.plan!.rationale}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.section}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Planned question set</h3>
|
||||
<p>These blueprint slices will be turned into a student-specific redo assignment when you generate it.</p>
|
||||
</div>
|
||||
<div class={styles.planList}>
|
||||
<For each={props.data.plan!.questionSet}>
|
||||
{(item, index) => (
|
||||
<article class={styles.planCard}>
|
||||
<div class={styles.questionTop}>
|
||||
<div>
|
||||
<p class={styles.order}>Item {index() + 1}</p>
|
||||
<h3>{item.topic}</h3>
|
||||
</div>
|
||||
<span class={`${styles.statusPill} ${styles.progress}`.trim()}>{item.difficulty}</span>
|
||||
</div>
|
||||
<Show when={item.tags.length > 0}>
|
||||
<div class={styles.tagList}>
|
||||
<For each={item.tags}>{(tag) => <span class={styles.tagPill}>{tag}</span>}</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class={styles.supportBlock}>
|
||||
<p>Reason</p>
|
||||
<span>{item.reason}</span>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
type RedoPlanSidebarProps = {
|
||||
data: TeacherRedoPlanData;
|
||||
isCreatingRedoAssignment: boolean;
|
||||
generationError: string | null;
|
||||
generationSuccess: string | null;
|
||||
onGenerateRedoQuestions: () => void;
|
||||
};
|
||||
|
||||
export const RedoPlanSidebar: Component<RedoPlanSidebarProps> = (props) => (
|
||||
<aside class={styles.sideColumn}>
|
||||
<section class={styles.sideCard}>
|
||||
<p class={styles.sideEyebrow}>Student</p>
|
||||
<h2>{props.data.selectedStudentName}</h2>
|
||||
<p>{props.data.selectedStudentEmail}</p>
|
||||
<div class={styles.progressNote}>Generated for {props.data.title}</div>
|
||||
<Show when={props.data.generatedAtLabel}>
|
||||
<div class={styles.draftHint}>Generated {props.data.generatedAtLabel}</div>
|
||||
</Show>
|
||||
<Show when={props.data.plan?.questionSet.length}>
|
||||
<button type="button" class={styles.primaryAction} onClick={props.onGenerateRedoQuestions} disabled={props.isCreatingRedoAssignment}>
|
||||
{props.isCreatingRedoAssignment ? "Creating redo assignment…" : "Generate redo questions"}
|
||||
</button>
|
||||
<p class={styles.progressNote}>This creates and assigns an independent redo assignment for {props.data.selectedStudentName} only.</p>
|
||||
</Show>
|
||||
<Show when={props.generationError}>
|
||||
<div class={`${styles.notice} ${styles.noticeError}`.trim()}>{props.generationError}</div>
|
||||
</Show>
|
||||
<Show when={props.generationSuccess}>
|
||||
<div class={`${styles.notice} ${styles.noticeSuccess}`.trim()}>{props.generationSuccess}</div>
|
||||
</Show>
|
||||
<div class={styles.actionRow}>
|
||||
<A href={getTeacherAssignmentNextStepHref(props.data.assignmentId, props.data.selectedStudentId)} class={styles.secondaryAction}>
|
||||
Back to next step
|
||||
</A>
|
||||
<A href={getAssignmentReviewHref("teacher", props.data.assignmentId)} class={styles.secondaryAction}>
|
||||
Back to review
|
||||
</A>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.sideCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Teacher feedback</h3>
|
||||
<p>The feedback context used when this redo plan was created.</p>
|
||||
</div>
|
||||
<div class={styles.supportBlock}>
|
||||
<span>{props.data.teacherFeedback || "No teacher feedback saved yet."}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.sideCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Topic scores</h3>
|
||||
<p>Historical performance snapshot used to plan the redo.</p>
|
||||
</div>
|
||||
<Show
|
||||
when={props.data.weaknessSummary.topicScores.length > 0}
|
||||
fallback={<div class={styles.emptyState}>No topic history is available yet for this student.</div>}
|
||||
>
|
||||
<div class={styles.scoreList}>
|
||||
<For each={props.data.weaknessSummary.topicScores}>
|
||||
{(item) => (
|
||||
<div class={styles.scoreRow}>
|
||||
<span>{item.topic}</span>
|
||||
<strong>{item.score.toFixed(1)}%</strong>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<section class={styles.sideCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Weak tags</h3>
|
||||
<p>Low-performing or flagged tags from previous work.</p>
|
||||
</div>
|
||||
<Show
|
||||
when={props.data.weaknessSummary.weakTags.length > 0}
|
||||
fallback={<div class={styles.emptyState}>No weak tags were identified from the current review history.</div>}
|
||||
>
|
||||
<div class={styles.tagList}>
|
||||
<For each={props.data.weaknessSummary.weakTags}>{(tag) => <span class={styles.tagPill}>{tag}</span>}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<section class={styles.sideCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>Recent issues</h3>
|
||||
<p>Recent issue reasons pulled into the weakness summary.</p>
|
||||
</div>
|
||||
<Show
|
||||
when={props.data.weaknessSummary.recentIssues.length > 0}
|
||||
fallback={<div class={styles.emptyState}>No recent issue reasons were available.</div>}
|
||||
>
|
||||
<ul class={styles.noteList}>
|
||||
<For each={props.data.weaknessSummary.recentIssues}>{(issue) => <li>{issue}</li>}</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { createSignal } from "solid-js";
|
||||
import { useAuth } from "~/context/auth/context";
|
||||
import { getAssignmentReviewHref } from "../../../lib/routes";
|
||||
import { createRedoAssignmentForStudent } from "./assignment-teacher-redo-plan.helpers";
|
||||
import { RedoPlanMainColumn, RedoPlanSidebar } from "./assignment-teacher-redo-plan.sections";
|
||||
import type { AssignmentTeacherRedoPlanProps } from "./assignment-teacher-redo-plan.types";
|
||||
import styles from "./assignment-teacher-review.module.scss";
|
||||
|
||||
const AssignmentTeacherRedoPlan: Component<AssignmentTeacherRedoPlanProps> = (props) => {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [isCreatingRedoAssignment, setIsCreatingRedoAssignment] = createSignal(false);
|
||||
const [generationError, setGenerationError] = createSignal<string | null>(null);
|
||||
const [generationSuccess, setGenerationSuccess] = createSignal<string | null>(null);
|
||||
|
||||
const handleGenerateRedoQuestions = async () => {
|
||||
if (!props.data.plan?.questionSet.length) return;
|
||||
const teacherId = auth.user()?.role === "teacher" ? auth.user()!.id : null;
|
||||
if (!teacherId) {
|
||||
setGenerationError("Your teacher session is still loading.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingRedoAssignment(true);
|
||||
setGenerationError(null);
|
||||
setGenerationSuccess(null);
|
||||
|
||||
try {
|
||||
const result = await createRedoAssignmentForStudent({
|
||||
data: props.data,
|
||||
teacherId,
|
||||
});
|
||||
|
||||
setGenerationSuccess(result.successMessage);
|
||||
void navigate(getAssignmentReviewHref("teacher", result.assignmentId));
|
||||
} catch (error) {
|
||||
setGenerationError(error instanceof Error ? error.message : "Unable to create the redo assignment right now.");
|
||||
} finally {
|
||||
setIsCreatingRedoAssignment(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={styles.contentGrid}>
|
||||
<RedoPlanMainColumn data={props.data} />
|
||||
<RedoPlanSidebar
|
||||
data={props.data}
|
||||
isCreatingRedoAssignment={isCreatingRedoAssignment()}
|
||||
generationError={generationError()}
|
||||
generationSuccess={generationSuccess()}
|
||||
onGenerateRedoQuestions={() => void handleGenerateRedoQuestions()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignmentTeacherRedoPlan;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { TeacherRedoPlanData } from "./assignment-teacher-review.types";
|
||||
|
||||
export type AssignmentTeacherRedoPlanProps = {
|
||||
data: TeacherRedoPlanData;
|
||||
};
|
||||
|
||||
export type RedoPlanGroupedItem = {
|
||||
topic: string;
|
||||
difficulty: "easy" | "medium" | "hard";
|
||||
count: number;
|
||||
tags: string[];
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
export type RedoPlanGenerationResult = {
|
||||
assignmentId: number;
|
||||
successMessage: string;
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
// Path: Frontend/src/components/assignment/teacher/assignment-teacher-review.data.ts
|
||||
|
||||
import { apiFetchJson } from "../../../lib/api";
|
||||
import type {
|
||||
ApiAssignment,
|
||||
ApiAssignmentRedoPlanResponse,
|
||||
ApiAssignmentStudentQuestionDetail,
|
||||
ApiClassroom,
|
||||
ApiListResponse,
|
||||
ApiReviewQueueItem,
|
||||
ApiReviewSummary,
|
||||
} from "../../../lib/api-types";
|
||||
import {
|
||||
buildCloseSummary,
|
||||
buildQuestionTags,
|
||||
extractTopic,
|
||||
formatDateLabel,
|
||||
formatRelativeLabel,
|
||||
formatSolveMode,
|
||||
mapQueueReviewStatus,
|
||||
mapRedoPlan,
|
||||
mapTopicScores,
|
||||
normalizeScore,
|
||||
questionStatus,
|
||||
queueStatusLabel,
|
||||
queueTone,
|
||||
} from "./assignment-teacher-review.formatters";
|
||||
import type {
|
||||
TeacherAssignmentPassStatus,
|
||||
TeacherAssignmentReviewPageData,
|
||||
TeacherNextStepOutcome,
|
||||
TeacherRedoPlanData,
|
||||
TeacherReviewDraftFields,
|
||||
TeacherReviewQuestion,
|
||||
} from "./assignment-teacher-review.types";
|
||||
|
||||
type UpdateTeacherAnswerReviewInput = TeacherReviewDraftFields & {
|
||||
status: "not_started" | "in_progress" | "submitted" | "reviewed";
|
||||
reviewTags: string[];
|
||||
};
|
||||
|
||||
type UpdateAssignmentTeacherFeedbackInput = {
|
||||
teacherFeedback: string;
|
||||
passStatusOverride: TeacherAssignmentPassStatus | null;
|
||||
nextStepOutcome: TeacherNextStepOutcome | null;
|
||||
};
|
||||
|
||||
export const getTeacherAssignmentReviewPageData = async (assignmentId: number, selectedStudentId?: number | null): Promise<TeacherAssignmentReviewPageData | null> => {
|
||||
if (!Number.isFinite(assignmentId) || assignmentId <= 0) return null;
|
||||
|
||||
try {
|
||||
const assignment = await apiFetchJson<ApiAssignment>(`/api/assignments/${assignmentId}`);
|
||||
const [classrooms, summary, queueResponse] = await Promise.all([
|
||||
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${assignment.teacher_id}/classrooms`),
|
||||
apiFetchJson<ApiReviewSummary>(`/api/assignments/${assignmentId}/review-summary`),
|
||||
apiFetchJson<ApiListResponse<ApiReviewQueueItem>>(`/api/assignments/${assignmentId}/review`),
|
||||
]);
|
||||
|
||||
const classroom = classrooms.data.find((entry) => entry.id === assignment.classroom_id);
|
||||
const queue = queueResponse.data;
|
||||
const resolvedStudentId = selectedStudentId ?? queue[0]?.student_id ?? null;
|
||||
const selectedStudent = queue.find((item) => item.student_id === resolvedStudentId) ?? null;
|
||||
const questionDetails = resolvedStudentId
|
||||
? await apiFetchJson<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/api/assignments/${assignmentId}/students/${resolvedStudentId}/questions`)
|
||||
: { data: [] as ApiAssignmentStudentQuestionDetail[] };
|
||||
const questions = questionDetails.data;
|
||||
const assignmentAiFeedback = questions[0]?.assignment_ai_feedback?.trim() || "";
|
||||
const assignmentTeacherFeedback = questions[0]?.assignment_teacher_feedback?.trim() || "";
|
||||
const overallScore = normalizeScore(questions[0]?.overall_score);
|
||||
const passThreshold =
|
||||
typeof assignment.pass_threshold === "number"
|
||||
? assignment.pass_threshold
|
||||
: typeof questions[0]?.pass_threshold === "number"
|
||||
? questions[0]!.pass_threshold!
|
||||
: 6;
|
||||
const nextStepOutcome = (questions[0]?.next_step_outcome as TeacherNextStepOutcome | undefined) ?? null;
|
||||
const passStatusOverride = (questions[0]?.pass_status_override as TeacherAssignmentPassStatus | undefined) ?? null;
|
||||
const passStatus = (questions[0]?.pass_status as TeacherAssignmentPassStatus | undefined) ?? "pending";
|
||||
const topic = extractTopic(assignment, questions);
|
||||
const reviewCoverage = summary.total_assigned > 0 ? Math.round((summary.reviewed / summary.total_assigned) * 100) : 0;
|
||||
|
||||
return {
|
||||
assignmentId: assignment.id,
|
||||
title: assignment.title,
|
||||
classroomId: assignment.classroom_id,
|
||||
classroomName: classroom?.name ?? "Classroom",
|
||||
statusLabel: assignment.status === "draft" ? "Draft" : assignment.status === "closed" ? "Closed" : "Live",
|
||||
headline: selectedStudent ? `Review ${selectedStudent.student_name}'s responses for ${topic}` : `Review submissions for ${topic}`,
|
||||
description: selectedStudent
|
||||
? `${selectedStudent.student_name} has ${selectedStudent.answered_questions} answered question${selectedStudent.answered_questions === 1 ? "" : "s"}. Flag any response that still needs attention, then move to the next step when the rest is ready.`
|
||||
: "Choose a student from the review queue to inspect answers, working steps, and feedback status for this assignment.",
|
||||
dueLabel: formatDateLabel(assignment.due_at),
|
||||
stats: [
|
||||
{ label: "Assigned", value: `${summary.total_assigned}` },
|
||||
{ label: "Submitted", value: `${summary.submitted}` },
|
||||
{ label: "Reviewed", value: `${summary.reviewed}` },
|
||||
{ label: "Coverage", value: `${reviewCoverage}%` },
|
||||
],
|
||||
closeSummary: buildCloseSummary(assignment.status, queue),
|
||||
reviewQueue: queue.map((item) => ({
|
||||
studentId: item.student_id,
|
||||
studentName: item.student_name,
|
||||
email: item.student_email,
|
||||
reviewStatus: item.review_status,
|
||||
nextStepOutcome: mapQueueReviewStatus(item.next_step_outcome),
|
||||
submittedQuestions: item.submitted_questions,
|
||||
reviewedQuestions: item.reviewed_questions,
|
||||
progressLabel: `${item.reviewed_questions}/${item.total_questions} reviewed · ${item.answered_questions} answered`,
|
||||
statusLabel: queueStatusLabel(item),
|
||||
statusTone: queueTone(item),
|
||||
timestampLabel: formatRelativeLabel(item.latest_submitted_at ?? item.latest_reviewed_at, "No submission yet"),
|
||||
})),
|
||||
selectedStudentId: selectedStudent?.student_id ?? null,
|
||||
selectedStudentName: selectedStudent?.student_name ?? null,
|
||||
selectedStudentEmail: selectedStudent?.student_email ?? null,
|
||||
selectedStudentProgress: selectedStudent ? `${selectedStudent.reviewed_questions} of ${selectedStudent.total_questions} reviewed · ${selectedStudent.submitted_questions} waiting` : null,
|
||||
selectedStudentReviewStatus: selectedStudent?.review_status ?? null,
|
||||
selectedStudentSubmittedQuestions: selectedStudent?.submitted_questions ?? 0,
|
||||
selectedStudentReviewedQuestions: selectedStudent?.reviewed_questions ?? 0,
|
||||
assignmentAiFeedback,
|
||||
assignmentTeacherFeedback,
|
||||
overallScore,
|
||||
passThreshold,
|
||||
nextStepOutcome,
|
||||
passStatusOverride,
|
||||
passStatus,
|
||||
coachCard: {
|
||||
title: selectedStudent ? "Teacher review notes" : "Review queue guidance",
|
||||
description: selectedStudent
|
||||
? "Flag only the responses that still need attention. Moving to the next step will treat everything else as reviewed."
|
||||
: "Start with the freshest submitted work, then move through the queue in order of urgency.",
|
||||
items: [`${summary.submitted} submission${summary.submitted === 1 ? "" : "s"} waiting`, `${summary.in_progress} in progress`, `${summary.not_started} not started`],
|
||||
},
|
||||
questions: questions.map((question): TeacherReviewQuestion => {
|
||||
const status = questionStatus(question);
|
||||
return {
|
||||
id: question.question_id,
|
||||
order: question.position,
|
||||
prompt: question.prompt,
|
||||
subject: question.subject,
|
||||
source: question.source,
|
||||
questionTags: buildQuestionTags(question),
|
||||
answerId: question.answer_id ?? null,
|
||||
answerStatus: (question.answer_status as TeacherReviewQuestion["answerStatus"]) ?? null,
|
||||
statusLabel: status.statusLabel,
|
||||
statusTone: status.statusTone,
|
||||
answerText: question.answer_text?.trim() || "No answer has been submitted for this question yet.",
|
||||
solveModeLabel: formatSolveMode(question.solve_mode),
|
||||
workingSteps: question.working_steps?.trim() || "",
|
||||
correctAnswer: question.correct_answer?.trim() || null,
|
||||
isCorrect: typeof question.is_correct === "boolean" ? question.is_correct : null,
|
||||
reviewNeedsAttention: Boolean(question.review_needs_attention),
|
||||
reviewIssueReason: question.review_issue_reason?.trim() || "",
|
||||
reviewCorrectnessScore: normalizeScore(question.review_correctness_score),
|
||||
reviewUnderstandingScore: normalizeScore(question.review_understanding_score),
|
||||
reviewQuestionScore: normalizeScore(question.review_question_score),
|
||||
reviewConfidence: normalizeScore(question.review_confidence),
|
||||
correctnessLabel:
|
||||
typeof question.is_correct === "boolean"
|
||||
? question.is_correct
|
||||
? "Matches the saved correct answer"
|
||||
: "Does not match the saved correct answer yet"
|
||||
: question.correct_answer
|
||||
? "Correct answer is available for comparison"
|
||||
: "No correct answer saved yet",
|
||||
submittedLabel: formatDateLabel(question.submitted_at, "Not submitted"),
|
||||
reviewedLabel: formatDateLabel(question.reviewed_at, "Not reviewed yet"),
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "not_found") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTeacherAnswerReview = async (answerId: number, input: UpdateTeacherAnswerReviewInput) => {
|
||||
return await apiFetchJson(`/api/answers/${answerId}/review`, {
|
||||
method: "PATCH",
|
||||
allowNoContent: true,
|
||||
body: JSON.stringify({
|
||||
status: input.status,
|
||||
review_needs_attention: input.reviewNeedsAttention,
|
||||
review_issue_reason: input.reviewIssueReason || null,
|
||||
review_correctness_score: input.reviewCorrectnessScore,
|
||||
review_understanding_score: input.reviewUnderstandingScore,
|
||||
review_question_score: input.reviewQuestionScore,
|
||||
review_confidence: input.reviewConfidence,
|
||||
review_tags: input.reviewTags,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateAssignmentTeacherFeedback = async (assignmentId: number, studentId: number, input: UpdateAssignmentTeacherFeedbackInput) => {
|
||||
return await apiFetchJson(`/api/assignments/${assignmentId}/students/${studentId}/feedback`, {
|
||||
method: "PATCH",
|
||||
allowNoContent: true,
|
||||
body: JSON.stringify({
|
||||
teacher_feedback: input.teacherFeedback,
|
||||
pass_status_override: input.passStatusOverride,
|
||||
next_step_outcome: input.nextStepOutcome,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export const closeTeacherAssignment = async (assignmentId: number) => {
|
||||
return await apiFetchJson<ApiAssignment>(`/api/assignments/${assignmentId}/close`, {
|
||||
method: "POST",
|
||||
parseErrorMessage: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTeacherAssignmentRedoPlanData = async (assignmentId: number, studentId: number): Promise<TeacherRedoPlanData | null> => {
|
||||
if (!Number.isFinite(assignmentId) || assignmentId <= 0 || !Number.isFinite(studentId) || studentId <= 0) return null;
|
||||
|
||||
const [reviewData, redoPlan] = await Promise.all([
|
||||
getTeacherAssignmentReviewPageData(assignmentId, studentId),
|
||||
apiFetchJson<ApiAssignmentRedoPlanResponse>(`/api/assignments/${assignmentId}/students/${studentId}/redo-plan`, { notFoundAsNull: true }),
|
||||
]);
|
||||
|
||||
if (!reviewData || !redoPlan || !reviewData.selectedStudentId || !reviewData.selectedStudentName || !reviewData.selectedStudentEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
assignmentId: reviewData.assignmentId,
|
||||
title: reviewData.title,
|
||||
classroomId: reviewData.classroomId,
|
||||
classroomName: reviewData.classroomName,
|
||||
selectedStudentId: reviewData.selectedStudentId,
|
||||
selectedStudentName: reviewData.selectedStudentName,
|
||||
selectedStudentEmail: reviewData.selectedStudentEmail,
|
||||
teacherFeedback: redoPlan.teacher_feedback?.trim() || reviewData.assignmentTeacherFeedback,
|
||||
generatedAtLabel: redoPlan.redo_plan_generated_at ? formatRelativeLabel(redoPlan.redo_plan_generated_at) : null,
|
||||
weaknessSummary: {
|
||||
studentId: redoPlan.weakness_summary.student_id,
|
||||
topicScores: mapTopicScores(redoPlan.weakness_summary),
|
||||
weakTags: Array.isArray(redoPlan.weakness_summary.weak_tags) ? redoPlan.weakness_summary.weak_tags.filter(Boolean) : [],
|
||||
recentIssues: Array.isArray(redoPlan.weakness_summary.recent_issues) ? redoPlan.weakness_summary.recent_issues.filter(Boolean) : [],
|
||||
},
|
||||
plan: mapRedoPlan(redoPlan.plan),
|
||||
error: redoPlan.error?.trim() || null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { TeacherReviewDraftFields, TeacherReviewQuestion } from "./assignment-teacher-review.types";
|
||||
import { assignmentUiCopy } from "~/content/ui-copy";
|
||||
|
||||
export type QuestionReviewDraft = TeacherReviewDraftFields & {
|
||||
answerId: number;
|
||||
};
|
||||
|
||||
export type StudentReviewDraft = {
|
||||
questionReviews?: Record<string, QuestionReviewDraft>;
|
||||
};
|
||||
|
||||
export type AssignmentReviewDraftStore = {
|
||||
students: Record<string, StudentReviewDraft>;
|
||||
};
|
||||
|
||||
export const EMPTY_DRAFT_STORE: AssignmentReviewDraftStore = {
|
||||
students: {},
|
||||
};
|
||||
|
||||
export const draftStorageKeyForAssignment = (assignmentId: number) => `teacher-review-draft:${assignmentId}`;
|
||||
|
||||
export const readDraftStore = (assignmentId: number): AssignmentReviewDraftStore => {
|
||||
if (typeof window === "undefined") return EMPTY_DRAFT_STORE;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(draftStorageKeyForAssignment(assignmentId));
|
||||
if (!raw) return EMPTY_DRAFT_STORE;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || typeof parsed.students !== "object" || parsed.students === null) {
|
||||
return EMPTY_DRAFT_STORE;
|
||||
}
|
||||
|
||||
return {
|
||||
students: parsed.students as AssignmentReviewDraftStore["students"],
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_DRAFT_STORE;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeDraftStore = (assignmentId: number, draftStore: AssignmentReviewDraftStore) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const key = draftStorageKeyForAssignment(assignmentId);
|
||||
if (Object.keys(draftStore.students).length === 0) {
|
||||
window.localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(key, JSON.stringify(draftStore));
|
||||
};
|
||||
|
||||
export const reviewFieldsFromQuestion = (question: TeacherReviewQuestion): TeacherReviewDraftFields => ({
|
||||
reviewNeedsAttention: question.reviewNeedsAttention,
|
||||
reviewIssueReason: question.reviewIssueReason,
|
||||
reviewCorrectnessScore: question.reviewCorrectnessScore,
|
||||
reviewUnderstandingScore: question.reviewUnderstandingScore,
|
||||
reviewQuestionScore: question.reviewQuestionScore,
|
||||
reviewConfidence: question.reviewConfidence,
|
||||
});
|
||||
|
||||
const normalizeOptionalScore = (value: number | null) => (typeof value === "number" && Number.isFinite(value) ? Math.min(1, Math.max(0, Number(value.toFixed(3)))) : null);
|
||||
|
||||
export const sanitizeReviewFields = (fields: TeacherReviewDraftFields): TeacherReviewDraftFields => ({
|
||||
reviewNeedsAttention: Boolean(fields.reviewNeedsAttention),
|
||||
reviewIssueReason: fields.reviewIssueReason.trim(),
|
||||
reviewCorrectnessScore: 1,
|
||||
reviewUnderstandingScore: normalizeOptionalScore(fields.reviewUnderstandingScore),
|
||||
reviewQuestionScore: 1,
|
||||
reviewConfidence: normalizeOptionalScore(fields.reviewConfidence),
|
||||
});
|
||||
|
||||
export const reviewFieldsEqual = (left: TeacherReviewDraftFields, right: TeacherReviewDraftFields) =>
|
||||
left.reviewNeedsAttention === right.reviewNeedsAttention &&
|
||||
left.reviewIssueReason === right.reviewIssueReason &&
|
||||
left.reviewCorrectnessScore === right.reviewCorrectnessScore &&
|
||||
left.reviewUnderstandingScore === right.reviewUnderstandingScore &&
|
||||
left.reviewQuestionScore === right.reviewQuestionScore &&
|
||||
left.reviewConfidence === right.reviewConfidence;
|
||||
|
||||
const questionStatusMeta = (status: TeacherReviewQuestion["answerStatus"]) => {
|
||||
if (status === "reviewed") {
|
||||
return { statusLabel: assignmentUiCopy.teacherReview.status.reviewed, statusTone: "success" as const };
|
||||
}
|
||||
if (status === "submitted") {
|
||||
return { statusLabel: assignmentUiCopy.teacherReview.status.submitted, statusTone: "review" as const };
|
||||
}
|
||||
if (status === "in_progress") {
|
||||
return { statusLabel: assignmentUiCopy.teacherReview.status.inProgress, statusTone: "progress" as const };
|
||||
}
|
||||
return { statusLabel: assignmentUiCopy.teacherReview.status.noAnswerYet, statusTone: "muted" as const };
|
||||
};
|
||||
|
||||
export const applyReviewDrafts = (questions: TeacherReviewQuestion[], studentDraft: StudentReviewDraft | null) =>
|
||||
questions.map((question) => {
|
||||
const draftReview = studentDraft?.questionReviews?.[String(question.id)] ?? null;
|
||||
if (!draftReview) return question;
|
||||
|
||||
const effectiveReview = sanitizeReviewFields({
|
||||
...reviewFieldsFromQuestion(question),
|
||||
...draftReview,
|
||||
});
|
||||
const nextStatus = effectiveReview.reviewNeedsAttention ? "submitted" : question.answerStatus;
|
||||
const statusMeta = questionStatusMeta(nextStatus);
|
||||
|
||||
return {
|
||||
...question,
|
||||
...effectiveReview,
|
||||
answerStatus: nextStatus,
|
||||
statusLabel: effectiveReview.reviewNeedsAttention ? assignmentUiCopy.teacherReview.status.needsAttention : statusMeta.statusLabel,
|
||||
statusTone: statusMeta.statusTone,
|
||||
reviewedLabel: effectiveReview.reviewNeedsAttention ? assignmentUiCopy.teacherReview.status.needsAttentionDraft : question.reviewedLabel,
|
||||
};
|
||||
});
|
||||
|
||||
export const countPendingQuestionDrafts = (draftStore: AssignmentReviewDraftStore) =>
|
||||
Object.values(draftStore.students).reduce((count, studentDraft) => count + Object.keys(studentDraft.questionReviews ?? {}).length, 0);
|
||||
@@ -0,0 +1,194 @@
|
||||
import type {
|
||||
ApiAssignment,
|
||||
ApiAssignmentStudentQuestionDetail,
|
||||
ApiRedoPlan,
|
||||
ApiRedoPlanQuestion,
|
||||
ApiReviewQueueItem,
|
||||
ApiStudentWeaknessSummary,
|
||||
} from "../../../lib/api-types";
|
||||
import type {
|
||||
TeacherAssignmentCloseSummary,
|
||||
TeacherNextStepOutcome,
|
||||
TeacherRedoPlanQuestion,
|
||||
TeacherReviewQueueItem,
|
||||
} from "./assignment-teacher-review.types";
|
||||
import { assignmentUiCopy } from "~/content/ui-copy";
|
||||
|
||||
export const formatDateLabel = (value: string | null | undefined, fallback = "No due date") => {
|
||||
if (!value) return fallback;
|
||||
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
export const formatRelativeLabel = (value: string | null | undefined, fallback = "No recent update") => {
|
||||
if (!value) return fallback;
|
||||
|
||||
const timestamp = new Date(value).getTime();
|
||||
if (Number.isNaN(timestamp)) return fallback;
|
||||
|
||||
const diffMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000));
|
||||
if (diffMinutes < 1) return "Just now";
|
||||
if (diffMinutes < 60) return `${diffMinutes} min ago`;
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours} hr ago`;
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
|
||||
};
|
||||
|
||||
export const formatSolveMode = (value: ApiAssignmentStudentQuestionDetail["solve_mode"]) => {
|
||||
switch (value) {
|
||||
case "step_by_step":
|
||||
return "Step by step";
|
||||
case "solve_together":
|
||||
return "Solve together";
|
||||
case "handwritten":
|
||||
return "Handwritten";
|
||||
case "just_answer":
|
||||
return "Just answer";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
|
||||
const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/im)?.[1]?.trim();
|
||||
if (fromInstructions) return fromInstructions;
|
||||
return questions[0]?.subject ?? "this assignment";
|
||||
};
|
||||
|
||||
export const queueTone = (item: ApiReviewQueueItem): TeacherReviewQueueItem["statusTone"] => {
|
||||
if (item.review_status === "submitted") return "review";
|
||||
if (item.review_status === "in_progress" || item.reviewed_questions > 0 || item.answered_questions > 0) return "progress";
|
||||
if (item.next_step_outcome === "accept") return "success";
|
||||
if (item.next_step_outcome === "redo" || item.next_step_outcome === "support") return "review";
|
||||
return "muted";
|
||||
};
|
||||
|
||||
export const queueStatusLabel = (item: ApiReviewQueueItem) => {
|
||||
if (item.review_status === "submitted") return "Submitted";
|
||||
if (item.review_status === "in_progress" || item.reviewed_questions > 0 || item.answered_questions > 0) return "In progress";
|
||||
if (item.next_step_outcome === "redo") return "Redo";
|
||||
if (item.next_step_outcome === "accept") return "Accept";
|
||||
if (item.next_step_outcome === "support") return "Support";
|
||||
return "Not started";
|
||||
};
|
||||
|
||||
export const buildCloseSummary = (assignmentStatus: ApiAssignment["status"], queue: ApiReviewQueueItem[]): TeacherAssignmentCloseSummary => {
|
||||
if (assignmentStatus === "closed") {
|
||||
return {
|
||||
state: "closed",
|
||||
canClose: false,
|
||||
blockers: [],
|
||||
summary: "This assignment is already closed.",
|
||||
};
|
||||
}
|
||||
|
||||
if (queue.length === 0) {
|
||||
return {
|
||||
state: "blocked",
|
||||
canClose: false,
|
||||
blockers: ["No students have been assigned yet."],
|
||||
summary: "Assign at least one student before closing this assignment.",
|
||||
};
|
||||
}
|
||||
|
||||
const blockers = queue.flatMap((item) => {
|
||||
if (item.submitted_questions > 0 || item.review_status === "submitted") {
|
||||
return [`${item.student_name} still has submitted work waiting for review.`];
|
||||
}
|
||||
|
||||
if (item.in_progress_questions > 0 || item.review_status === "in_progress") {
|
||||
return [`${item.student_name} still has work in progress.`];
|
||||
}
|
||||
|
||||
if (item.answered_questions === 0 || item.review_status === "not_started") {
|
||||
return [`${item.student_name} has not started this assignment yet.`];
|
||||
}
|
||||
|
||||
if (!item.next_step_outcome) {
|
||||
return [`${item.student_name} still needs a next-step decision.`];
|
||||
}
|
||||
|
||||
return [] as string[];
|
||||
});
|
||||
|
||||
if (blockers.length > 0) {
|
||||
return {
|
||||
state: "blocked",
|
||||
canClose: false,
|
||||
blockers,
|
||||
summary: `${blockers.length} blocker${blockers.length === 1 ? "" : "s"} still need attention before this assignment can be closed.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "ready",
|
||||
canClose: true,
|
||||
blockers: [],
|
||||
summary: "All assigned students have been reviewed and given a next-step decision. This assignment is ready to close.",
|
||||
};
|
||||
};
|
||||
|
||||
export const questionStatus = (question: ApiAssignmentStudentQuestionDetail) => {
|
||||
if (question.review_needs_attention) {
|
||||
return { statusLabel: assignmentUiCopy.teacherReview.status.needsAttention, statusTone: "review" as const };
|
||||
}
|
||||
if (question.answer_status === "reviewed") {
|
||||
return { statusLabel: assignmentUiCopy.teacherReview.status.reviewed, statusTone: "success" as const };
|
||||
}
|
||||
if (question.answer_status === "submitted") {
|
||||
return { statusLabel: assignmentUiCopy.teacherReview.status.submitted, statusTone: "review" as const };
|
||||
}
|
||||
if (question.answer_status === "in_progress") {
|
||||
return { statusLabel: assignmentUiCopy.teacherReview.status.inProgress, statusTone: "progress" as const };
|
||||
}
|
||||
return { statusLabel: assignmentUiCopy.teacherReview.status.noAnswerYet, statusTone: "muted" as const };
|
||||
};
|
||||
|
||||
export const normalizeScore = (value: number | null | undefined) => (typeof value === "number" && Number.isFinite(value) ? value : null);
|
||||
|
||||
export const normalizeTags = (value: string[] | null | undefined) =>
|
||||
Array.isArray(value)
|
||||
? value.map((tag) => tag.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
export const buildQuestionTags = (question: ApiAssignmentStudentQuestionDetail) => {
|
||||
const tags = [...normalizeTags(question.question_tags), question.subject?.trim() || "", formatSolveMode(question.solve_mode) || ""].filter(Boolean);
|
||||
return Array.from(new Set(tags));
|
||||
};
|
||||
|
||||
export const formatTopicLabel = (value: string) =>
|
||||
value
|
||||
.split("_")
|
||||
.map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : part))
|
||||
.join(" ");
|
||||
|
||||
export const formatDifficultyLabel = (value: string) => (value ? value[0]!.toUpperCase() + value.slice(1).toLowerCase() : value);
|
||||
|
||||
export const mapTopicScores = (summary: ApiStudentWeaknessSummary) =>
|
||||
Object.entries(summary.topic_scores ?? {})
|
||||
.map(([topic, score]) => ({ topic: formatTopicLabel(topic), score }))
|
||||
.sort((left, right) => left.score - right.score || left.topic.localeCompare(right.topic));
|
||||
|
||||
export const mapRedoPlanQuestion = (item: ApiRedoPlanQuestion): TeacherRedoPlanQuestion => ({
|
||||
topic: formatTopicLabel(item.topic),
|
||||
topicKey: item.topic,
|
||||
difficulty: formatDifficultyLabel(item.difficulty),
|
||||
difficultyKey: item.difficulty,
|
||||
tags: Array.isArray(item.tags) ? item.tags.filter(Boolean) : [],
|
||||
reason: item.reason?.trim() || "No reason provided.",
|
||||
});
|
||||
|
||||
export const mapRedoPlan = (plan: ApiRedoPlan | null | undefined) => {
|
||||
if (!plan) return null;
|
||||
return {
|
||||
rationale: plan.rationale?.trim() || "No rationale provided.",
|
||||
questionSet: Array.isArray(plan.questionSet) ? plan.questionSet.map(mapRedoPlanQuestion) : [],
|
||||
};
|
||||
};
|
||||
|
||||
export const mapQueueReviewStatus = (value: ApiReviewQueueItem["next_step_outcome"]): TeacherNextStepOutcome | null => value ?? null;
|
||||
@@ -0,0 +1,824 @@
|
||||
.section,
|
||||
.sideCard {
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-3xl);
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 1.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction,
|
||||
.queueButton {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: var(--radius-pill);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
background: var(--surface-soft);
|
||||
color: var(--text);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.secondaryAction:hover {
|
||||
text-decoration: none;
|
||||
background: color-mix(in srgb, var(--surface-soft) 88%, white 12%);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
|
||||
color: var(--action-primary-text);
|
||||
box-shadow: var(--action-primary-shadow);
|
||||
}
|
||||
|
||||
.primaryAction:hover {
|
||||
filter: saturate(1.02);
|
||||
}
|
||||
|
||||
.primaryAction:disabled,
|
||||
.secondaryAction:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.sideEyebrow,
|
||||
.order,
|
||||
.feedbackBlock label,
|
||||
.responseBlock p,
|
||||
.supportBlock p {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.statusPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.42rem 0.68rem;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.review {
|
||||
background: var(--surface-warning-tint);
|
||||
color: var(--warning-text);
|
||||
border-color: color-mix(in srgb, var(--warning-text) 24%, transparent 76%);
|
||||
}
|
||||
|
||||
.progress {
|
||||
background: color-mix(in srgb, var(--surface-info) 20%, white 80%);
|
||||
color: var(--info);
|
||||
border-color: color-mix(in srgb, var(--info) 24%, transparent 76%);
|
||||
}
|
||||
|
||||
.success {
|
||||
background: var(--surface-success-tint);
|
||||
color: var(--success-text);
|
||||
border-color: var(--border-success-soft);
|
||||
}
|
||||
|
||||
.muted {
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.contentGrid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
|
||||
@include respond(desktop-lg) {
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(19rem, 0.85fr);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.contentGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sideColumn {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.mainColumn,
|
||||
.sideColumn {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sideColumn {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
|
||||
@include respond(desktop-lg) {
|
||||
position: sticky;
|
||||
top: 1.25rem;
|
||||
max-height: calc(100dvh - 2.5rem);
|
||||
overflow-y: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.queueCard {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mobileStickyNav {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.mobileStickyNavHeader {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mobileStickyNavActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mobileStickyNavToggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel-strong);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobileStickyNavBackToTop {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid color-mix(in srgb, var(--border-soft) 74%, white 26%);
|
||||
background: color-mix(in srgb, var(--surface-panel-strong) 82%, white 18%);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 18px hsl(220 35% 12% / 0.1);
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
background: color-mix(in srgb, var(--surface-panel-strong) 74%, white 26%);
|
||||
}
|
||||
}
|
||||
|
||||
.mobileStickyNavContent {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.mobileStickyNavContentExpanded {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.mobileQuestionNavList {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.mobileQuestionNavButton {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
text-align: left;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-divider);
|
||||
background: var(--surface-panel-strong);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.mobileQuestionNavAnswered {
|
||||
border-color: color-mix(in srgb, var(--border-soft) 62%, var(--info) 38%);
|
||||
background: color-mix(in srgb, var(--surface-soft) 88%, white 12%);
|
||||
}
|
||||
|
||||
.sectionHeader,
|
||||
.sideCard {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.questionList,
|
||||
.queueList,
|
||||
.noteList {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.sideActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.queueScroller {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.assignmentFeedbackCard {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel-strong);
|
||||
margin-bottom: 0.95rem;
|
||||
}
|
||||
|
||||
.saveAllCard {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel-strong);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.nextStepCard,
|
||||
.optionCard {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel-strong);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.planList,
|
||||
.scoreList {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.planCard {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel-strong);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scoreRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-soft);
|
||||
|
||||
span {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.questionCard,
|
||||
.queueButton {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel-strong);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.optionGrid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
|
||||
@include respond(tablet) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.optionCard {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
strong {
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--border-soft) 68%, var(--info) 32%);
|
||||
background: color-mix(in srgb, var(--surface-panel-strong) 86%, white 14%);
|
||||
}
|
||||
}
|
||||
|
||||
.optionCardActive {
|
||||
border-color: color-mix(in srgb, var(--border-soft) 54%, var(--info) 46%);
|
||||
background: color-mix(in srgb, var(--surface-info) 14%, white 86%);
|
||||
box-shadow: var(--focus-ring-info-shadow);
|
||||
}
|
||||
|
||||
.questionTop {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
|
||||
h3 {
|
||||
line-height: 1.25;
|
||||
}
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
|
||||
span {
|
||||
padding: 0.35rem 0.55rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-soft);
|
||||
color: color-mix(in srgb, var(--text-muted) 84%, var(--info) 16%);
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.responseBlock,
|
||||
.supportBlock,
|
||||
.feedbackBlock {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.reviewEditorCard {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: color-mix(in srgb, var(--surface-soft) 88%, white 12%);
|
||||
}
|
||||
|
||||
.reviewEditorTop {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
.reviewCheckbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
|
||||
input {
|
||||
inline-size: 1rem;
|
||||
block-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.reviewField {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.reviewScoreGrid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
|
||||
@include respond(tablet) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.scoreHighlight {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid color-mix(in srgb, var(--border-soft) 56%, var(--info) 44%);
|
||||
background: color-mix(in srgb, var(--surface-info) 12%, white 88%);
|
||||
|
||||
@include respond(tablet) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.scoreHighlightValue {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
min-height: 3rem;
|
||||
|
||||
strong,
|
||||
span {
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
.compactInput {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--border-soft) 56%, var(--info) 44%);
|
||||
box-shadow: var(--focus-ring-info-shadow);
|
||||
background: color-mix(in srgb, var(--surface-panel) 88%, white 12%);
|
||||
}
|
||||
}
|
||||
|
||||
.tagList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tagPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.reviewDraftEmpty {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--border-soft);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.responseBlock,
|
||||
.supportBlock {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.responseBlock strong,
|
||||
.supportBlock span,
|
||||
.supportBlock pre {
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.responseBlock strong {
|
||||
color: color-mix(in srgb, var(--text) 88%, var(--info) 12%);
|
||||
}
|
||||
|
||||
.supportBlock span,
|
||||
.supportBlock pre {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feedbackInput {
|
||||
width: 100%;
|
||||
min-height: 8rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.feedbackInput:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--border-soft) 56%, var(--info) 44%);
|
||||
box-shadow: var(--focus-ring-info-shadow);
|
||||
background: color-mix(in srgb, var(--surface-soft) 88%, white 12%);
|
||||
}
|
||||
|
||||
.draftHint {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 0.8rem 0.95rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.noticeSuccess {
|
||||
background: var(--surface-success-tint);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.noticeError {
|
||||
background: var(--surface-danger);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.queueButton {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
|
||||
strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--border-soft) 70%, var(--info) 30%);
|
||||
background: color-mix(in srgb, var(--surface-soft) 92%, white 8%);
|
||||
}
|
||||
}
|
||||
|
||||
.queueButtonActive {
|
||||
border-color: color-mix(in srgb, var(--border-soft) 60%, var(--info) 40%);
|
||||
background: color-mix(in srgb, var(--surface-soft) 86%, white 14%);
|
||||
}
|
||||
|
||||
.queueMeta {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
justify-items: start;
|
||||
|
||||
small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.queueEmpty,
|
||||
.emptyState,
|
||||
.progressNote {
|
||||
padding: 0.95rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
border: 1px dashed var(--border-soft);
|
||||
}
|
||||
|
||||
.closeAssignmentStatusRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.closeBlockerList {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding-left: 1rem;
|
||||
|
||||
li {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.noteList {
|
||||
padding-left: 1rem;
|
||||
|
||||
li {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.mobileStickyNav {
|
||||
position: sticky;
|
||||
top: 0.75rem;
|
||||
z-index: 25;
|
||||
pointer-events: auto;
|
||||
background: color-mix(in srgb, var(--surface-panel) 84%, white 16%);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid color-mix(in srgb, var(--border-soft) 78%, white 22%);
|
||||
border-radius: calc(var(--radius-3xl) + 0.25rem);
|
||||
box-shadow: 0 16px 36px hsl(220 35% 12% / 0.14);
|
||||
}
|
||||
|
||||
.mobileStickyNav {
|
||||
gap: 0.75rem;
|
||||
padding: 0.95rem;
|
||||
}
|
||||
|
||||
.mobileStickyNavHeader {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mobileStickyNavActions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mobileStickyNavToggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.mobileStickyNavContent {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileStickyNavContentExpanded {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.queueScroller,
|
||||
.mobileQuestionNavList {
|
||||
max-height: min(55dvh, 24rem);
|
||||
overflow-y: auto;
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
|
||||
.section,
|
||||
.sideCard,
|
||||
.questionCard,
|
||||
.queueButton,
|
||||
.assignmentFeedbackCard,
|
||||
.saveAllCard {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.questionTop,
|
||||
.reviewEditorTop {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
scroll-margin-top: 8.5rem;
|
||||
}
|
||||
|
||||
.sideActions {
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.sideActions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.queueMeta {
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@include respond(desktop-lg) {
|
||||
.mobileStickyNavToggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileStickyNavContent {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
// Path: Frontend/src/components/assignment/teacher/assignment-teacher-review.sections.tsx
|
||||
|
||||
import type { Component, JSX } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { assignmentUiCopy } from "~/content/ui-copy";
|
||||
import type { TeacherAssignmentReviewPageData, TeacherReviewDraftFields, TeacherReviewNotice, TeacherReviewQuestion } from "./assignment-teacher-review.types";
|
||||
import { getDashboardAssignmentsHref } from "../../../lib/routes";
|
||||
import styles from "./assignment-teacher-review.module.scss";
|
||||
|
||||
const toneClass = (tone: string) => {
|
||||
switch (tone) {
|
||||
case "review":
|
||||
return styles.review;
|
||||
case "progress":
|
||||
return styles.progress;
|
||||
case "success":
|
||||
return styles.success;
|
||||
default:
|
||||
return styles.muted;
|
||||
}
|
||||
};
|
||||
|
||||
type AssignmentFeedbackSectionProps = {
|
||||
data: TeacherAssignmentReviewPageData;
|
||||
teacherFeedbackDraft: string;
|
||||
hasPendingAssignmentFeedback: boolean;
|
||||
busy: boolean;
|
||||
notice: TeacherReviewNotice;
|
||||
draftActionLabel: string;
|
||||
actions?: JSX.Element;
|
||||
onTeacherFeedbackInput: (value: string) => void;
|
||||
};
|
||||
|
||||
export const AssignmentFeedbackSection: Component<AssignmentFeedbackSectionProps> = (props) => (
|
||||
<section class={styles.assignmentFeedbackCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>{assignmentUiCopy.teacherReview.feedback.title}</h3>
|
||||
<p>Keep AI feedback and teacher feedback in one place for the whole assignment.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.supportBlock}>
|
||||
<p>{assignmentUiCopy.teacherReview.feedback.aiFeedback}</p>
|
||||
<span>{props.data.assignmentAiFeedback || assignmentUiCopy.teacherReview.feedback.noAiFeedback}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.feedbackBlock}>
|
||||
<label for="assignment-feedback">{assignmentUiCopy.teacherReview.feedback.teacherFeedback}</label>
|
||||
<textarea
|
||||
id="assignment-feedback"
|
||||
class={styles.feedbackInput}
|
||||
value={props.teacherFeedbackDraft}
|
||||
onInput={(event) => props.onTeacherFeedbackInput(event.currentTarget.value)}
|
||||
disabled={props.busy}
|
||||
placeholder={assignmentUiCopy.teacherReview.feedback.teacherFeedbackPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={props.notice?.scope === "assignment"}>
|
||||
<div class={`${styles.notice} ${props.notice?.tone === "error" ? styles.noticeError : styles.noticeSuccess}`}>{props.notice?.text}</div>
|
||||
</Show>
|
||||
|
||||
<div class={styles.draftHint}>
|
||||
<Show when={props.hasPendingAssignmentFeedback} fallback={<span>{assignmentUiCopy.teacherReview.feedback.cleanDraft}</span>}>
|
||||
<span>{assignmentUiCopy.teacherReview.feedback.dirtyDraftPrefix} {props.draftActionLabel}.</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.actions}>{(actions) => <div class={styles.actionRow}>{actions()}</div>}</Show>
|
||||
</section>
|
||||
);
|
||||
|
||||
type QuestionCardProps = {
|
||||
cardId?: string;
|
||||
question: TeacherReviewQuestion;
|
||||
notice: TeacherReviewNotice;
|
||||
hasPendingDraft: boolean;
|
||||
savingAll: boolean;
|
||||
onResetDraft: (question: TeacherReviewQuestion) => void;
|
||||
onNeedsAttention: (question: TeacherReviewQuestion) => void;
|
||||
onReviewFieldChange: (question: TeacherReviewQuestion, patch: Partial<TeacherReviewDraftFields>) => void;
|
||||
};
|
||||
|
||||
export const TeacherReviewQuestionCard: Component<QuestionCardProps> = (props) => (
|
||||
<article id={props.cardId} class={styles.questionCard}>
|
||||
<div class={styles.questionTop}>
|
||||
<div>
|
||||
<p class={styles.order}>{assignmentUiCopy.teacherReview.question.questionPrefix} {props.question.order}</p>
|
||||
<h3>{props.question.prompt}</h3>
|
||||
</div>
|
||||
<span class={`${styles.statusPill} ${toneClass(props.question.statusTone)}`}>{props.question.statusLabel}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.metaRow}>
|
||||
<span>{props.question.submittedLabel}</span>
|
||||
<span>{props.question.reviewedLabel}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.responseBlock}>
|
||||
<p>{assignmentUiCopy.teacherReview.question.studentAnswer}</p>
|
||||
<strong>{props.question.answerText}</strong>
|
||||
</div>
|
||||
|
||||
<Show when={props.question.workingSteps || props.question.solveModeLabel}>
|
||||
<div class={styles.supportBlock}>
|
||||
<p>{assignmentUiCopy.teacherReview.question.studentSteps}</p>
|
||||
<Show when={props.question.solveModeLabel}>
|
||||
<span>{props.question.solveModeLabel}</span>
|
||||
</Show>
|
||||
<Show when={props.question.workingSteps} fallback={<span>{assignmentUiCopy.teacherReview.question.noSteps}</span>}>
|
||||
<pre>{props.question.workingSteps}</pre>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class={styles.supportBlock}>
|
||||
<p>{assignmentUiCopy.teacherReview.question.correctAnswer}</p>
|
||||
<Show when={props.question.correctAnswer} fallback={<span>{assignmentUiCopy.teacherReview.question.noCorrectAnswer}</span>}>
|
||||
<strong>{props.question.correctAnswer}</strong>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.question.answerId} fallback={<div class={styles.reviewDraftEmpty}>{assignmentUiCopy.teacherReview.question.structuredReviewLocked}</div>}>
|
||||
<div class={styles.reviewEditorCard}>
|
||||
<div class={styles.reviewEditorTop}>
|
||||
<h4>{assignmentUiCopy.teacherReview.question.structuredReview}</h4>
|
||||
<label class={styles.reviewCheckbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.question.reviewNeedsAttention}
|
||||
disabled={props.savingAll}
|
||||
onChange={(event) =>
|
||||
props.onReviewFieldChange(props.question, {
|
||||
reviewNeedsAttention: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{assignmentUiCopy.teacherReview.question.needsAttention}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<small>{assignmentUiCopy.teacherReview.question.weightingNote}</small>
|
||||
|
||||
<div class={styles.reviewField}>
|
||||
<label for={`issue-reason-${props.question.id}`}>{assignmentUiCopy.teacherReview.question.issueReason}</label>
|
||||
<textarea
|
||||
id={`issue-reason-${props.question.id}`}
|
||||
class={styles.feedbackInput}
|
||||
value={props.question.reviewIssueReason}
|
||||
disabled={props.savingAll}
|
||||
placeholder={assignmentUiCopy.teacherReview.question.issueReasonPlaceholder}
|
||||
onInput={(event) =>
|
||||
props.onReviewFieldChange(props.question, {
|
||||
reviewIssueReason: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles.reviewScoreGrid}>
|
||||
<div class={styles.reviewField}>
|
||||
<label for={`understanding-score-${props.question.id}`}>{assignmentUiCopy.teacherReview.question.understandingScore}</label>
|
||||
<input
|
||||
id={`understanding-score-${props.question.id}`}
|
||||
type="number"
|
||||
class={styles.compactInput}
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={props.question.reviewUnderstandingScore ?? ""}
|
||||
disabled={props.savingAll}
|
||||
onInput={(event) =>
|
||||
props.onReviewFieldChange(props.question, {
|
||||
reviewUnderstandingScore: event.currentTarget.value === "" ? null : Number(event.currentTarget.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={styles.reviewField}>
|
||||
<label for={`confidence-score-${props.question.id}`}>{assignmentUiCopy.teacherReview.question.confidence}</label>
|
||||
<input
|
||||
id={`confidence-score-${props.question.id}`}
|
||||
type="number"
|
||||
class={styles.compactInput}
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={props.question.reviewConfidence ?? ""}
|
||||
disabled={props.savingAll}
|
||||
onInput={(event) =>
|
||||
props.onReviewFieldChange(props.question, {
|
||||
reviewConfidence: event.currentTarget.value === "" ? null : Number(event.currentTarget.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.reviewField}>
|
||||
<label>{assignmentUiCopy.teacherReview.question.questionTags}</label>
|
||||
<Show
|
||||
when={props.question.questionTags.length > 0}
|
||||
fallback={<small>{assignmentUiCopy.teacherReview.question.noSavedTags}</small>}
|
||||
>
|
||||
<div class={styles.tagList}>
|
||||
<For each={props.question.questionTags}>{(tag) => <span class={styles.tagPill}>{tag}</span>}</For>
|
||||
</div>
|
||||
<small>{assignmentUiCopy.teacherReview.question.inheritedTags}</small>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.notice?.scope === "question" && props.notice?.questionId === props.question.id}>
|
||||
<div class={`${styles.notice} ${props.notice?.tone === "error" ? styles.noticeError : styles.noticeSuccess}`}>{props.notice?.text}</div>
|
||||
</Show>
|
||||
|
||||
<div class={styles.actionRow}>
|
||||
<Show when={props.hasPendingDraft}>
|
||||
<button type="button" class={styles.secondaryAction} disabled={!props.question.answerId || props.savingAll} onClick={() => props.onResetDraft(props.question)}>
|
||||
{assignmentUiCopy.teacherReview.question.resetDraft}
|
||||
</button>
|
||||
</Show>
|
||||
<button type="button" class={styles.primaryAction} disabled={!props.question.answerId || props.savingAll} onClick={() => props.onNeedsAttention(props.question)}>
|
||||
{props.hasPendingDraft ? assignmentUiCopy.teacherReview.question.needsAttentionInDraft : assignmentUiCopy.teacherReview.question.needsAttention}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
type TeacherReviewSaveProgressCardProps = {
|
||||
canMoveToNextStep: boolean;
|
||||
pendingChangeCount: number;
|
||||
savingAll: boolean;
|
||||
closeState: TeacherAssignmentReviewPageData["closeSummary"]["state"];
|
||||
notice: TeacherReviewNotice;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export const TeacherReviewSaveProgressCard: Component<TeacherReviewSaveProgressCardProps> = (props) => (
|
||||
<section class={styles.saveAllCard}>
|
||||
<div class={styles.sectionHeader}>
|
||||
<h3>{assignmentUiCopy.teacherReview.saveProgress.title}</h3>
|
||||
<p>
|
||||
<Show
|
||||
when={props.canMoveToNextStep}
|
||||
fallback={
|
||||
props.closeState === "closed"
|
||||
? assignmentUiCopy.teacherReview.saveProgress.closedFallback
|
||||
: assignmentUiCopy.teacherReview.saveProgress.lockedFallback
|
||||
}
|
||||
>
|
||||
{props.pendingChangeCount} question{props.pendingChangeCount === 1 ? " is" : "s are"} flagged for attention. Everything else will be marked reviewed when you continue.
|
||||
</Show>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={props.notice?.scope === "global"}>
|
||||
<div class={`${styles.notice} ${props.notice?.tone === "error" ? styles.noticeError : styles.noticeSuccess}`}>{props.notice?.text}</div>
|
||||
</Show>
|
||||
|
||||
<div class={styles.actionRow}>
|
||||
<button type="button" class={styles.primaryAction} disabled={props.savingAll || !props.canMoveToNextStep || props.closeState === "closed"} onClick={props.onSave}>
|
||||
{props.savingAll ? assignmentUiCopy.teacherReview.saveProgress.saving : assignmentUiCopy.teacherReview.saveProgress.saveAndNextStep}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
type TeacherReviewSidebarProps = {
|
||||
data: TeacherAssignmentReviewPageData;
|
||||
hasQueue: boolean;
|
||||
closingAssignment: boolean;
|
||||
closeNotice: TeacherReviewNotice;
|
||||
onSelectStudent: (studentId: number) => void;
|
||||
onCloseAssignment: () => void;
|
||||
};
|
||||
|
||||
export const TeacherReviewSidebar: Component<TeacherReviewSidebarProps> = (props) => (
|
||||
(() => {
|
||||
const [isQueueExpanded, setIsQueueExpanded] = createSignal(false);
|
||||
|
||||
return (
|
||||
<aside class={styles.sideColumn}>
|
||||
<section class={`${styles.sideCard} ${styles.queueCard} ${styles.mobileStickyNav}`}>
|
||||
<div class={styles.mobileStickyNavHeader}>
|
||||
<div>
|
||||
<p class={styles.sideEyebrow}>{assignmentUiCopy.teacherReview.sidebar.queueEyebrow}</p>
|
||||
<h2>{assignmentUiCopy.teacherReview.sidebar.studentsToReview}</h2>
|
||||
<p>Select a student to switch the question review panel.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={styles.mobileStickyNavToggle}
|
||||
aria-expanded={isQueueExpanded()}
|
||||
onClick={() => setIsQueueExpanded((value) => !value)}
|
||||
>
|
||||
{isQueueExpanded() ? "Hide queue" : "Open queue"}
|
||||
</button>
|
||||
</div>
|
||||
<div class={styles.sideActions}>
|
||||
<A href={getDashboardAssignmentsHref("teacher")} class={styles.secondaryAction}>
|
||||
{assignmentUiCopy.teacherReview.sidebar.backToDashboard}
|
||||
</A>
|
||||
</div>
|
||||
<div classList={{ [styles.mobileStickyNavContent]: true, [styles.mobileStickyNavContentExpanded]: isQueueExpanded() }}>
|
||||
<div class={styles.queueScroller}>
|
||||
<div class={styles.queueList}>
|
||||
<Show when={props.hasQueue} fallback={<div class={styles.queueEmpty}>{assignmentUiCopy.teacherReview.sidebar.queueEmpty}</div>}>
|
||||
<For each={props.data.reviewQueue}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.queueButton} ${props.data.selectedStudentId === item.studentId ? styles.queueButtonActive : ""}`.trim()}
|
||||
onClick={() => {
|
||||
props.onSelectStudent(item.studentId);
|
||||
setIsQueueExpanded(false);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{item.studentName}</strong>
|
||||
<p>{item.progressLabel}</p>
|
||||
</div>
|
||||
<div class={styles.queueMeta}>
|
||||
<span class={`${styles.statusPill} ${toneClass(item.statusTone)}`}>{item.statusLabel}</span>
|
||||
<small>{item.timestampLabel}</small>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.sideCard}>
|
||||
<p class={styles.sideEyebrow}>{assignmentUiCopy.teacherReview.sidebar.statusEyebrow}</p>
|
||||
<h2>{assignmentUiCopy.teacherReview.sidebar.closeAssignment}</h2>
|
||||
<p>{props.data.closeSummary.summary}</p>
|
||||
<div class={styles.closeAssignmentStatusRow}>
|
||||
<span
|
||||
class={`${styles.statusPill} ${
|
||||
props.data.closeSummary.state === "closed"
|
||||
? styles.muted
|
||||
: props.data.closeSummary.state === "ready"
|
||||
? styles.success
|
||||
: styles.review
|
||||
}`}
|
||||
>
|
||||
{props.data.closeSummary.state === "closed"
|
||||
? assignmentUiCopy.teacherReview.sidebar.closed
|
||||
: props.data.closeSummary.state === "ready"
|
||||
? assignmentUiCopy.teacherReview.sidebar.readyToClose
|
||||
: assignmentUiCopy.teacherReview.sidebar.blocked}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={props.data.closeSummary.blockers.length > 0}>
|
||||
<ul class={styles.closeBlockerList}>
|
||||
<For each={props.data.closeSummary.blockers}>{(blocker) => <li>{blocker}</li>}</For>
|
||||
</ul>
|
||||
</Show>
|
||||
<Show when={props.closeNotice?.scope === "assignment"}>
|
||||
<div class={`${styles.notice} ${props.closeNotice?.tone === "error" ? styles.noticeError : styles.noticeSuccess}`}>{props.closeNotice?.text}</div>
|
||||
</Show>
|
||||
<div class={styles.sideActions}>
|
||||
<button type="button" class={styles.primaryAction} disabled={!props.data.closeSummary.canClose || props.closingAssignment} onClick={props.onCloseAssignment}>
|
||||
{props.closingAssignment ? assignmentUiCopy.teacherReview.sidebar.closing : props.data.closeSummary.state === "closed" ? assignmentUiCopy.teacherReview.sidebar.assignmentClosed : assignmentUiCopy.teacherReview.sidebar.closeAssignment}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.sideCard}>
|
||||
<p class={styles.sideEyebrow}>{assignmentUiCopy.teacherReview.sidebar.overviewEyebrow}</p>
|
||||
<h2>{props.data.selectedStudentName ?? assignmentUiCopy.teacherReview.sidebar.reviewSummary}</h2>
|
||||
<p>{props.data.selectedStudentEmail ?? props.data.coachCard.description}</p>
|
||||
<Show when={props.data.selectedStudentProgress}>
|
||||
<div class={styles.progressNote}>{props.data.selectedStudentProgress}</div>
|
||||
</Show>
|
||||
<ul class={styles.noteList}>
|
||||
<For each={props.data.coachCard.items}>{(item) => <li>{item}</li>}</For>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
})()
|
||||
);
|
||||
@@ -0,0 +1,354 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { ApiReviewQueueItem } from "../../../lib/api-types";
|
||||
|
||||
export type TeacherReviewQueueItem = {
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
email: string;
|
||||
reviewStatus: ApiReviewQueueItem["review_status"];
|
||||
nextStepOutcome: TeacherNextStepOutcome | null;
|
||||
submittedQuestions: number;
|
||||
reviewedQuestions: number;
|
||||
progressLabel: string;
|
||||
statusLabel: string;
|
||||
statusTone: "review" | "progress" | "muted" | "success";
|
||||
timestampLabel: string;
|
||||
};
|
||||
|
||||
export type TeacherAssignmentCloseState = "ready" | "blocked" | "closed";
|
||||
|
||||
export type TeacherAssignmentCloseSummary = {
|
||||
state: TeacherAssignmentCloseState;
|
||||
canClose: boolean;
|
||||
blockers: string[];
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type TeacherAssignmentPassStatus = "pending" | "pass" | "no_pass";
|
||||
export type TeacherNextStepOutcome = "redo" | "accept" | "support";
|
||||
|
||||
export type TeacherReviewDraftFields = {
|
||||
reviewNeedsAttention: boolean;
|
||||
reviewIssueReason: string;
|
||||
reviewCorrectnessScore: number | null;
|
||||
reviewUnderstandingScore: number | null;
|
||||
reviewQuestionScore: number | null;
|
||||
reviewConfidence: number | null;
|
||||
};
|
||||
|
||||
export type TeacherReviewQuestion = TeacherReviewDraftFields & {
|
||||
id: number;
|
||||
order: number;
|
||||
prompt: string;
|
||||
subject: string;
|
||||
source: string | null;
|
||||
questionTags: string[];
|
||||
answerId: number | null;
|
||||
answerStatus: "not_started" | "in_progress" | "submitted" | "reviewed" | null;
|
||||
statusLabel: string;
|
||||
statusTone: "review" | "progress" | "muted" | "success";
|
||||
answerText: string;
|
||||
solveModeLabel: string | null;
|
||||
workingSteps: string;
|
||||
correctAnswer: string | null;
|
||||
isCorrect: boolean | null;
|
||||
correctnessLabel: string;
|
||||
submittedLabel: string;
|
||||
reviewedLabel: string;
|
||||
};
|
||||
|
||||
export type TeacherAssignmentReviewPageData = {
|
||||
assignmentId: number;
|
||||
title: string;
|
||||
classroomId: number;
|
||||
classroomName: string;
|
||||
statusLabel: string;
|
||||
headline: string;
|
||||
description: string;
|
||||
dueLabel: string;
|
||||
stats: Array<{ label: string; value: string }>;
|
||||
closeSummary: TeacherAssignmentCloseSummary;
|
||||
reviewQueue: TeacherReviewQueueItem[];
|
||||
selectedStudentId: number | null;
|
||||
selectedStudentName: string | null;
|
||||
selectedStudentEmail: string | null;
|
||||
selectedStudentProgress: string | null;
|
||||
selectedStudentReviewStatus: ApiReviewQueueItem["review_status"] | null;
|
||||
selectedStudentSubmittedQuestions: number;
|
||||
selectedStudentReviewedQuestions: number;
|
||||
assignmentAiFeedback: string;
|
||||
assignmentTeacherFeedback: string;
|
||||
overallScore: number | null;
|
||||
passThreshold: number;
|
||||
nextStepOutcome: TeacherNextStepOutcome | null;
|
||||
passStatusOverride: TeacherAssignmentPassStatus | null;
|
||||
passStatus: TeacherAssignmentPassStatus;
|
||||
coachCard: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
};
|
||||
questions: TeacherReviewQuestion[];
|
||||
};
|
||||
|
||||
export type TeacherRedoPlanQuestion = {
|
||||
topic: string;
|
||||
topicKey: string;
|
||||
difficulty: string;
|
||||
difficultyKey: "easy" | "medium" | "hard";
|
||||
tags: string[];
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type TeacherRedoPlanData = {
|
||||
assignmentId: number;
|
||||
title: string;
|
||||
classroomId: number;
|
||||
classroomName: string;
|
||||
selectedStudentId: number;
|
||||
selectedStudentName: string;
|
||||
selectedStudentEmail: string;
|
||||
teacherFeedback: string;
|
||||
generatedAtLabel: string | null;
|
||||
weaknessSummary: {
|
||||
studentId: number;
|
||||
topicScores: Array<{ topic: string; score: number }>;
|
||||
weakTags: string[];
|
||||
recentIssues: string[];
|
||||
};
|
||||
plan: {
|
||||
rationale: string;
|
||||
questionSet: TeacherRedoPlanQuestion[];
|
||||
} | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type TeacherReviewNotice = {
|
||||
scope: "assignment" | "question" | "global";
|
||||
questionId?: number;
|
||||
tone: "success" | "error";
|
||||
text: string;
|
||||
} | null;
|
||||
215
Frontend/src/components/assignment/work/assignment-work.data.ts
Normal file
215
Frontend/src/components/assignment/work/assignment-work.data.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
// Path: Frontend/src/components/assignment/work/assignment-work.data.ts
|
||||
|
||||
import { apiFetchJson } from "../../../lib/api";
|
||||
import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiClassroom, ApiListResponse, ApiUser } from "../../../lib/api-types";
|
||||
import { getAssignmentReviewHref } from "../../../lib/routes";
|
||||
import type { AssignmentPageData } from "../shared/assignment-types";
|
||||
|
||||
export type AssignmentWorkQuestion = AssignmentPageData["questions"][number] & {
|
||||
answerId: number | null;
|
||||
answerStatus: "not_started" | "in_progress" | "submitted" | "reviewed";
|
||||
submittedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
workingSteps: string;
|
||||
correctAnswer: string | null;
|
||||
isCorrect: boolean | null;
|
||||
};
|
||||
|
||||
export type AssignmentWorkPageData = Omit<AssignmentPageData, "questions" | "description" | "headline" | "primaryAction"> & {
|
||||
headline: string;
|
||||
description: string;
|
||||
primaryAction: string;
|
||||
questions: AssignmentWorkQuestion[];
|
||||
};
|
||||
|
||||
type UpsertAssignmentAnswerInput = {
|
||||
assignmentId: number;
|
||||
questionId: number;
|
||||
answerText: string | null;
|
||||
solveMode: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
|
||||
workingSteps: string | null;
|
||||
status: "not_started" | "in_progress" | "submitted" | "reviewed";
|
||||
submittedAt?: string | null;
|
||||
};
|
||||
|
||||
const formatDateLabel = (value: string | null) => {
|
||||
if (!value) return "No due date";
|
||||
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
|
||||
const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/i)?.[1]?.trim();
|
||||
if (fromInstructions) return fromInstructions;
|
||||
|
||||
return questions[0]?.subject ?? "Assignment";
|
||||
};
|
||||
|
||||
const deriveStudentStatus = (questions: ApiAssignmentStudentQuestionDetail[], assignmentStatus: ApiAssignment["status"]) => {
|
||||
const total = questions.length;
|
||||
const answered = questions.filter((question) => question.answer_id).length;
|
||||
const submitted = questions.filter((question) => question.answer_status === "submitted" || question.answer_status === "reviewed").length;
|
||||
|
||||
if (answered === 0) return "NOT_STARTED" as const;
|
||||
if (submitted === total || assignmentStatus === "closed") return "SUBMITTED" as const;
|
||||
return "IN_PROGRESS" as const;
|
||||
};
|
||||
|
||||
const deriveStatusLabel = (status: AssignmentPageData["status"]) => {
|
||||
switch (status) {
|
||||
case "SUBMITTED":
|
||||
return "Submitted";
|
||||
case "IN_PROGRESS":
|
||||
return "In progress";
|
||||
default:
|
||||
return "Not started";
|
||||
}
|
||||
};
|
||||
|
||||
const mapQuestionStatus = (question: ApiAssignmentStudentQuestionDetail) => {
|
||||
if (question.answer_status === "reviewed") {
|
||||
return {
|
||||
statusLabel: "Reviewed",
|
||||
statusTone: "success" as const,
|
||||
answerStatus: "reviewed" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (question.answer_status === "submitted") {
|
||||
return {
|
||||
statusLabel: "Submitted",
|
||||
statusTone: "warning" as const,
|
||||
answerStatus: "submitted" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (question.answer_status === "in_progress") {
|
||||
return {
|
||||
statusLabel: "Draft saved",
|
||||
statusTone: "warning" as const,
|
||||
answerStatus: "in_progress" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusLabel: "Not started",
|
||||
statusTone: "muted" as const,
|
||||
answerStatus: "not_started" as const,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAssignmentWorkPageData = async (assignmentId: number, studentId: number): Promise<AssignmentWorkPageData | null> => {
|
||||
if (!Number.isFinite(assignmentId) || assignmentId <= 0) return null;
|
||||
|
||||
try {
|
||||
const assignment = await apiFetchJson<ApiAssignment>(`/api/assignments/${assignmentId}`);
|
||||
|
||||
const [student, teacher, classrooms, questionDetails] = await Promise.all([
|
||||
apiFetchJson<ApiUser>(`/api/users/${studentId}`),
|
||||
apiFetchJson<ApiUser>(`/api/users/${assignment.teacher_id}`),
|
||||
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${assignment.teacher_id}/classrooms`),
|
||||
apiFetchJson<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/api/assignments/${assignmentId}/students/${studentId}/questions`),
|
||||
]);
|
||||
|
||||
const classroom = classrooms.data.find((entry) => entry.id === assignment.classroom_id);
|
||||
const questions = questionDetails.data;
|
||||
const topic = extractTopic(assignment, questions);
|
||||
const answeredCount = questions.filter((question) => question.answer_id).length;
|
||||
const submittedCount = questions.filter((question) => question.answer_status === "submitted" || question.answer_status === "reviewed").length;
|
||||
const status = deriveStudentStatus(questions, assignment.status);
|
||||
const statusLabel = deriveStatusLabel(status);
|
||||
|
||||
return {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
topic,
|
||||
status,
|
||||
statusLabel,
|
||||
dueLabel: formatDateLabel(assignment.due_at),
|
||||
studentName: student.full_name,
|
||||
classroomName: classroom?.name ?? "Classroom",
|
||||
tutorName: teacher.full_name,
|
||||
headline: `Work through ${assignment.title}`,
|
||||
description: `Write your final answer here, keep your working notes in sync, and save each question back to the live assignment as you go.`,
|
||||
primaryAction: status === "SUBMITTED" ? "Review submission" : "Keep working",
|
||||
primaryHref: getAssignmentReviewHref("student", assignment.id),
|
||||
stats: [
|
||||
{ label: "Status", value: statusLabel },
|
||||
{ label: "Due", value: formatDateLabel(assignment.due_at) },
|
||||
{ label: "Questions", value: `${questions.length}` },
|
||||
{ label: "Saved / submitted", value: `${submittedCount}/${questions.length}` },
|
||||
],
|
||||
coachCard: {
|
||||
title: "Workspace notes",
|
||||
description: `Your final answer, solve mode, and working notes all sync to the backend when you save.`,
|
||||
items: [`${answeredCount} questions have an answer saved`, `${submittedCount} questions are submitted`, `${Math.max(questions.length - answeredCount, 0)} questions still untouched`],
|
||||
},
|
||||
questions: questions.map((question) => {
|
||||
const questionStatus = mapQuestionStatus(question);
|
||||
|
||||
return {
|
||||
id: question.question_id,
|
||||
order: question.position,
|
||||
prompt: question.prompt,
|
||||
topic: question.subject,
|
||||
subTopic: question.source,
|
||||
difficulty: "Backend",
|
||||
marks: null,
|
||||
statusLabel: questionStatus.statusLabel,
|
||||
statusTone: questionStatus.statusTone,
|
||||
responseLabel: question.answer_id ? "Your saved answer" : "Status",
|
||||
responseValue: question.answer_text?.trim() || "No saved answer yet",
|
||||
feedback: question.teacher_feedback?.trim() || question.ai_feedback?.trim() || "Save a draft to keep your latest workspace on the assignment.",
|
||||
solveModeLabel: undefined,
|
||||
initialAnswer: question.answer_text?.trim() || "",
|
||||
initialSolveMode: question.solve_mode ?? "just_answer",
|
||||
showAnswerKey: Boolean(question.correct_answer && question.answer_id),
|
||||
correctAnswer: question.correct_answer?.trim() || null,
|
||||
isCorrect: typeof question.is_correct === "boolean" ? question.is_correct : null,
|
||||
answerId: question.answer_id ?? null,
|
||||
answerStatus: questionStatus.answerStatus,
|
||||
submittedAt: question.submitted_at ?? null,
|
||||
updatedAt: question.answer_updated_at ?? null,
|
||||
workingSteps: question.working_steps ?? "",
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "not_found") {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertAssignmentAnswer = async (input: UpsertAssignmentAnswerInput) => {
|
||||
return apiFetchJson<{
|
||||
id: number;
|
||||
assignment_id: number;
|
||||
question_id: number;
|
||||
student_id: number;
|
||||
answer_text?: string;
|
||||
solve_mode: string;
|
||||
working_steps?: string;
|
||||
status: string;
|
||||
submitted_at?: string;
|
||||
updated_at?: string;
|
||||
}>("/api/answers", {
|
||||
method: "POST",
|
||||
parseErrorMessage: true,
|
||||
body: JSON.stringify({
|
||||
assignment_id: input.assignmentId,
|
||||
question_id: input.questionId,
|
||||
answer_text: input.answerText,
|
||||
solve_mode: input.solveMode,
|
||||
working_steps: input.workingSteps,
|
||||
status: input.status,
|
||||
submitted_at: input.submittedAt ?? null,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
/* Path: Frontend/src/components/assignment/work/assignment-work.module.scss */
|
||||
|
||||
.page {
|
||||
min-height: 100dvh;
|
||||
padding: 1.25rem;
|
||||
@@ -20,7 +22,7 @@
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.1rem 1.25rem;
|
||||
border-radius: 1.35rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-success);
|
||||
border: 1px solid color-mix(in srgb, var(--success) 28%, transparent);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -56,7 +58,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
color: var(--text);
|
||||
@@ -68,13 +70,21 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.4rem;
|
||||
border-radius: 1.5rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end));
|
||||
border: 1px solid var(--border-overlay);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.headerCard {
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
}
|
||||
}
|
||||
|
||||
.headerTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -82,13 +92,21 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.headerTop {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.backLink,
|
||||
.statusPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-overlay-soft);
|
||||
border: 1px solid var(--border-overlay);
|
||||
color: var(--text-on-accent);
|
||||
@@ -111,6 +129,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.headerCopy {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.headerCopy h1 {
|
||||
font-size: clamp(1.45rem, 1.1rem + 1.4vw, 1.9rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.headerCopy > p:not(.eyebrow) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--text-on-accent-muted);
|
||||
}
|
||||
@@ -123,12 +156,43 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.headerMeta {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.submitMeta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.45rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
justify-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.saveState {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-on-accent-muted);
|
||||
}
|
||||
|
||||
.submitHint {
|
||||
max-width: 28rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-on-accent-muted);
|
||||
text-align: right;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.submitButton,
|
||||
.primaryButton,
|
||||
.secondaryButton,
|
||||
@@ -137,7 +201,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
@@ -161,11 +225,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
.submitButton:disabled,
|
||||
.primaryButton:disabled,
|
||||
.secondaryButton:disabled,
|
||||
.questionButton:disabled {
|
||||
cursor: wait;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.submitButton:disabled,
|
||||
.primaryButton:disabled {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--action-primary-start) 82%, white), color-mix(in srgb, var(--action-primary-end) 82%, white));
|
||||
}
|
||||
|
||||
.secondaryButton:disabled,
|
||||
.questionButton:disabled {
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.contentGrid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
|
||||
@media (min-width: 1080px) {
|
||||
@include respond(workspace) {
|
||||
grid-template-columns: minmax(15rem, 18rem) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
@@ -177,14 +261,18 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.2rem;
|
||||
border-radius: 1.35rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.navigator {
|
||||
@media (min-width: 1080px) {
|
||||
position: sticky;
|
||||
top: 0.75rem;
|
||||
z-index: 25;
|
||||
|
||||
@include respond(workspace) {
|
||||
position: sticky;
|
||||
top: 1.25rem;
|
||||
}
|
||||
@@ -207,13 +295,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
.navigatorHeader {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.navigatorToggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel-strong);
|
||||
color: var(--text);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navigatorContent {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.navigatorContentExpanded {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.progressMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--border-soft);
|
||||
font-size: 0.85rem;
|
||||
@@ -225,14 +342,69 @@
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.navigator {
|
||||
gap: 0.75rem;
|
||||
padding: 0.95rem;
|
||||
}
|
||||
|
||||
.navigatorToggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.navigatorContent {
|
||||
display: none;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.navigatorContentExpanded {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.progressMeta {
|
||||
padding: 0.7rem 0.8rem;
|
||||
}
|
||||
|
||||
.questionButtons {
|
||||
max-height: min(55dvh, 24rem);
|
||||
overflow-y: auto;
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
|
||||
.workspace,
|
||||
.emptyState {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.workspaceHeader {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.workspaceHeader h2 {
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.28;
|
||||
}
|
||||
}
|
||||
|
||||
@include respond(workspace) {
|
||||
.navigatorToggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navigatorContent {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
.questionButton {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
text-align: left;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-divider);
|
||||
background: var(--surface-panel-strong);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
@@ -269,7 +441,7 @@
|
||||
|
||||
span {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-panel-strong);
|
||||
border: 1px solid var(--border-divider);
|
||||
font-size: 0.82rem;
|
||||
@@ -298,7 +470,7 @@
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-field);
|
||||
background: var(--surface-field);
|
||||
outline: none;
|
||||
@@ -306,7 +478,7 @@
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 4px var(--focus-ring-primary);
|
||||
box-shadow: var(--focus-ring-primary-shadow);
|
||||
background: var(--surface-field-focus);
|
||||
}
|
||||
}
|
||||
@@ -326,7 +498,7 @@
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--border-soft);
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
// Path: Frontend/src/components/assignment/work/student-assignment-work.page.tsx
|
||||
|
||||
import { A, useParams } from "@solidjs/router";
|
||||
import type { Component } from "solid-js";
|
||||
import { Show, createEffect, createMemo, createResource, createSignal } from "solid-js";
|
||||
import { useAuth } from "~/context/auth/context";
|
||||
import { getAssignmentReviewHref, getDashboardHomeHref } from "../../../lib/routes";
|
||||
import AssignmentTabs from "../shared/assignment-tabs";
|
||||
import { getAssignmentWorkPageData, upsertAssignmentAnswer } from "./assignment-work.data";
|
||||
import styles from "./assignment-work.module.scss";
|
||||
import { AssignmentWorkBanner, AssignmentWorkHeader, AssignmentWorkNavigator, AssignmentWorkWorkspace, type DraftState } from "./student-assignment-work.sections";
|
||||
|
||||
const StudentAssignmentWorkPage: Component = () => {
|
||||
const params = useParams();
|
||||
const auth = useAuth();
|
||||
const assignmentId = createMemo(() => {
|
||||
const parsed = Number(params.id);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
});
|
||||
const studentSource = createMemo(() => {
|
||||
const id = assignmentId();
|
||||
const user = auth.user();
|
||||
if (!auth.isReady() || !id || !user || user.role !== "student") return null;
|
||||
return { assignmentId: id, studentId: user.id };
|
||||
});
|
||||
const [assignmentData, { refetch }] = createResource(studentSource, ({ assignmentId, studentId }) => getAssignmentWorkPageData(assignmentId, studentId));
|
||||
|
||||
const [drafts, setDrafts] = createSignal<DraftState>({});
|
||||
const [activeQuestionId, setActiveQuestionId] = createSignal<number | null>(null);
|
||||
const [hydratedAssignmentId, setHydratedAssignmentId] = createSignal<number | null>(null);
|
||||
const [saveState, setSaveState] = createSignal<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [submitState, setSubmitState] = createSignal<"idle" | "submitting" | "error">("idle");
|
||||
const [lastSavedAt, setLastSavedAt] = createSignal<string | null>(null);
|
||||
const [isSubmitted, setIsSubmitted] = createSignal(false);
|
||||
const [submittedAt, setSubmittedAt] = createSignal<string | null>(null);
|
||||
|
||||
createEffect(() => {
|
||||
const data = assignmentData();
|
||||
if (!data || hydratedAssignmentId() === data.id) return;
|
||||
|
||||
setDrafts(
|
||||
Object.fromEntries(
|
||||
data.questions.map((question) => [
|
||||
question.id,
|
||||
{
|
||||
answer: question.initialAnswer ?? "",
|
||||
steps: question.workingSteps ?? "",
|
||||
solveMode: question.initialSolveMode ?? "just_answer",
|
||||
},
|
||||
]),
|
||||
) as DraftState,
|
||||
);
|
||||
setActiveQuestionId(data.questions[0]?.id ?? null);
|
||||
setHydratedAssignmentId(data.id);
|
||||
setIsSubmitted(data.status === "SUBMITTED");
|
||||
setSubmittedAt(null);
|
||||
setLastSavedAt(null);
|
||||
setSaveState("idle");
|
||||
setSubmitState("idle");
|
||||
});
|
||||
|
||||
const questions = createMemo(() => assignmentData()?.questions ?? []);
|
||||
const currentQuestion = createMemo(() => questions().find((question) => question.id === activeQuestionId()) ?? questions()[0]);
|
||||
const activeDraft = createMemo(() => {
|
||||
const question = currentQuestion();
|
||||
return question ? drafts()[question.id] : undefined;
|
||||
});
|
||||
const answeredCount = createMemo(() => Object.values(drafts()).filter((draft) => draft.answer.trim().length > 0).length);
|
||||
const remainingCount = createMemo(() => Math.max(questions().length - answeredCount(), 0));
|
||||
const isLastQuestion = createMemo(() => {
|
||||
const current = currentQuestion();
|
||||
if (!current) return false;
|
||||
return questions()[questions().length - 1]?.id === current.id;
|
||||
});
|
||||
const saveLabel = createMemo(() => {
|
||||
if (submitState() === "submitting") return "Submitting assignment and preparing your AI draft…";
|
||||
if (submitState() === "error") return "We could not submit the assignment.";
|
||||
if (saveState() === "saving") return "Saving to the backend…";
|
||||
if (saveState() === "error") return "We could not save that answer.";
|
||||
if (saveState() === "saved" && lastSavedAt()) {
|
||||
return `Saved ${new Intl.DateTimeFormat("en-GB", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(lastSavedAt()!))}`;
|
||||
}
|
||||
|
||||
return "Draft synced with the backend when you save";
|
||||
});
|
||||
|
||||
const updateDraft = (field: "answer" | "steps" | "solveMode", value: string) => {
|
||||
if (submitState() === "submitting") return;
|
||||
const question = currentQuestion();
|
||||
if (!question) return;
|
||||
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[question.id]: {
|
||||
...current[question.id],
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
setIsSubmitted(false);
|
||||
};
|
||||
|
||||
const saveQuestion = async (statusOverride?: "in_progress" | "submitted") => {
|
||||
if (submitState() === "submitting") return;
|
||||
const data = assignmentData();
|
||||
const question = currentQuestion();
|
||||
const draft = activeDraft();
|
||||
if (!data || !question || !draft) return;
|
||||
|
||||
const trimmedAnswer = draft.answer.trim();
|
||||
const trimmedSteps = draft.steps.trim();
|
||||
const nextStatus = statusOverride ?? (trimmedAnswer || trimmedSteps ? (question.answerStatus === "submitted" || question.answerStatus === "reviewed" ? "submitted" : "in_progress") : "not_started");
|
||||
|
||||
setSaveState("saving");
|
||||
|
||||
try {
|
||||
await upsertAssignmentAnswer({
|
||||
assignmentId: data.id,
|
||||
questionId: question.id,
|
||||
answerText: trimmedAnswer || null,
|
||||
workingSteps: trimmedSteps || null,
|
||||
solveMode: draft.solveMode,
|
||||
status: nextStatus,
|
||||
submittedAt: nextStatus === "submitted" ? new Date().toISOString() : null,
|
||||
});
|
||||
setLastSavedAt(new Date().toISOString());
|
||||
setSaveState("saved");
|
||||
await refetch();
|
||||
} catch {
|
||||
setSaveState("error");
|
||||
}
|
||||
};
|
||||
|
||||
const moveQuestion = (direction: 1 | -1) => {
|
||||
const current = currentQuestion();
|
||||
if (!current) return;
|
||||
const index = questions().findIndex((question) => question.id === current.id);
|
||||
const target = questions()[index + direction];
|
||||
if (target) setActiveQuestionId(target.id);
|
||||
};
|
||||
|
||||
const handleSaveAndContinue = async () => {
|
||||
if (submitState() === "submitting") return;
|
||||
await saveQuestion();
|
||||
if (!isLastQuestion()) moveQuestion(1);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const data = assignmentData();
|
||||
if (!data || submitState() === "submitting") return;
|
||||
|
||||
setSubmitState("submitting");
|
||||
setSaveState("saving");
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
data.questions.map(async (question) => {
|
||||
const draft = drafts()[question.id] ?? {
|
||||
answer: question.initialAnswer ?? "",
|
||||
steps: question.workingSteps ?? "",
|
||||
solveMode: question.initialSolveMode ?? "just_answer",
|
||||
};
|
||||
const trimmedAnswer = draft.answer.trim();
|
||||
const trimmedSteps = draft.steps.trim();
|
||||
|
||||
await upsertAssignmentAnswer({
|
||||
assignmentId: data.id,
|
||||
questionId: question.id,
|
||||
answerText: trimmedAnswer || null,
|
||||
workingSteps: trimmedSteps || null,
|
||||
solveMode: draft.solveMode,
|
||||
status: "submitted",
|
||||
submittedAt: now,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
setIsSubmitted(true);
|
||||
setSubmittedAt(now);
|
||||
setLastSavedAt(now);
|
||||
setSubmitState("idle");
|
||||
setSaveState("saved");
|
||||
await refetch();
|
||||
} catch {
|
||||
setSubmitState("error");
|
||||
setSaveState("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main class={styles.page}>
|
||||
<div class={styles.shell}>
|
||||
<Show
|
||||
when={!assignmentData.loading}
|
||||
fallback={
|
||||
<section class={styles.emptyState}>
|
||||
<p class={styles.emptyEyebrow}>Loading assignment</p>
|
||||
<h1>Pulling your live workspace from the backend.</h1>
|
||||
</section>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={assignmentData()}
|
||||
fallback={
|
||||
<section class={styles.emptyState}>
|
||||
<p class={styles.emptyEyebrow}>Assignment not found</p>
|
||||
<h1>We could not find that assignment workspace.</h1>
|
||||
<A href={getDashboardHomeHref("student")} class={styles.backButton}>
|
||||
Back to dashboard
|
||||
</A>
|
||||
</section>
|
||||
}
|
||||
>
|
||||
{(data) => {
|
||||
const reviewHref = createMemo(() => getAssignmentReviewHref("student", data().id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<AssignmentWorkBanner data={data()} answeredCount={answeredCount()} isSubmitted={isSubmitted()} reviewHref={reviewHref()} />
|
||||
|
||||
<AssignmentWorkHeader
|
||||
data={data()}
|
||||
answeredCount={answeredCount()}
|
||||
remainingCount={remainingCount()}
|
||||
saveLabel={saveLabel()}
|
||||
reviewHref={reviewHref()}
|
||||
isSubmitting={submitState() === "submitting"}
|
||||
onSubmit={() => void handleSubmit()}
|
||||
/>
|
||||
|
||||
<AssignmentTabs role="student" />
|
||||
|
||||
<div class={styles.contentGrid}>
|
||||
<AssignmentWorkNavigator
|
||||
questions={data().questions}
|
||||
drafts={drafts()}
|
||||
activeQuestionId={activeQuestionId()}
|
||||
answeredCount={answeredCount()}
|
||||
remainingCount={remainingCount()}
|
||||
saveLabel={saveLabel()}
|
||||
isSubmitting={submitState() === "submitting"}
|
||||
onSelectQuestion={setActiveQuestionId}
|
||||
/>
|
||||
|
||||
<Show when={currentQuestion() && activeDraft()}>
|
||||
<AssignmentWorkWorkspace
|
||||
question={currentQuestion()!}
|
||||
draft={activeDraft()!}
|
||||
saveLabel={saveLabel()}
|
||||
submittedAt={submittedAt()}
|
||||
isLastQuestion={isLastQuestion()}
|
||||
isSubmitting={submitState() === "submitting"}
|
||||
onUpdateDraft={updateDraft}
|
||||
onMoveQuestion={moveQuestion}
|
||||
onSaveDraft={() => void saveQuestion()}
|
||||
onSaveAndContinue={() => void handleSaveAndContinue()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudentAssignmentWorkPage;
|
||||
@@ -0,0 +1,247 @@
|
||||
// Path: Frontend/src/components/assignment/work/student-assignment-work.sections.tsx
|
||||
|
||||
import { A } from "@solidjs/router";
|
||||
import type { Component } from "solid-js";
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { assignmentUiCopy } from "~/content/ui-copy";
|
||||
import type { AssignmentWorkPageData, AssignmentWorkQuestion } from "./assignment-work.data";
|
||||
import styles from "./assignment-work.module.scss";
|
||||
|
||||
export type DraftState = Record<
|
||||
number,
|
||||
{
|
||||
answer: string;
|
||||
steps: string;
|
||||
solveMode: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
|
||||
}
|
||||
>;
|
||||
|
||||
export const solveModeOptions = [
|
||||
{ value: "just_answer", label: assignmentUiCopy.studentWork.solveModes.justAnswer },
|
||||
{ value: "step_by_step", label: assignmentUiCopy.studentWork.solveModes.stepByStep },
|
||||
{ value: "solve_together", label: assignmentUiCopy.studentWork.solveModes.solveTogether },
|
||||
{ value: "handwritten", label: assignmentUiCopy.studentWork.solveModes.handwritten },
|
||||
] as const;
|
||||
|
||||
type AssignmentWorkBannerProps = {
|
||||
data: AssignmentWorkPageData;
|
||||
answeredCount: number;
|
||||
isSubmitted: boolean;
|
||||
reviewHref: string;
|
||||
};
|
||||
|
||||
export const AssignmentWorkBanner: Component<AssignmentWorkBannerProps> = (props) => (
|
||||
<Show when={props.isSubmitted}>
|
||||
<section class={styles.submitBanner}>
|
||||
<div>
|
||||
<p class={styles.bannerEyebrow}>{assignmentUiCopy.studentWork.banner.eyebrow}</p>
|
||||
<h2>
|
||||
You answered {props.answeredCount} of {props.data.questions.length} questions.
|
||||
</h2>
|
||||
<p>Your latest answers are synced. You can review the assignment or keep refining any answer.</p>
|
||||
</div>
|
||||
<A href={props.reviewHref} class={styles.bannerLink}>
|
||||
{assignmentUiCopy.studentWork.banner.link}
|
||||
</A>
|
||||
</section>
|
||||
</Show>
|
||||
);
|
||||
|
||||
type AssignmentWorkHeaderProps = {
|
||||
data: AssignmentWorkPageData;
|
||||
answeredCount: number;
|
||||
remainingCount: number;
|
||||
saveLabel: string;
|
||||
reviewHref: string;
|
||||
isSubmitting: boolean;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export const AssignmentWorkHeader: Component<AssignmentWorkHeaderProps> = (props) => (
|
||||
<section class={styles.headerCard}>
|
||||
<div class={styles.headerTop}>
|
||||
<A href={props.reviewHref} class={styles.backLink}>
|
||||
{assignmentUiCopy.studentWork.header.backToReview}
|
||||
</A>
|
||||
<span class={styles.statusPill}>{props.data.statusLabel}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.headerCopy}>
|
||||
<p class={styles.eyebrow}>{props.data.classroomName}</p>
|
||||
<h1>{assignmentUiCopy.studentWork.header.titlePrefix} {props.data.title}</h1>
|
||||
<p>Answer each question, show your working, and save each answer back to the live assignment as you go.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.headerMeta}>
|
||||
<p class={styles.saveState}>{props.saveLabel}</p>
|
||||
<div class={styles.submitMeta}>
|
||||
<button type="button" class={styles.submitButton} onClick={props.onSubmit} disabled={props.isSubmitting}>
|
||||
{props.isSubmitting ? assignmentUiCopy.studentWork.header.submitting : props.remainingCount > 0 ? `Submit ${props.answeredCount}/${props.data.questions.length}` : assignmentUiCopy.studentWork.header.submitAssignment}
|
||||
</button>
|
||||
<Show when={props.isSubmitting}>
|
||||
<p class={styles.submitHint}>{assignmentUiCopy.studentWork.header.submitHint}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
type AssignmentWorkNavigatorProps = {
|
||||
questions: AssignmentWorkQuestion[];
|
||||
drafts: DraftState;
|
||||
activeQuestionId: number | null;
|
||||
answeredCount: number;
|
||||
remainingCount: number;
|
||||
saveLabel: string;
|
||||
isSubmitting: boolean;
|
||||
onSelectQuestion: (questionId: number) => void;
|
||||
};
|
||||
|
||||
export const AssignmentWorkNavigator: Component<AssignmentWorkNavigatorProps> = (props) => {
|
||||
const [isExpanded, setIsExpanded] = createSignal(false);
|
||||
|
||||
return (
|
||||
<aside class={styles.navigator}>
|
||||
<div class={styles.navigatorHeader}>
|
||||
<div>
|
||||
<h2>{assignmentUiCopy.studentWork.navigator.title}</h2>
|
||||
<p>
|
||||
{props.answeredCount} of {props.questions.length} {assignmentUiCopy.studentWork.navigator.answeredSuffix}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={styles.navigatorToggle}
|
||||
aria-expanded={isExpanded()}
|
||||
onClick={() => setIsExpanded((value) => !value)}
|
||||
>
|
||||
{isExpanded() ? "Hide list" : "Open list"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div classList={{ [styles.navigatorContent]: true, [styles.navigatorContentExpanded]: isExpanded() }}>
|
||||
<div class={styles.progressMeta}>
|
||||
<span>{props.remainingCount} {assignmentUiCopy.studentWork.navigator.remainingSuffix}</span>
|
||||
<span>{props.saveLabel}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.questionButtons}>
|
||||
<For each={props.questions}>
|
||||
{(question) => {
|
||||
const hasDraft = () => (props.drafts[question.id]?.answer ?? "").trim().length > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
classList={{
|
||||
[styles.questionButton]: true,
|
||||
[styles.activeQuestion]: props.activeQuestionId === question.id,
|
||||
[styles.completedQuestion]: hasDraft(),
|
||||
}}
|
||||
disabled={props.isSubmitting}
|
||||
onClick={() => {
|
||||
props.onSelectQuestion(question.id);
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
>
|
||||
<span>Q{question.order}</span>
|
||||
<small>{hasDraft() ? assignmentUiCopy.studentWork.navigator.draftReady : question.statusLabel}</small>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
type AssignmentWorkWorkspaceProps = {
|
||||
question: AssignmentWorkQuestion;
|
||||
draft: DraftState[number];
|
||||
saveLabel: string;
|
||||
submittedAt: string | null;
|
||||
isLastQuestion: boolean;
|
||||
isSubmitting: boolean;
|
||||
onUpdateDraft: (field: "answer" | "steps" | "solveMode", value: string) => void;
|
||||
onMoveQuestion: (direction: 1 | -1) => void;
|
||||
onSaveDraft: () => void;
|
||||
onSaveAndContinue: () => void;
|
||||
};
|
||||
|
||||
export const AssignmentWorkWorkspace: Component<AssignmentWorkWorkspaceProps> = (props) => (
|
||||
<section class={styles.workspace}>
|
||||
<div class={styles.workspaceHeader}>
|
||||
<div>
|
||||
<p class={styles.questionEyebrow}>{assignmentUiCopy.studentWork.workspace.questionPrefix} {props.question.order}</p>
|
||||
<h2>{props.question.prompt}</h2>
|
||||
<p class={styles.questionStatus}>{props.question.statusLabel}</p>
|
||||
</div>
|
||||
<div class={styles.questionMeta}>
|
||||
<span>{props.question.topic}</span>
|
||||
<Show when={props.question.subTopic}>
|
||||
<span>{props.question.subTopic}</span>
|
||||
</Show>
|
||||
<span>{props.question.difficulty}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class={styles.fieldGroup}>
|
||||
<span>{assignmentUiCopy.studentWork.workspace.solveMode}</span>
|
||||
<select value={props.draft.solveMode} disabled={props.isSubmitting} onInput={(event) => props.onUpdateDraft("solveMode", event.currentTarget.value)}>
|
||||
<For each={solveModeOptions}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||
</select>
|
||||
<small class={styles.fieldHint}>{assignmentUiCopy.studentWork.workspace.solveModeHint}</small>
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldGroup}>
|
||||
<span>{assignmentUiCopy.studentWork.workspace.yourAnswer}</span>
|
||||
<input type="text" value={props.draft.answer} disabled={props.isSubmitting} onInput={(event) => props.onUpdateDraft("answer", event.currentTarget.value)} placeholder={assignmentUiCopy.studentWork.workspace.answerPlaceholder} />
|
||||
<small class={styles.fieldHint}>Keep this short — your final number, fraction, or expression.</small>
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldGroup}>
|
||||
<span>{assignmentUiCopy.studentWork.workspace.workingSteps}</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={props.draft.steps}
|
||||
disabled={props.isSubmitting}
|
||||
onInput={(event) => props.onUpdateDraft("steps", event.currentTarget.value)}
|
||||
placeholder={assignmentUiCopy.studentWork.workspace.stepsPlaceholder}
|
||||
/>
|
||||
<small class={styles.fieldHint}>Show enough working that you could explain it back to your teacher later.</small>
|
||||
</label>
|
||||
|
||||
<div class={styles.helperCard}>
|
||||
<p class={styles.helperEyebrow}>{assignmentUiCopy.studentWork.workspace.helperEyebrow}</p>
|
||||
<p>Try to explain why each step makes sense, not just what number you wrote next.</p>
|
||||
<Show when={props.question.showAnswerKey}>
|
||||
<div class={styles.answerReveal}>
|
||||
<span>{assignmentUiCopy.studentWork.workspace.answerKey}</span>
|
||||
<strong>{props.question.correctAnswer}</strong>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={styles.actionRow}>
|
||||
<button type="button" class={styles.secondaryButton} onClick={() => props.onMoveQuestion(-1)} disabled={props.isSubmitting}>
|
||||
{assignmentUiCopy.studentWork.workspace.previous}
|
||||
</button>
|
||||
<div class={styles.primaryActions}>
|
||||
<button type="button" class={styles.secondaryButton} onClick={props.onSaveDraft} disabled={props.isSubmitting}>
|
||||
{assignmentUiCopy.studentWork.workspace.saveDraft}
|
||||
</button>
|
||||
<button type="button" class={styles.primaryButton} onClick={props.onSaveAndContinue} disabled={props.isSubmitting}>
|
||||
{props.isLastQuestion ? assignmentUiCopy.studentWork.workspace.saveThisQuestion : assignmentUiCopy.studentWork.workspace.saveAndContinue}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.questionFooter}>
|
||||
<p>{props.saveLabel}</p>
|
||||
<Show when={props.submittedAt}>
|
||||
<p>{assignmentUiCopy.studentWork.workspace.lastSubmittedPrefix} {new Intl.DateTimeFormat("en-GB", { hour: "numeric", minute: "2-digit" }).format(new Date(props.submittedAt!))}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { assignmentFocusGroups, assignmentFocusStats } from "./dashboard.data";
|
||||
import styles from "./dashboard-assignments-focus.module.scss";
|
||||
|
||||
const DashboardAssignmentsFocus: Component = () => {
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<article class={styles.heroCard}>
|
||||
<div class={styles.heroCopy}>
|
||||
<p class={styles.eyebrow}>Assignments</p>
|
||||
<h1>Your assignment hub</h1>
|
||||
<p>Stay in dashboard mode to see what is live, what needs finishing, and what is ready for review.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.statGrid}>
|
||||
<For each={assignmentFocusStats}>
|
||||
{(stat) => (
|
||||
<div class={styles.statCard}>
|
||||
<strong>{stat.value}</strong>
|
||||
<span>{stat.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class={styles.groupList}>
|
||||
<For each={assignmentFocusGroups}>
|
||||
{(group) => (
|
||||
<section class={styles.group}>
|
||||
<div class={styles.groupHeader}>
|
||||
<div>
|
||||
<h2>{group.title}</h2>
|
||||
<p>{group.description}</p>
|
||||
</div>
|
||||
<span class={styles.groupCount}>{group.items.length}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.cardGrid}>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<article class={styles.assignmentCard}>
|
||||
<div class={styles.cardTop}>
|
||||
<span classList={{ [styles.statusChip]: true, [styles[item.tone]]: true }}>{item.statusLabel}</span>
|
||||
<span class={styles.progressText}>{item.progressText}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.cardBody}>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.meta}</p>
|
||||
<small>{item.subMeta}</small>
|
||||
</div>
|
||||
|
||||
<div class={styles.cardActions}>
|
||||
<A href={item.primaryHref} class={styles.primaryAction}>
|
||||
{item.primaryLabel}
|
||||
</A>
|
||||
<A href={item.secondaryHref} class={styles.secondaryAction}>
|
||||
{item.secondaryLabel}
|
||||
</A>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardAssignmentsFocus;
|
||||
@@ -1,188 +0,0 @@
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.heroCard {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.15rem;
|
||||
border-radius: 1.5rem;
|
||||
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
|
||||
@media (min-width: 880px) {
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr);
|
||||
align-items: center;
|
||||
padding: 1.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.96rem;
|
||||
max-width: 58ch;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.statGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
|
||||
@media (min-width: 520px) {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 880px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.statCard {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.95rem;
|
||||
border-radius: 1.15rem;
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--border-soft);
|
||||
|
||||
strong {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.threadList {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 900px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.threadCard {
|
||||
display: grid;
|
||||
gap: 0.95rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1.3rem;
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.threadTop {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.senderWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0.18rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9999px;
|
||||
background: var(--surface-accent-soft);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.threadMeta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.32rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.unread {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.32rem 0.62rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--surface-info-emphasis);
|
||||
color: var(--text-info-strong);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview {
|
||||
font-size: 0.93rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.threadActions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 9999px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
|
||||
color: var(--action-primary-text);
|
||||
box-shadow: var(--action-primary-shadow);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { messageFocusStats, messageFocusThreads } from "./dashboard.data";
|
||||
import styles from "./dashboard-messages-focus.module.scss";
|
||||
|
||||
const DashboardMessagesFocus: Component = () => {
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<article class={styles.heroCard}>
|
||||
<div class={styles.heroCopy}>
|
||||
<p class={styles.eyebrow}>Messages</p>
|
||||
<h1>Your message centre</h1>
|
||||
<p>Keep dashboard context while you check tutor guidance, assignment reminders, and quick study nudges that point you to the next useful action.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.statGrid}>
|
||||
<For each={messageFocusStats}>
|
||||
{(stat) => (
|
||||
<div class={styles.statCard}>
|
||||
<strong>{stat.value}</strong>
|
||||
<span>{stat.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class={styles.threadList}>
|
||||
<For each={messageFocusThreads}>
|
||||
{(thread) => (
|
||||
<article class={styles.threadCard}>
|
||||
<div class={styles.threadTop}>
|
||||
<div class={styles.senderWrap}>
|
||||
<span class={styles.avatar}>{thread.initials}</span>
|
||||
<div>
|
||||
<h2>{thread.sender}</h2>
|
||||
<p>{thread.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class={styles.threadMeta}>
|
||||
<span>{thread.timestamp}</span>
|
||||
{thread.unread && <strong class={styles.unread}>Unread</strong>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class={styles.preview}>{thread.preview}</p>
|
||||
|
||||
<div class={styles.threadActions}>
|
||||
<A href={thread.actionHref} class={styles.primaryAction}>
|
||||
{thread.actionLabel}
|
||||
</A>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardMessagesFocus;
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { settingsFocusPanels, settingsFocusStats, topbarSummary } from "./dashboard.data";
|
||||
import styles from "./dashboard-settings-focus.module.scss";
|
||||
|
||||
const DashboardSettingsFocus: Component = () => {
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<article class={styles.heroCard}>
|
||||
<div class={styles.heroCopy}>
|
||||
<p class={styles.eyebrow}>Settings</p>
|
||||
<h1>Your dashboard settings</h1>
|
||||
<p>Keep this inside the dashboard shell so profile details, study preferences, and learner goals feel like part of the same student workspace.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.profileChip}>
|
||||
<span class={styles.avatar}>{topbarSummary.profileBadge}</span>
|
||||
<div>
|
||||
<strong>{topbarSummary.profileName}</strong>
|
||||
<span>{topbarSummary.profileRole}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.statGrid}>
|
||||
<For each={settingsFocusStats}>
|
||||
{(stat) => (
|
||||
<div class={styles.statCard}>
|
||||
<strong>{stat.value}</strong>
|
||||
<span>{stat.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class={styles.panelGrid}>
|
||||
<For each={settingsFocusPanels}>
|
||||
{(panel) => (
|
||||
<article class={styles.panelCard}>
|
||||
<div class={styles.panelBody}>
|
||||
<div class={styles.panelCopy}>
|
||||
<h2>{panel.title}</h2>
|
||||
<p>{panel.description}</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.rowList}>
|
||||
<For each={panel.rows}>
|
||||
{(row) => (
|
||||
<div class={styles.rowItem}>
|
||||
<span>{row.label}</span>
|
||||
<strong>{row.value}</strong>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.panelActions}>
|
||||
<A href={panel.primaryHref} class={styles.primaryAction}>
|
||||
{panel.primaryLabel}
|
||||
</A>
|
||||
{panel.secondaryHref && panel.secondaryLabel && (
|
||||
<A href={panel.secondaryHref} class={styles.secondaryAction}>
|
||||
{panel.secondaryLabel}
|
||||
</A>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSettingsFocus;
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { classroomSummary, sidebarLinks, sidebarSupport } from "./dashboard.data";
|
||||
import styles from "./dashboard-sidebar.module.scss";
|
||||
|
||||
type DashboardSidebarProps = {
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
const DashboardSidebar: Component<DashboardSidebarProps> = (props) => {
|
||||
const location = useLocation();
|
||||
|
||||
const isActiveLink = (href?: string) => {
|
||||
if (!href) return false;
|
||||
if (href === "/dashboard") return location.pathname === "/dashboard";
|
||||
return location.pathname === href || location.pathname.startsWith(`${href}/`);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside class={styles.sidebar}>
|
||||
<div class={styles.brand}>
|
||||
<div class={styles.logoMark}>R</div>
|
||||
<div>
|
||||
<p class={styles.brandName}>Rooster AI</p>
|
||||
<p class={styles.brandMeta}>{classroomSummary.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class={styles.navigation} aria-label="Dashboard navigation">
|
||||
<For each={sidebarLinks}>
|
||||
{(link) => (
|
||||
<A
|
||||
href={link.href ?? "#"}
|
||||
classList={{ [styles.link]: true, [styles.active]: isActiveLink(link.href) || (!!link.active && location.pathname === "/dashboard") }}
|
||||
onClick={() => props.onNavigate?.()}
|
||||
>
|
||||
<span class={styles.iconSlot}>{link.icon}</span>
|
||||
<span class={styles.linkCopy}>
|
||||
<strong>{link.label}</strong>
|
||||
{link.detail && <small>{link.detail}</small>}
|
||||
</span>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
|
||||
<div class={styles.supportCard}>
|
||||
<div class={styles.avatarRow}>
|
||||
<For each={sidebarSupport.avatars}>{(avatar) => <span>{avatar}</span>}</For>
|
||||
</div>
|
||||
<h2>{sidebarSupport.title}</h2>
|
||||
<p>{sidebarSupport.description}</p>
|
||||
<A href={sidebarSupport.buttonHref} class={styles.supportButton} onClick={() => props.onNavigate?.()}>
|
||||
{sidebarSupport.buttonLabel}
|
||||
</A>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSidebar;
|
||||
@@ -1,218 +0,0 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { createSignal, For, onCleanup, onMount, Show, type Component } from "solid-js";
|
||||
import DashboardThemeToggle from "./dashboard-theme-toggle";
|
||||
import { topbarMessages, topbarNotifications, topbarSummary } from "./dashboard.data";
|
||||
import styles from "./dashboard-topbar.module.scss";
|
||||
|
||||
type DashboardTopbarProps = {
|
||||
isSidebarOpen: boolean;
|
||||
onMenuToggle: () => void;
|
||||
};
|
||||
|
||||
const DashboardTopbar: Component<DashboardTopbarProps> = (props) => {
|
||||
const [openMenu, setOpenMenu] = createSignal<"notifications" | "messages" | "profile" | null>(null);
|
||||
let menuRoot: HTMLDivElement | undefined;
|
||||
|
||||
const toggleMenu = (menu: "notifications" | "messages" | "profile") => {
|
||||
setOpenMenu((current) => (current === menu ? null : menu));
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!menuRoot?.contains(event.target as Node)) {
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
onCleanup(() => document.removeEventListener("pointerdown", handlePointerDown));
|
||||
});
|
||||
|
||||
return (
|
||||
<header class={styles.topbar}>
|
||||
<button
|
||||
type="button"
|
||||
classList={{ [styles.menuButton]: true, [styles.menuButtonOpen]: props.isSidebarOpen }}
|
||||
aria-label={props.isSidebarOpen ? "Close menu" : "Open menu"}
|
||||
aria-expanded={props.isSidebarOpen}
|
||||
onClick={props.onMenuToggle}
|
||||
>
|
||||
<span class={styles.menuGlyph} aria-hidden="true">
|
||||
<span class={styles.menuLine} />
|
||||
<span class={styles.menuLine} />
|
||||
<span class={styles.menuLine} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<label class={styles.searchField}>
|
||||
<span class={styles.searchIcon} aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="6" />
|
||||
<path d="M20 20l-4.2-4.2" />
|
||||
</svg>
|
||||
</span>
|
||||
<input type="search" placeholder={topbarSummary.searchPlaceholder} />
|
||||
</label>
|
||||
|
||||
<div class={styles.actions} ref={menuRoot}>
|
||||
<DashboardThemeToggle />
|
||||
|
||||
<div class={styles.menuGroup}>
|
||||
<button
|
||||
type="button"
|
||||
class={styles.iconButton}
|
||||
aria-label="Notifications"
|
||||
aria-expanded={openMenu() === "notifications"}
|
||||
onClick={() => toggleMenu("notifications")}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 5a4 4 0 0 1 4 4v2.5c0 1 .4 2 .99 2.8L18 16H6l1.01-1.7c.59-.8.99-1.8.99-2.8V9a4 4 0 0 1 4-4Z" />
|
||||
<path d="M10 18a2 2 0 0 0 4 0" />
|
||||
</svg>
|
||||
<Show when={topbarSummary.notificationCount > 0}>
|
||||
<span class={styles.countBadge}>{topbarSummary.notificationCount}</span>
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<Show when={openMenu() === "notifications"}>
|
||||
<div class={styles.dropdown}>
|
||||
<div class={styles.dropdownHeader}>
|
||||
<div>
|
||||
<p class={styles.dropdownEyebrow}>Alerts</p>
|
||||
<strong>Notifications</strong>
|
||||
</div>
|
||||
<A href="/dashboard/progress" class={styles.dropdownLink} onClick={() => setOpenMenu(null)}>
|
||||
Open progress
|
||||
</A>
|
||||
</div>
|
||||
|
||||
<div class={styles.dropdownList}>
|
||||
<For each={topbarNotifications}>
|
||||
{(item) => (
|
||||
<A href={item.href} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
|
||||
<span class={`${styles.itemTone} ${styles[`tone-${item.tone}`]}`} aria-hidden="true" />
|
||||
<div class={styles.itemCopy}>
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.description}</p>
|
||||
</div>
|
||||
<small>{item.timestamp}</small>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={styles.menuGroup}>
|
||||
<button
|
||||
type="button"
|
||||
class={styles.iconButton}
|
||||
aria-label="Messages"
|
||||
aria-expanded={openMenu() === "messages"}
|
||||
onClick={() => toggleMenu("messages")}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 6h16v10H8l-4 3V6Z" />
|
||||
</svg>
|
||||
<Show when={topbarSummary.messageCount > 0}>
|
||||
<span class={styles.countBadge}>{topbarSummary.messageCount}</span>
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<Show when={openMenu() === "messages"}>
|
||||
<div class={styles.dropdown}>
|
||||
<div class={styles.dropdownHeader}>
|
||||
<div>
|
||||
<p class={styles.dropdownEyebrow}>Inbox</p>
|
||||
<strong>Messages</strong>
|
||||
</div>
|
||||
<A href="/dashboard/messages" class={styles.dropdownLink} onClick={() => setOpenMenu(null)}>
|
||||
Open inbox
|
||||
</A>
|
||||
</div>
|
||||
|
||||
<div class={styles.dropdownList}>
|
||||
<For each={topbarMessages}>
|
||||
{(item) => (
|
||||
<A href={item.actionHref} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
|
||||
<span class={styles.messageAvatar}>{item.initials}</span>
|
||||
<div class={styles.itemCopy}>
|
||||
<strong>{item.sender}</strong>
|
||||
<p>{item.preview}</p>
|
||||
</div>
|
||||
<small>{item.timestamp}</small>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={styles.menuGroup}>
|
||||
<button
|
||||
type="button"
|
||||
classList={{ [styles.profileButton]: true, [styles.profileButtonOpen]: openMenu() === "profile" }}
|
||||
aria-label="Profile menu"
|
||||
aria-expanded={openMenu() === "profile"}
|
||||
onClick={() => toggleMenu("profile")}
|
||||
>
|
||||
<div class={styles.profile}>
|
||||
<div>
|
||||
<p class={styles.profileName}>{topbarSummary.profileName}</p>
|
||||
<p class={styles.profileRole}>{topbarSummary.profileRole}</p>
|
||||
</div>
|
||||
<span class={styles.profileBadge}>{topbarSummary.profileBadge}</span>
|
||||
<span class={styles.profileChevron} aria-hidden="true">
|
||||
<svg viewBox="0 0 20 20">
|
||||
<path d="m5 7.5 5 5 5-5" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Show when={openMenu() === "profile"}>
|
||||
<div class={styles.dropdown}>
|
||||
<div class={styles.dropdownHeader}>
|
||||
<div>
|
||||
<p class={styles.dropdownEyebrow}>Signed in as</p>
|
||||
<strong>{topbarSummary.profileName}</strong>
|
||||
</div>
|
||||
<span class={styles.profileMenuBadge}>{topbarSummary.profileBadge}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.dropdownList}>
|
||||
<A href="/dashboard/settings" class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
|
||||
<span class={`${styles.itemTone} ${styles["tone-blue"]}`} aria-hidden="true" />
|
||||
<div class={styles.itemCopy}>
|
||||
<strong>Profile</strong>
|
||||
<p>Update your learner details and view your profile settings.</p>
|
||||
</div>
|
||||
</A>
|
||||
|
||||
<A href="/dashboard/settings" class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
|
||||
<span class={`${styles.itemTone} ${styles["tone-yellow"]}`} aria-hidden="true" />
|
||||
<div class={styles.itemCopy}>
|
||||
<strong>Settings</strong>
|
||||
<p>Manage goals, preferences, reminders, and dashboard options.</p>
|
||||
</div>
|
||||
</A>
|
||||
|
||||
<A href="/auth/login" class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
|
||||
<span class={`${styles.itemTone} ${styles["tone-teal"]}`} aria-hidden="true" />
|
||||
<div class={styles.itemCopy}>
|
||||
<strong>Log out</strong>
|
||||
<p>Return to the sign-in screen. This is a UI-only sign out for now.</p>
|
||||
</div>
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTopbar;
|
||||
@@ -1,774 +0,0 @@
|
||||
import rawDataset from "../../../../Mock-Data/dataset.json";
|
||||
|
||||
type SidebarLink = {
|
||||
label: string;
|
||||
detail?: string;
|
||||
icon: string;
|
||||
href?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
type SpotlightStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "purple" | "yellow" | "blue";
|
||||
};
|
||||
|
||||
type QuickStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type AssignmentCard = {
|
||||
title: string;
|
||||
lessons: string;
|
||||
accent: "yellow" | "pink" | "teal" | "blue";
|
||||
cta: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type AssignmentFocusStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type AssignmentFocusItem = {
|
||||
title: string;
|
||||
meta: string;
|
||||
subMeta: string;
|
||||
statusLabel: string;
|
||||
progressText: string;
|
||||
tone: "yellow" | "teal" | "blue";
|
||||
primaryLabel: string;
|
||||
primaryHref: string;
|
||||
secondaryLabel: string;
|
||||
secondaryHref: string;
|
||||
};
|
||||
|
||||
type AssignmentFocusGroup = {
|
||||
title: string;
|
||||
description: string;
|
||||
items: AssignmentFocusItem[];
|
||||
};
|
||||
|
||||
type PracticeFocusStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type PracticeFocusCard = {
|
||||
title: string;
|
||||
description: string;
|
||||
meta: string;
|
||||
tone: "yellow" | "teal" | "blue";
|
||||
primaryLabel: string;
|
||||
primaryHref: string;
|
||||
secondaryLabel: string;
|
||||
secondaryHref: string;
|
||||
};
|
||||
|
||||
type MessageFocusStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type MessageFocusThread = {
|
||||
sender: string;
|
||||
role: string;
|
||||
initials: string;
|
||||
preview: string;
|
||||
timestamp: string;
|
||||
unread?: boolean;
|
||||
actionLabel: string;
|
||||
actionHref: string;
|
||||
};
|
||||
|
||||
type TopbarNotificationItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
href: string;
|
||||
tone: "blue" | "yellow" | "teal";
|
||||
};
|
||||
|
||||
type SettingsFocusStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SettingsFocusPanel = {
|
||||
title: string;
|
||||
description: string;
|
||||
rows: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
primaryLabel: string;
|
||||
primaryHref: string;
|
||||
secondaryLabel?: string;
|
||||
secondaryHref?: string;
|
||||
};
|
||||
|
||||
type StudentSupportCard = {
|
||||
name: string;
|
||||
meta: string;
|
||||
initials: string;
|
||||
actionLabel: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type HighlightCard = {
|
||||
value: string;
|
||||
label: string;
|
||||
note: string;
|
||||
tone: "yellow" | "pink";
|
||||
};
|
||||
|
||||
type PerformanceBar = {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "blue" | "purple" | "teal" | "pink" | "yellow";
|
||||
};
|
||||
|
||||
type UsageItem = {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "blue" | "purple" | "teal" | "yellow";
|
||||
};
|
||||
|
||||
type Dataset = {
|
||||
_meta: {
|
||||
reference_today: string;
|
||||
students: number;
|
||||
assignments: number;
|
||||
questions_in_bank: number;
|
||||
student_answers: number;
|
||||
expected_top_3_at_risk_student_ids: number[];
|
||||
};
|
||||
classroom: {
|
||||
name: string;
|
||||
invite_code: string;
|
||||
target_level: number;
|
||||
};
|
||||
tutor: {
|
||||
fullname: string;
|
||||
role: string;
|
||||
};
|
||||
students: Array<{
|
||||
id: number;
|
||||
fullname: string;
|
||||
_persona: string;
|
||||
}>;
|
||||
assignments: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
topic: string;
|
||||
status: "DRAFT" | "PUBLISHED" | "CLOSED";
|
||||
due_date: number;
|
||||
maximum_marks: number;
|
||||
}>;
|
||||
assignment_assignees: Array<{
|
||||
id: number;
|
||||
assignment_id: number;
|
||||
student_id: number;
|
||||
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
|
||||
total_marks: number;
|
||||
}>;
|
||||
student_answers: Array<{
|
||||
assignee_id: number;
|
||||
_is_correct: boolean;
|
||||
_solve_mode: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
|
||||
_question_topic: string;
|
||||
_answered_at: number;
|
||||
}>;
|
||||
activity_logs: Array<{
|
||||
timestamp: number;
|
||||
duration_seconds: number;
|
||||
_student_id: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
type StudentAssignment = Dataset["assignment_assignees"][number] & {
|
||||
assignment: Dataset["assignments"][number];
|
||||
answerCount: number;
|
||||
accuracy: number;
|
||||
};
|
||||
|
||||
const dataset = rawDataset as Dataset;
|
||||
const referenceTime = new Date(`${dataset._meta.reference_today}T00:00:00Z`).getTime();
|
||||
const referenceDate = new Date(referenceTime);
|
||||
const msPerDay = 1000 * 60 * 60 * 24;
|
||||
const defaultStudentId = 201;
|
||||
|
||||
const students = dataset.students;
|
||||
const assignments = [...dataset.assignments].sort((left, right) => left.due_date - right.due_date);
|
||||
const assignees = dataset.assignment_assignees;
|
||||
const answers = dataset.student_answers;
|
||||
const activityLogs = dataset.activity_logs;
|
||||
|
||||
const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0];
|
||||
|
||||
const initialsFor = (name: string) =>
|
||||
name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
const firstName = student.fullname.split(" ")[0];
|
||||
|
||||
const formatCompactNumber = (value: number) => new Intl.NumberFormat("en-GB", { notation: "compact", maximumFractionDigits: 1 }).format(value);
|
||||
const formatPercent = (value: number) => `${Math.round(value)}%`;
|
||||
|
||||
const formatDueDate = (timestamp: number) =>
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(timestamp));
|
||||
|
||||
const formatHours = (seconds: number) => `${(seconds / 3600).toFixed(1)}h`;
|
||||
|
||||
const daysUntil = (timestamp: number) => Math.max(0, Math.ceil((timestamp - referenceTime) / msPerDay));
|
||||
|
||||
const formatLastSeen = (timestamp: number) => {
|
||||
const dayDiff = Math.max(0, Math.round((referenceTime - timestamp) / msPerDay));
|
||||
if (dayDiff <= 0) return "today";
|
||||
if (dayDiff === 1) return "yesterday";
|
||||
return `${dayDiff} days ago`;
|
||||
};
|
||||
|
||||
const topicTone = ["pink", "yellow", "blue", "purple", "teal"] as const;
|
||||
|
||||
const assignmentById = new Map(assignments.map((assignment) => [assignment.id, assignment]));
|
||||
const assigneeById = new Map(assignees.map((assignee) => [assignee.id, assignee]));
|
||||
|
||||
const studentAnswers = answers.filter((answer) => assigneeById.get(answer.assignee_id)?.student_id === student.id);
|
||||
const studentLogs = activityLogs.filter((log) => log._student_id === student.id);
|
||||
|
||||
const studentAssignments: StudentAssignment[] = assignees
|
||||
.filter((assignee) => assignee.student_id === student.id)
|
||||
.map((assignee) => {
|
||||
const assignment = assignmentById.get(assignee.assignment_id);
|
||||
if (!assignment) throw new Error(`Missing assignment ${assignee.assignment_id}`);
|
||||
|
||||
const assignmentAnswers = studentAnswers.filter((answer) => answer.assignee_id === assignee.id);
|
||||
const answerCount = assignmentAnswers.length;
|
||||
const correct = assignmentAnswers.filter((answer) => answer._is_correct).length;
|
||||
|
||||
return {
|
||||
...assignee,
|
||||
assignment,
|
||||
answerCount,
|
||||
accuracy: answerCount > 0 ? Math.round((correct / answerCount) * 100) : 0,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.assignment.due_date - right.assignment.due_date);
|
||||
|
||||
const totalCorrectAnswers = studentAnswers.filter((answer) => answer._is_correct).length;
|
||||
const overallAccuracy = studentAnswers.length > 0 ? (totalCorrectAnswers / studentAnswers.length) * 100 : 0;
|
||||
const totalDurationSeconds = studentLogs.reduce((sum, log) => sum + log.duration_seconds, 0);
|
||||
const latestActivityTime = Math.max(...studentLogs.map((log) => log.timestamp));
|
||||
|
||||
const submittedAssignments = studentAssignments.filter((assignment) => assignment.status === "SUBMITTED");
|
||||
const inProgressAssignments = studentAssignments.filter((assignment) => assignment.status === "IN_PROGRESS");
|
||||
const notStartedAssignments = studentAssignments.filter((assignment) => assignment.status === "NOT_STARTED");
|
||||
const pendingAssignments = studentAssignments.filter((assignment) => assignment.status !== "SUBMITTED");
|
||||
|
||||
const currentAssignment = inProgressAssignments[0] ?? notStartedAssignments[0] ?? studentAssignments[studentAssignments.length - 1];
|
||||
const nextNotStartedAssignment = notStartedAssignments[0];
|
||||
|
||||
const topicBuckets = new Map<string, { correct: number; total: number }>();
|
||||
for (const answer of studentAnswers) {
|
||||
const bucket = topicBuckets.get(answer._question_topic) ?? { correct: 0, total: 0 };
|
||||
bucket.correct += Number(answer._is_correct);
|
||||
bucket.total += 1;
|
||||
topicBuckets.set(answer._question_topic, bucket);
|
||||
}
|
||||
|
||||
const topicPerformance = [...topicBuckets.entries()].map(([label, bucket]) => ({
|
||||
label,
|
||||
value: Math.round((bucket.correct / bucket.total) * 100),
|
||||
total: bucket.total,
|
||||
}));
|
||||
|
||||
const strongestTopic = [...topicPerformance].sort((left, right) => right.value - left.value || right.total - left.total)[0];
|
||||
const weakestTopic = [...topicPerformance].sort((left, right) => left.value - right.value || right.total - left.total)[0];
|
||||
|
||||
const topicMasteryBars: PerformanceBar[] = [...topicPerformance]
|
||||
.sort((left, right) => right.total - left.total || left.value - right.value)
|
||||
.slice(0, 5)
|
||||
.sort((left, right) => left.value - right.value)
|
||||
.map((topic, index) => ({
|
||||
label: topic.label,
|
||||
value: topic.value,
|
||||
tone: topicTone[index % topicTone.length],
|
||||
}));
|
||||
|
||||
const solveModeBuckets = new Map<string, number>();
|
||||
for (const answer of studentAnswers) {
|
||||
solveModeBuckets.set(answer._solve_mode, (solveModeBuckets.get(answer._solve_mode) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const solveModeToneMap: Record<string, UsageItem["tone"]> = {
|
||||
just_answer: "purple",
|
||||
step_by_step: "blue",
|
||||
solve_together: "teal",
|
||||
handwritten: "yellow",
|
||||
};
|
||||
|
||||
const solveModeLabelMap: Record<string, string> = {
|
||||
just_answer: "Just answer",
|
||||
step_by_step: "Step by step",
|
||||
solve_together: "Solve together",
|
||||
handwritten: "Handwritten",
|
||||
};
|
||||
|
||||
const solveModeUsage: UsageItem[] = [...solveModeBuckets.entries()]
|
||||
.map(([mode, count]) => ({
|
||||
label: solveModeLabelMap[mode] ?? mode,
|
||||
value: Math.round((count / studentAnswers.length) * 100),
|
||||
tone: solveModeToneMap[mode] ?? "blue",
|
||||
}))
|
||||
.sort((left, right) => right.value - left.value);
|
||||
|
||||
const topSolveMode = solveModeUsage[0];
|
||||
|
||||
const personaSummary: Record<string, { title: string; recommendation: string }> = {
|
||||
fraction_inversion: {
|
||||
title: "Fractions is your focus this week",
|
||||
recommendation: "Slow down on equivalent fractions and practise inversion rules before test prep.",
|
||||
},
|
||||
place_value_gaps: {
|
||||
title: "Place value is worth another pass",
|
||||
recommendation: "Use quick warm-ups before multi-step arithmetic to avoid slips early in the question.",
|
||||
},
|
||||
rushed_careless: {
|
||||
title: "A calmer pace will lift your accuracy",
|
||||
recommendation: "Use Step by step more often on tougher questions so careless mistakes do not snowball.",
|
||||
},
|
||||
solve_together_dependent: {
|
||||
title: "Independent confidence is your next goal",
|
||||
recommendation: "Try one question solo before switching to Solve together so you build recall first.",
|
||||
},
|
||||
word_problem_weak: {
|
||||
title: "Word problems are the main thing to unlock",
|
||||
recommendation: "Underline key information and turn the question into a quick number sentence first.",
|
||||
},
|
||||
stable_strong: {
|
||||
title: "You are in a strong learning rhythm",
|
||||
recommendation: "Keep stretching with harder mixed practice so the easier wins stay automatic.",
|
||||
},
|
||||
stable_mid: {
|
||||
title: "You are close to a really strong streak",
|
||||
recommendation: "A few focused sessions on weaker topics should move your average up quickly.",
|
||||
},
|
||||
stable_weak: {
|
||||
title: "A steadier routine will help most right now",
|
||||
recommendation: "Aim for short, regular practice blocks before trying bigger mixed assignments.",
|
||||
},
|
||||
};
|
||||
|
||||
const activePersona = personaSummary[student._persona] ?? {
|
||||
title: "Keep building momentum",
|
||||
recommendation: "Stay focused on one weak area at a time and finish current work before starting new tasks.",
|
||||
};
|
||||
|
||||
const assignmentAccent = (status: StudentAssignment["status"]): AssignmentCard["accent"] => {
|
||||
switch (status) {
|
||||
case "IN_PROGRESS":
|
||||
return "blue";
|
||||
case "NOT_STARTED":
|
||||
return "yellow";
|
||||
default:
|
||||
return "teal";
|
||||
}
|
||||
};
|
||||
|
||||
const assignmentCta = (status: StudentAssignment["status"]) => {
|
||||
switch (status) {
|
||||
case "IN_PROGRESS":
|
||||
return "Resume now";
|
||||
case "NOT_STARTED":
|
||||
return "Start now";
|
||||
default:
|
||||
return "View recap";
|
||||
}
|
||||
};
|
||||
|
||||
const assignmentStatusCopy = (assignment: StudentAssignment) => {
|
||||
if (assignment.status === "SUBMITTED") {
|
||||
return `${assignment.assignment.topic} · Submitted · Score ${assignment.total_marks}/${assignment.assignment.maximum_marks}`;
|
||||
}
|
||||
|
||||
if (assignment.status === "IN_PROGRESS") {
|
||||
return `${assignment.assignment.topic} · ${assignment.answerCount} questions answered · Due ${formatDueDate(assignment.assignment.due_date)}`;
|
||||
}
|
||||
|
||||
return `${assignment.assignment.topic} · Not started · Due ${formatDueDate(assignment.assignment.due_date)}`;
|
||||
};
|
||||
|
||||
const assignmentCards: AssignmentCard[] = [...studentAssignments]
|
||||
.sort((left, right) => {
|
||||
const weight = { IN_PROGRESS: 0, NOT_STARTED: 1, SUBMITTED: 2 } as const;
|
||||
return weight[left.status] - weight[right.status] || left.assignment.due_date - right.assignment.due_date;
|
||||
})
|
||||
.slice(0, 4)
|
||||
.map((assignment) => ({
|
||||
title: assignment.assignment.name,
|
||||
lessons: assignmentStatusCopy(assignment),
|
||||
accent: assignmentAccent(assignment.status),
|
||||
cta: assignmentCta(assignment.status),
|
||||
href: `/assignment/${assignment.assignment.id}`,
|
||||
}));
|
||||
|
||||
const submittedTrend = submittedAssignments.slice(-6).map((assignment) => ({
|
||||
label: assignment.assignment.topic.length > 8 ? assignment.assignment.topic.slice(0, 8) : assignment.assignment.topic,
|
||||
value: assignment.assignment.maximum_marks > 0 ? Math.round((assignment.total_marks / assignment.assignment.maximum_marks) * 100) : 0,
|
||||
}));
|
||||
|
||||
const usageSummary = {
|
||||
note: topSolveMode
|
||||
? `You use ${topSolveMode.label} for ${topSolveMode.value}% of your answers. When ${weakestTopic?.label?.toLowerCase() ?? "a topic"} gets tricky, try Step by step for a calmer second try.`
|
||||
: "Try a mix of independent and guided solving to learn what helps you most.",
|
||||
};
|
||||
|
||||
export const classroomSummary = {
|
||||
name: dataset.classroom.name,
|
||||
targetLevel: dataset.classroom.target_level,
|
||||
inviteCode: dataset.classroom.invite_code,
|
||||
tutorName: dataset.tutor.fullname,
|
||||
tutorRole: "Lead tutor",
|
||||
tutorInitials: initialsFor(dataset.tutor.fullname),
|
||||
};
|
||||
|
||||
export const sidebarLinks: SidebarLink[] = [
|
||||
{ label: "Home", detail: "Today", icon: "⌂", href: "/dashboard", active: true },
|
||||
{ label: "Assignments", detail: `${pendingAssignments.length} live`, icon: "✓", href: "/dashboard/assignments" },
|
||||
{ label: "Progress", detail: `${Math.round(overallAccuracy)}% accuracy`, icon: "↗", href: "/dashboard/progress" },
|
||||
{ label: "Practice", detail: weakestTopic?.label ?? "Mixed skills", icon: "✦", href: "/dashboard/practice" },
|
||||
{ label: "Messages", detail: dataset.tutor.fullname.split(" ")[0], icon: "✉", href: "/dashboard/messages" },
|
||||
{ label: "Settings", detail: "Profile & goals", icon: "⋯", href: "/dashboard/settings" },
|
||||
];
|
||||
|
||||
export const sidebarSupport = {
|
||||
avatars: [currentAssignment.assignment.name.replace("HW", "H").split("—")[0].trim(), weakestTopic?.label.slice(0, 2) ?? "WK", nextNotStartedAssignment?.assignment.name.replace("HW", "H").split("—")[0].trim() ?? "GO"],
|
||||
title: "Today’s study plan",
|
||||
description: `Pick up ${currentAssignment.assignment.name.split("—")[0].trim()}, spend 15 minutes on ${weakestTopic?.label ?? "your focus topic"}, then start ${nextNotStartedAssignment ? nextNotStartedAssignment.assignment.name.split("—")[0].trim() : "your next task"} while it still feels easy.`,
|
||||
buttonLabel: "Open my plan",
|
||||
buttonHref: `/assignment/${currentAssignment.assignment.id}/work`,
|
||||
};
|
||||
|
||||
export const topbarSummary = {
|
||||
searchPlaceholder: "Search assignments, hints, or question topics",
|
||||
profileName: student.fullname,
|
||||
profileRole: `${dataset.classroom.name} · Student`,
|
||||
profileBadge: initialsFor(student.fullname),
|
||||
notificationCount: 3,
|
||||
messageCount: 1,
|
||||
};
|
||||
|
||||
export const topbarNotifications: TopbarNotificationItem[] = [
|
||||
{
|
||||
title: `${currentAssignment.assignment.name.split("—")[0].trim()} is still live`,
|
||||
description: currentAssignment.status === "SUBMITTED"
|
||||
? `You have already finished this task. Open the review and clean up any missed marks.`
|
||||
: `${currentAssignment.answerCount} questions are already touched. Finish it before ${formatDueDate(currentAssignment.assignment.due_date)}.`,
|
||||
timestamp: `${daysUntil(currentAssignment.assignment.due_date)}d left`,
|
||||
href: currentAssignment.status === "SUBMITTED" ? `/assignment/${currentAssignment.assignment.id}` : `/assignment/${currentAssignment.assignment.id}/work`,
|
||||
tone: currentAssignment.status === "SUBMITTED" ? "teal" : "blue",
|
||||
},
|
||||
{
|
||||
title: `${weakestTopic?.label ?? "Focus practice"} needs a quick pass`,
|
||||
description: weakestTopic
|
||||
? `${weakestTopic.value}% accuracy across ${weakestTopic.total} recent questions. A short focused block here should move the needle fastest.`
|
||||
: "Open a short practice block and work through your next weak spot.",
|
||||
timestamp: "Practice now",
|
||||
href: "/dashboard/practice",
|
||||
tone: "yellow",
|
||||
},
|
||||
{
|
||||
title: `${dataset.tutor.fullname.split(" ")[0]} left feedback`,
|
||||
description: `There is a fresh tutor message waiting with a calm next-step suggestion for ${weakestTopic?.label?.toLowerCase() ?? "your current focus"}.`,
|
||||
timestamp: "Today",
|
||||
href: "/dashboard/messages",
|
||||
tone: "teal",
|
||||
},
|
||||
];
|
||||
|
||||
export const heroSummary = {
|
||||
eyebrow: `Welcome back, ${firstName}`,
|
||||
title: activePersona.title,
|
||||
description: `You have finished ${submittedAssignments.length} assignments so far. Right now, the best next step is to keep ${currentAssignment.assignment.name.split("—")[0].trim()} moving and give ${weakestTopic?.label ?? "your focus topic"} a short confidence boost.`,
|
||||
visualBadges: [formatPercent(overallAccuracy), `${currentAssignment.assignment.name.split("—")[0].trim()} live`, `${weakestTopic?.label ?? "Focus"} focus`],
|
||||
};
|
||||
|
||||
export const spotlightStats: SpotlightStat[] = [
|
||||
{ label: "Assignments done", value: `${submittedAssignments.length}/${studentAssignments.length}`, tone: "purple" },
|
||||
{ label: "Current focus", value: weakestTopic?.label ?? "Mixed practice", tone: "yellow" },
|
||||
{ label: "Best topic", value: strongestTopic?.label ?? "Building", tone: "blue" },
|
||||
];
|
||||
|
||||
export const heroSideCard = {
|
||||
title: currentAssignment.status === "SUBMITTED" ? `Nice work on ${currentAssignment.assignment.name.split("—")[0].trim()}` : `Pick up where you left off`,
|
||||
description:
|
||||
currentAssignment.status === "SUBMITTED"
|
||||
? `You scored ${currentAssignment.total_marks}/${currentAssignment.assignment.maximum_marks}. A quick recap now will make the next assignment feel easier.`
|
||||
: `${currentAssignment.answerCount} questions are already done. Finish it by ${formatDueDate(currentAssignment.assignment.due_date)} while the topic is still fresh.`,
|
||||
buttonLabel: currentAssignment.status === "SUBMITTED" ? "Review this work" : "Resume assignment",
|
||||
buttonHref: currentAssignment.status === "SUBMITTED" ? `/assignment/${currentAssignment.assignment.id}` : `/assignment/${currentAssignment.assignment.id}/work`,
|
||||
};
|
||||
|
||||
export const quickStats: QuickStat[] = [
|
||||
{ label: "Next due", value: `${daysUntil(currentAssignment.assignment.due_date)}d` },
|
||||
{ label: "Last active", value: formatLastSeen(latestActivityTime) },
|
||||
];
|
||||
|
||||
export { assignmentCards };
|
||||
|
||||
export const assignmentFocusStats: AssignmentFocusStat[] = [
|
||||
{ label: "Live now", value: `${pendingAssignments.length}` },
|
||||
{ label: "Completed", value: `${submittedAssignments.length}` },
|
||||
{ label: "Average score", value: formatPercent(overallAccuracy) },
|
||||
{ label: "Next due", value: formatDueDate(currentAssignment.assignment.due_date) },
|
||||
];
|
||||
|
||||
export const progressFocusStats: AssignmentFocusStat[] = [
|
||||
{ label: "Accuracy", value: formatPercent(overallAccuracy) },
|
||||
{ label: "Marked", value: `${submittedAssignments.length}` },
|
||||
{ label: "Strongest", value: strongestTopic?.label ?? "Building" },
|
||||
{ label: "Study time", value: formatHours(totalDurationSeconds) },
|
||||
];
|
||||
|
||||
export const practiceFocusStats: PracticeFocusStat[] = [
|
||||
{ label: "Focus topic", value: weakestTopic?.label ?? "Mixed skills" },
|
||||
{ label: "Topic score", value: weakestTopic ? formatPercent(weakestTopic.value) : "--" },
|
||||
{ label: "Best support", value: topSolveMode?.label ?? "Try mixed modes" },
|
||||
{ label: "Ready now", value: currentAssignment.assignment.name.split("—")[0].trim() },
|
||||
];
|
||||
|
||||
export const practiceFocusCards: PracticeFocusCard[] = [
|
||||
{
|
||||
title: `Rebuild ${weakestTopic?.label ?? "your focus topic"}`,
|
||||
description: `Start with the topic that is costing you the most marks right now, then return to your current assignment while the method is still fresh.`,
|
||||
meta: weakestTopic ? `${weakestTopic.total} recent questions · ${weakestTopic.value}% accuracy` : "Short, focused practice is the fastest win.",
|
||||
tone: "yellow",
|
||||
primaryLabel: "Open practice",
|
||||
primaryHref: `/assignment/${currentAssignment.assignment.id}/work`,
|
||||
secondaryLabel: "View progress",
|
||||
secondaryHref: "/dashboard/progress",
|
||||
},
|
||||
{
|
||||
title: "Try one independent pass",
|
||||
description: topSolveMode
|
||||
? `${topSolveMode.label} is your default. Try answering one question solo before switching modes so you build stronger recall.`
|
||||
: "Use one question to test yourself first, then bring in guided help if you need it.",
|
||||
meta: `Current assignment: ${currentAssignment.assignment.name}`,
|
||||
tone: "blue",
|
||||
primaryLabel: currentAssignment.status === "IN_PROGRESS" ? "Resume work" : "Start assignment",
|
||||
primaryHref: `/assignment/${currentAssignment.assignment.id}/work`,
|
||||
secondaryLabel: "Review task",
|
||||
secondaryHref: `/assignment/${currentAssignment.assignment.id}`,
|
||||
},
|
||||
{
|
||||
title: `Finish with ${strongestTopic?.label ?? "a strong topic"}`,
|
||||
description: `Once the hard bit is done, finish your session on something you usually get right so your next study block starts with confidence.`,
|
||||
meta: strongestTopic ? `${strongestTopic.value}% accuracy in ${strongestTopic.label}` : "Pick a familiar topic for a strong finish.",
|
||||
tone: "teal",
|
||||
primaryLabel: "See assignments",
|
||||
primaryHref: "/dashboard/assignments",
|
||||
secondaryLabel: "Open dashboard",
|
||||
secondaryHref: "/dashboard",
|
||||
},
|
||||
];
|
||||
|
||||
export const messageFocusStats: MessageFocusStat[] = [
|
||||
{ label: "Tutor", value: dataset.tutor.fullname.split(" ")[0] },
|
||||
{ label: "Unread", value: "3" },
|
||||
{ label: "Latest topic", value: weakestTopic?.label ?? "Mixed skills" },
|
||||
{ label: "Last active", value: formatLastSeen(latestActivityTime) },
|
||||
];
|
||||
|
||||
export const messageFocusThreads: MessageFocusThread[] = [
|
||||
{
|
||||
sender: dataset.tutor.fullname,
|
||||
role: "Tutor",
|
||||
initials: initialsFor(dataset.tutor.fullname),
|
||||
preview: `Let’s spend the next session tightening up ${weakestTopic?.label?.toLowerCase() ?? "your focus topic"}. You only need a couple of calm wins here to lift your confidence.`,
|
||||
timestamp: "Today",
|
||||
unread: true,
|
||||
actionLabel: "Open practice",
|
||||
actionHref: "/dashboard/practice",
|
||||
},
|
||||
{
|
||||
sender: "Assignment check-in",
|
||||
role: currentAssignment.assignment.name.split("—")[0].trim(),
|
||||
initials: currentAssignment.assignment.name.replace("HW", "H").split("—")[0].trim(),
|
||||
preview: currentAssignment.status === "SUBMITTED"
|
||||
? `Nice work finishing this one. Review the marked questions before you start the next task.`
|
||||
: `${currentAssignment.answerCount} questions are already done. Finish this assignment before ${formatDueDate(currentAssignment.assignment.due_date)} while it still feels familiar.`,
|
||||
timestamp: currentAssignment.status === "SUBMITTED" ? "Yesterday" : `${daysUntil(currentAssignment.assignment.due_date)}d left`,
|
||||
actionLabel: currentAssignment.status === "SUBMITTED" ? "Open review" : "Resume work",
|
||||
actionHref: currentAssignment.status === "SUBMITTED" ? `/assignment/${currentAssignment.assignment.id}` : `/assignment/${currentAssignment.assignment.id}/work`,
|
||||
},
|
||||
{
|
||||
sender: "Study coach",
|
||||
role: "Quick reminder",
|
||||
initials: "SC",
|
||||
preview: `Your strongest topic is ${strongestTopic?.label ?? "building"}. End today with one easier question there after you practise ${weakestTopic?.label?.toLowerCase() ?? "your focus topic"}.`,
|
||||
timestamp: "2 days ago",
|
||||
actionLabel: "View progress",
|
||||
actionHref: "/dashboard/progress",
|
||||
},
|
||||
];
|
||||
|
||||
export const topbarMessages = messageFocusThreads.slice(0, 3);
|
||||
|
||||
export const settingsFocusStats: SettingsFocusStat[] = [
|
||||
{ label: "Student", value: firstName },
|
||||
{ label: "Class", value: `Year ${dataset.classroom.target_level}` },
|
||||
{ label: "Focus", value: weakestTopic?.label ?? "Mixed skills" },
|
||||
{ label: "Goal", value: `${Math.max(Math.round(overallAccuracy) + 10, 70)}%` },
|
||||
];
|
||||
|
||||
export const settingsFocusPanels: SettingsFocusPanel[] = [
|
||||
{
|
||||
title: "Profile",
|
||||
description: "The learner details and classroom context this dashboard is currently designed around.",
|
||||
rows: [
|
||||
{ label: "Name", value: student.fullname },
|
||||
{ label: "Role", value: `${dataset.classroom.name} · Student` },
|
||||
{ label: "Tutor", value: dataset.tutor.fullname },
|
||||
{ label: "Invite code", value: dataset.classroom.invite_code },
|
||||
],
|
||||
primaryLabel: "Open messages",
|
||||
primaryHref: "/dashboard/messages",
|
||||
secondaryLabel: "View progress",
|
||||
secondaryHref: "/dashboard/progress",
|
||||
},
|
||||
{
|
||||
title: "Learning preferences",
|
||||
description: "A few smart defaults for how this learner is currently working best.",
|
||||
rows: [
|
||||
{ label: "Best support mode", value: topSolveMode?.label ?? "Mixed support" },
|
||||
{ label: "Strongest topic", value: strongestTopic?.label ?? "Building confidence" },
|
||||
{ label: "Needs attention", value: weakestTopic?.label ?? "Mixed skills" },
|
||||
{ label: "Last active", value: formatLastSeen(latestActivityTime) },
|
||||
],
|
||||
primaryLabel: "Open practice",
|
||||
primaryHref: "/dashboard/practice",
|
||||
secondaryLabel: "See assignments",
|
||||
secondaryHref: "/dashboard/assignments",
|
||||
},
|
||||
{
|
||||
title: "Goals and reminders",
|
||||
description: "Settings-like controls can start by summarising the targets and nudges this learner should keep in view.",
|
||||
rows: [
|
||||
{ label: "Current goal", value: `Lift ${weakestTopic?.label?.toLowerCase() ?? "focus work"} by 10%` },
|
||||
{ label: "Live assignments", value: `${pendingAssignments.length}` },
|
||||
{ label: "Study time", value: formatHours(totalDurationSeconds) },
|
||||
{ label: "Next due", value: formatDueDate(currentAssignment.assignment.due_date) },
|
||||
],
|
||||
primaryLabel: "Open today’s plan",
|
||||
primaryHref: `/assignment/${currentAssignment.assignment.id}/work`,
|
||||
secondaryLabel: "Go to dashboard",
|
||||
secondaryHref: "/dashboard",
|
||||
},
|
||||
];
|
||||
|
||||
const mapAssignmentFocusItem = (assignment: StudentAssignment): AssignmentFocusItem => {
|
||||
const isSubmitted = assignment.status === "SUBMITTED";
|
||||
const isInProgress = assignment.status === "IN_PROGRESS";
|
||||
|
||||
return {
|
||||
title: assignment.assignment.name,
|
||||
meta: assignment.assignment.topic,
|
||||
subMeta: isSubmitted
|
||||
? `Marked ${assignment.total_marks}/${assignment.assignment.maximum_marks} · Reviewed ${assignment.answerCount} questions`
|
||||
: `Due ${formatDueDate(assignment.assignment.due_date)} · ${assignment.answerCount} questions touched so far`,
|
||||
statusLabel: isSubmitted ? "Completed" : isInProgress ? "In progress" : "Ready to start",
|
||||
progressText: isSubmitted ? `${assignment.accuracy}% accuracy` : `${daysUntil(assignment.assignment.due_date)}d left`,
|
||||
tone: isSubmitted ? "teal" : isInProgress ? "blue" : "yellow",
|
||||
primaryLabel: isSubmitted ? "Open review" : isInProgress ? "Resume work" : "Start assignment",
|
||||
primaryHref: isSubmitted ? `/assignment/${assignment.assignment.id}` : `/assignment/${assignment.assignment.id}/work`,
|
||||
secondaryLabel: isSubmitted ? "Open workspace" : "See review",
|
||||
secondaryHref: isSubmitted ? `/assignment/${assignment.assignment.id}/work` : `/assignment/${assignment.assignment.id}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const assignmentFocusGroups: AssignmentFocusGroup[] = [
|
||||
{
|
||||
title: "Continue now",
|
||||
description: "Assignments that already have momentum and are best finished next.",
|
||||
items: studentAssignments.filter((assignment) => assignment.status === "IN_PROGRESS").map(mapAssignmentFocusItem),
|
||||
},
|
||||
{
|
||||
title: "Coming up",
|
||||
description: "Work that is live but not started yet, so you can choose what to begin early.",
|
||||
items: studentAssignments.filter((assignment) => assignment.status === "NOT_STARTED").map(mapAssignmentFocusItem),
|
||||
},
|
||||
{
|
||||
title: "Completed",
|
||||
description: "Marked work you can revisit for review, correction, and confidence boosts.",
|
||||
items: studentAssignments.filter((assignment) => assignment.status === "SUBMITTED").map(mapAssignmentFocusItem),
|
||||
},
|
||||
].filter((group) => group.items.length > 0);
|
||||
|
||||
export const activitySummary = {
|
||||
title: "Recent results",
|
||||
note: `${submittedAssignments.length} assignments have been marked so far. Use this as a quick check on what is improving and what still needs another pass.`,
|
||||
badge: `${submittedAssignments.length} marked`,
|
||||
};
|
||||
|
||||
export const progressPoints = submittedTrend.map((point) => point.value);
|
||||
export const progressLabels = submittedTrend.map((point) => point.label);
|
||||
|
||||
export const highlightCards: HighlightCard[] = [
|
||||
{
|
||||
value: `${studentAnswers.length}`,
|
||||
label: "Questions answered",
|
||||
note: `You have already submitted ${submittedAssignments.length} assignments and still have ${pendingAssignments.length} live tasks to work through.`,
|
||||
tone: "yellow",
|
||||
},
|
||||
{
|
||||
value: formatHours(totalDurationSeconds),
|
||||
label: "Time invested",
|
||||
note: `Last active ${formatLastSeen(latestActivityTime)}. ${activePersona.recommendation}`,
|
||||
tone: "pink",
|
||||
},
|
||||
];
|
||||
|
||||
export const studentSupportList: StudentSupportCard[] = [
|
||||
{
|
||||
name: `Review ${weakestTopic?.label ?? "your focus topic"}`,
|
||||
meta: `${weakestTopic?.value ?? 0}% accuracy across ${weakestTopic?.total ?? 0} questions. A short refresher here should give you the fastest win.`,
|
||||
initials: weakestTopic?.label.slice(0, 2).toUpperCase() ?? "WK",
|
||||
actionLabel: "Start practice",
|
||||
href: `/assignment/${currentAssignment.assignment.id}/work`,
|
||||
},
|
||||
{
|
||||
name: `Finish ${currentAssignment.assignment.name.split("—")[0].trim()}`,
|
||||
meta:
|
||||
currentAssignment.status === "IN_PROGRESS"
|
||||
? `${currentAssignment.answerCount} questions are already done. One more focused block should move this over the line.`
|
||||
: `This is your next live assignment. Starting early will make it feel much lighter later in the week.`,
|
||||
initials: currentAssignment.assignment.name.replace("HW", "H").split("—")[0].trim(),
|
||||
actionLabel: currentAssignment.status === "IN_PROGRESS" ? "Resume" : "Start",
|
||||
href: `/assignment/${currentAssignment.assignment.id}/work`,
|
||||
},
|
||||
{
|
||||
name: "Try one question solo",
|
||||
meta: topSolveMode
|
||||
? `${topSolveMode.label} is your default mode. Try one independent attempt before asking for help on the next tricky question.`
|
||||
: "Try guided support on tougher questions, then return to independent practice.",
|
||||
initials: "IP",
|
||||
actionLabel: "Use this tip",
|
||||
href: `/assignment/${currentAssignment.assignment.id}/work`,
|
||||
},
|
||||
];
|
||||
|
||||
export { topicMasteryBars };
|
||||
|
||||
export const overallPassRate = Math.round(overallAccuracy);
|
||||
|
||||
export { solveModeUsage, usageSummary };
|
||||
@@ -0,0 +1,232 @@
|
||||
// Path: Frontend/src/components/dashboard/messages/dashboard-messages.data.ts
|
||||
|
||||
import { apiFetchJson } from "../../../lib/api";
|
||||
import type { ApiListResponse } from "../../../lib/api-types";
|
||||
import type { AppRole } from "../../../lib/routes";
|
||||
import { getDashboardMessageThreadHref } from "../../../lib/routes";
|
||||
import type { TopbarMessageItem } from "../shared/dashboard-types";
|
||||
|
||||
type MessageRecipient = {
|
||||
id: number;
|
||||
email: string;
|
||||
role: "student" | "teacher";
|
||||
full_name: string;
|
||||
preferred_name: string | null;
|
||||
profile_icon_url: string | null;
|
||||
headline: string | null;
|
||||
};
|
||||
|
||||
export type MessageParticipant = {
|
||||
id: number;
|
||||
email: string;
|
||||
role: "student" | "teacher";
|
||||
full_name: string;
|
||||
preferred_name: string | null;
|
||||
profile_icon_url: string | null;
|
||||
headline: string | null;
|
||||
joined_at: string | null;
|
||||
last_read_at: string | null;
|
||||
archived_at: string | null;
|
||||
};
|
||||
|
||||
type MessageSender = {
|
||||
id: number;
|
||||
email: string;
|
||||
role: string;
|
||||
full_name: string;
|
||||
preferred_name: string | null;
|
||||
profile_icon_url: string | null;
|
||||
headline?: string | null;
|
||||
};
|
||||
|
||||
type MessageItem = {
|
||||
id: number;
|
||||
thread_id: number;
|
||||
body: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
mine: boolean;
|
||||
sender: MessageSender;
|
||||
};
|
||||
|
||||
type MessageThreadSummary = {
|
||||
id: number;
|
||||
subject: string;
|
||||
created_by_user_id: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
unread_count: number;
|
||||
last_message_id: number;
|
||||
last_message_body: string | null;
|
||||
last_message_created_at: string | null;
|
||||
last_message_sender?: MessageSender | null;
|
||||
participants: MessageParticipant[];
|
||||
};
|
||||
|
||||
type MessageThreadDetail = {
|
||||
id: number;
|
||||
subject: string;
|
||||
created_by_user_id: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
unread_count: number;
|
||||
last_read_at: string | null;
|
||||
participants: MessageParticipant[];
|
||||
messages: MessageItem[];
|
||||
};
|
||||
|
||||
type DashboardMessagesInboxData = {
|
||||
stats: Array<{ label: string; value: string }>;
|
||||
threads: MessageThreadSummary[];
|
||||
recipients: MessageRecipient[];
|
||||
};
|
||||
|
||||
type DashboardTopbarMessagesData = {
|
||||
messageCount: number;
|
||||
unreadThreadCount: number;
|
||||
topbarMessages: TopbarMessageItem[];
|
||||
};
|
||||
|
||||
export const getDashboardMessagesInboxData = async (_currentUserId: number): Promise<DashboardMessagesInboxData> => {
|
||||
const [threadsResponse, recipientsResponse] = await Promise.all([
|
||||
apiFetchJson<ApiListResponse<MessageThreadSummary>>("/api/messages/threads", { parseErrorMessage: true }),
|
||||
apiFetchJson<ApiListResponse<MessageRecipient>>("/api/messages/recipients", { parseErrorMessage: true }),
|
||||
]);
|
||||
|
||||
const unreadThreads = threadsResponse.data.filter((thread) => thread.unread_count > 0).length;
|
||||
const unreadMessages = threadsResponse.data.reduce((sum, thread) => sum + thread.unread_count, 0);
|
||||
|
||||
return {
|
||||
stats: [
|
||||
{ label: "Open threads", value: String(threadsResponse.data.length) },
|
||||
{ label: "Unread threads", value: String(unreadThreads) },
|
||||
{ label: "Unread messages", value: String(unreadMessages) },
|
||||
{ label: "Available contacts", value: String(recipientsResponse.data.length) },
|
||||
],
|
||||
threads: threadsResponse.data,
|
||||
recipients: recipientsResponse.data,
|
||||
};
|
||||
};
|
||||
|
||||
export const getDashboardTopbarMessagesData = async (currentUserId: number, role: AppRole): Promise<DashboardTopbarMessagesData> => {
|
||||
const threadsResponse = await apiFetchJson<ApiListResponse<MessageThreadSummary>>("/api/messages/threads", { parseErrorMessage: true });
|
||||
const unreadThreadCount = threadsResponse.data.filter((thread) => thread.unread_count > 0).length;
|
||||
const unreadMessageCount = threadsResponse.data.reduce((sum, thread) => sum + thread.unread_count, 0);
|
||||
|
||||
return {
|
||||
messageCount: unreadThreadCount,
|
||||
unreadThreadCount,
|
||||
topbarMessages: threadsResponse.data.slice(0, 5).map((thread) => {
|
||||
const peer = thread.participants.find((participant) => participant.id !== currentUserId) ?? thread.participants[0];
|
||||
const sender = peer ? displayName(peer) : thread.subject;
|
||||
const preview = thread.last_message_body?.trim() || `Subject: ${thread.subject}`;
|
||||
|
||||
return {
|
||||
sender,
|
||||
initials: initialsFor(peer ?? { full_name: sender }),
|
||||
preview,
|
||||
timestamp: formatShortTimestamp(thread.last_message_created_at ?? thread.updated_at),
|
||||
actionHref: getDashboardMessageThreadHref(role, thread.id),
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const getMessageThreadDetail = (threadId: number) => apiFetchJson<MessageThreadDetail>(`/api/messages/threads/${threadId}`, { parseErrorMessage: true });
|
||||
|
||||
export const createMessageThread = (input: { subject: string; recipientId: number }) =>
|
||||
apiFetchJson<{ thread_id: number }>("/api/messages/threads", {
|
||||
method: "POST",
|
||||
parseErrorMessage: true,
|
||||
body: JSON.stringify({
|
||||
subject: input.subject,
|
||||
recipient_ids: [input.recipientId],
|
||||
}),
|
||||
});
|
||||
|
||||
export const createThreadReply = (threadId: number, input: { body: string }) =>
|
||||
apiFetchJson<{ status: string }>(`/api/messages/threads/${threadId}/messages`, {
|
||||
method: "POST",
|
||||
parseErrorMessage: true,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
export const updateThreadSubject = (threadId: number, input: { subject: string }) =>
|
||||
apiFetchJson<{ status: string }>(`/api/messages/threads/${threadId}`, {
|
||||
method: "PATCH",
|
||||
parseErrorMessage: true,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
export const deleteThread = (threadId: number) =>
|
||||
apiFetchJson<{ status: string }>(`/api/messages/threads/${threadId}`, {
|
||||
method: "DELETE",
|
||||
parseErrorMessage: true,
|
||||
});
|
||||
|
||||
export const updateThreadMessage = (threadId: number, messageId: number, input: { body: string }) =>
|
||||
apiFetchJson<{ status: string }>(`/api/messages/threads/${threadId}/messages/${messageId}`, {
|
||||
method: "PATCH",
|
||||
parseErrorMessage: true,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
export const deleteThreadMessage = (threadId: number, messageId: number) =>
|
||||
apiFetchJson<{ status: string }>(`/api/messages/threads/${threadId}/messages/${messageId}`, {
|
||||
method: "DELETE",
|
||||
parseErrorMessage: true,
|
||||
});
|
||||
|
||||
export const markThreadRead = (threadId: number) =>
|
||||
apiFetchJson<{ status: string }>(`/api/messages/threads/${threadId}/read`, {
|
||||
method: "PATCH",
|
||||
parseErrorMessage: true,
|
||||
});
|
||||
|
||||
export const displayName = (person: { preferred_name?: string | null; full_name: string }) => person.preferred_name?.trim() || person.full_name;
|
||||
|
||||
export const initialsFor = (person: { preferred_name?: string | null; full_name: string }) => {
|
||||
const value = displayName(person).trim();
|
||||
if (!value) return "U";
|
||||
|
||||
const parts = value.split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
|
||||
return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase();
|
||||
};
|
||||
|
||||
export const roleLabel = (role: string) => (role === "teacher" ? "Teacher" : role === "student" ? "Student" : role);
|
||||
|
||||
export const formatShortTimestamp = (value: string | null) => {
|
||||
if (!value) return "Just now";
|
||||
|
||||
const date = new Date(value);
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffHours < 1) return "Now";
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export const formatMessageTimestamp = (value: string | null) => {
|
||||
if (!value) return "Just now";
|
||||
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
export const wasMessageEdited = (createdAt: string | null, updatedAt: string | null) => {
|
||||
if (!createdAt || !updatedAt) return false;
|
||||
return new Date(updatedAt).getTime() - new Date(createdAt).getTime() > 1000;
|
||||
};
|
||||
@@ -0,0 +1,551 @@
|
||||
/* Path: Frontend/src/components/dashboard/messages/dashboard-messages.module.scss */
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@include respond(desktop-sm) {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: var(--radius-3xl);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.panelEyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.workspaceGrid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
> * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@include respond(desktop-lg) {
|
||||
grid-template-columns: minmax(21rem, 26rem) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.workspaceGridMailboxCollapsed {
|
||||
@include respond(desktop-lg) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.leftRail {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leftRailCollapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.railHeader,
|
||||
.threadPanelHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.railToggle {
|
||||
display: inline-flex;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--border-soft);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: var(--radius-full);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.toggleButtonActive {
|
||||
background: var(--surface-panel);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.form,
|
||||
.replyForm {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
|
||||
span {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.textInput,
|
||||
.textArea,
|
||||
.selectInput,
|
||||
.searchField input {
|
||||
width: 100%;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-panel-strong);
|
||||
box-shadow: var(--focus-ring-primary-soft-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
.textArea {
|
||||
resize: vertical;
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.searchField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
|
||||
input {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
}
|
||||
|
||||
.inboxTools {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.resultsNote,
|
||||
.composeNote,
|
||||
.emptyState,
|
||||
.emptyThreadState,
|
||||
.preview,
|
||||
.messageBody {
|
||||
font-size: 0.93rem;
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.composeNote {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.feedbackMessage {
|
||||
padding: 0.8rem 0.95rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.88rem;
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.feedbackError {
|
||||
background: var(--surface-warning-tint);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.feedbackSuccess {
|
||||
background: var(--surface-success-tint);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: var(--radius-full);
|
||||
border: none;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
|
||||
color: var(--action-primary-text);
|
||||
box-shadow: var(--action-primary-shadow);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--action-primary-shadow-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.72rem 0.95rem;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-panel);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dangerAction {
|
||||
composes: secondaryAction;
|
||||
border-color: var(--border-danger-soft);
|
||||
color: var(--danger);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-danger-strong);
|
||||
background: var(--surface-danger-tint);
|
||||
}
|
||||
}
|
||||
|
||||
.threadList,
|
||||
.messageStream {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.threadButton {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 0.95rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-soft);
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
.threadButtonActive {
|
||||
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-strong));
|
||||
background: color-mix(in srgb, var(--surface-accent-soft) 55%, var(--surface-panel));
|
||||
}
|
||||
|
||||
.threadButtonTop,
|
||||
.messageMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.messageMetaAside {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.3rem;
|
||||
text-align: right;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.senderWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
|
||||
strong,
|
||||
h2 {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0.18rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.threadCopy {
|
||||
min-width: 0;
|
||||
|
||||
strong,
|
||||
p {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-accent-soft);
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.threadMeta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.3rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.unreadBadge,
|
||||
.statusNote {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.3rem 0.62rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
background: var(--surface-info-emphasis);
|
||||
color: var(--text-info-strong);
|
||||
}
|
||||
|
||||
.statusNote {
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview {
|
||||
line-height: 1.45;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.threadPanel {
|
||||
min-height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.threadHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inlineEditForm {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
width: min(100%, 32rem);
|
||||
}
|
||||
|
||||
.inlineEditActions,
|
||||
.messageActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.messageActions {
|
||||
justify-content: flex-end;
|
||||
opacity: 0.72;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.messageBubble:hover .messageActions,
|
||||
.messageBubble:focus-within .messageActions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.messageActions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.messageActionButton {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
}
|
||||
|
||||
.messageActionDanger {
|
||||
color: var(--text-danger-muted);
|
||||
|
||||
&:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.emptyThreadState {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--border-soft);
|
||||
background: color-mix(in srgb, var(--surface-soft) 75%, transparent);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.messageStream {
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.messageRow {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.messageRowMine {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.messageBubble {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
width: min(100%, 44rem);
|
||||
padding: 0.95rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.messageBubbleMine {
|
||||
background: color-mix(in srgb, var(--surface-accent-soft) 60%, var(--surface-panel));
|
||||
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-strong));
|
||||
}
|
||||
|
||||
.editedNote {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.replyActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 0.75rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Path: Frontend/src/components/dashboard/messages/dashboard-messages.page.tsx
|
||||
|
||||
import type { Component } from "solid-js";
|
||||
import { createEffect, createMemo, createResource, createSignal } from "solid-js";
|
||||
import { createMessageThread, createThreadReply, deleteThread, deleteThreadMessage, getDashboardMessagesInboxData, getMessageThreadDetail, markThreadRead, updateThreadMessage, updateThreadSubject } from "./dashboard-messages.data";
|
||||
import styles from "./dashboard-messages.module.scss";
|
||||
import { DashboardMessagesMailbox, DashboardMessagesThreadPanel } from "./dashboard-messages.sections";
|
||||
|
||||
type DashboardMessagesPageProps = {
|
||||
currentUserId: number;
|
||||
initialThreadId?: number | null;
|
||||
};
|
||||
|
||||
const DashboardMessagesPage: Component<DashboardMessagesPageProps> = (props) => {
|
||||
const [inboxData, { refetch: refetchInbox }] = createResource(() => props.currentUserId, getDashboardMessagesInboxData);
|
||||
const [selectedThreadId, setSelectedThreadId] = createSignal<number | null>(props.initialThreadId ?? null);
|
||||
const [lastSyncedInitialThreadId, setLastSyncedInitialThreadId] = createSignal<number | null>(null);
|
||||
const [threadDetail, { refetch: refetchThreadDetail }] = createResource(selectedThreadId, getMessageThreadDetail);
|
||||
const [railMode, setRailMode] = createSignal<"inbox" | "compose">("inbox");
|
||||
const [mailboxCollapsed, setMailboxCollapsed] = createSignal(false);
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
const [recipientId, setRecipientId] = createSignal("");
|
||||
const [subject, setSubject] = createSignal("");
|
||||
const [threadSubjectDraft, setThreadSubjectDraft] = createSignal("");
|
||||
const [replyBody, setReplyBody] = createSignal("");
|
||||
const [editingThreadTitle, setEditingThreadTitle] = createSignal(false);
|
||||
const [editingMessageId, setEditingMessageId] = createSignal<number | null>(null);
|
||||
const [editingMessageBody, setEditingMessageBody] = createSignal("");
|
||||
const [composeError, setComposeError] = createSignal<string | null>(null);
|
||||
const [composeSuccess, setComposeSuccess] = createSignal<string | null>(null);
|
||||
const [replyError, setReplyError] = createSignal<string | null>(null);
|
||||
const [replySuccess, setReplySuccess] = createSignal<string | null>(null);
|
||||
const [threadActionError, setThreadActionError] = createSignal<string | null>(null);
|
||||
const [threadActionSuccess, setThreadActionSuccess] = createSignal<string | null>(null);
|
||||
const [messageActionError, setMessageActionError] = createSignal<string | null>(null);
|
||||
const [messageActionSuccess, setMessageActionSuccess] = createSignal<string | null>(null);
|
||||
const [creatingThread, setCreatingThread] = createSignal(false);
|
||||
const [sendingReply, setSendingReply] = createSignal(false);
|
||||
const [savingThreadTitle, setSavingThreadTitle] = createSignal(false);
|
||||
const [deletingThreadId, setDeletingThreadId] = createSignal<number | null>(null);
|
||||
const [savingMessageId, setSavingMessageId] = createSignal<number | null>(null);
|
||||
const [deletingMessageId, setDeletingMessageId] = createSignal<number | null>(null);
|
||||
const [markingThreadId, setMarkingThreadId] = createSignal<number | null>(null);
|
||||
|
||||
const threads = createMemo(() => inboxData()?.threads ?? []);
|
||||
const recipients = createMemo(() => inboxData()?.recipients ?? []);
|
||||
const normalizedSearch = createMemo(() => searchQuery().trim().toLowerCase());
|
||||
const filteredThreads = createMemo(() => {
|
||||
const query = normalizedSearch();
|
||||
if (!query) return threads();
|
||||
|
||||
return threads().filter((thread) => {
|
||||
const haystack = [thread.subject, thread.participants.map((participant) => participant.full_name).join(" "), thread.last_message_body ?? ""].join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
});
|
||||
const selectedThread = createMemo(() => threadDetail() ?? null);
|
||||
const threadCanManage = createMemo(() => selectedThread()?.created_by_user_id === props.currentUserId);
|
||||
|
||||
createEffect(() => {
|
||||
const availableThreads = threads();
|
||||
const requestedThreadId = props.initialThreadId ?? null;
|
||||
const selected = selectedThreadId();
|
||||
const lastSynced = lastSyncedInitialThreadId();
|
||||
|
||||
if (!availableThreads.length) {
|
||||
if (selected !== null) setSelectedThreadId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestedThreadId !== null && requestedThreadId !== lastSynced && availableThreads.some((thread) => thread.id === requestedThreadId)) {
|
||||
setSelectedThreadId(requestedThreadId);
|
||||
setLastSyncedInitialThreadId(requestedThreadId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestedThreadId === null && lastSynced !== null) {
|
||||
setLastSyncedInitialThreadId(null);
|
||||
}
|
||||
|
||||
if (selected === null || !availableThreads.some((thread) => thread.id === selected)) {
|
||||
setSelectedThreadId(availableThreads[0]!.id);
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const detail = selectedThread();
|
||||
if (!detail || detail.unread_count <= 0) return;
|
||||
if (markingThreadId() === detail.id) return;
|
||||
|
||||
setMarkingThreadId(detail.id);
|
||||
void markThreadRead(detail.id)
|
||||
.then(async () => {
|
||||
await Promise.all([refetchInbox(), refetchThreadDetail()]);
|
||||
})
|
||||
.catch(() => {
|
||||
// silent; the detail pane still renders
|
||||
})
|
||||
.finally(() => {
|
||||
setMarkingThreadId((current) => (current === detail.id ? null : current));
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const detail = selectedThread();
|
||||
setThreadSubjectDraft(detail?.subject ?? "");
|
||||
setEditingThreadTitle(false);
|
||||
setEditingMessageId(null);
|
||||
setEditingMessageBody("");
|
||||
setThreadActionError(null);
|
||||
setThreadActionSuccess(null);
|
||||
setMessageActionError(null);
|
||||
setMessageActionSuccess(null);
|
||||
});
|
||||
|
||||
const submitNewThread = async (event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
setComposeError(null);
|
||||
setComposeSuccess(null);
|
||||
|
||||
const nextRecipientId = Number(recipientId());
|
||||
if (!nextRecipientId || !subject().trim()) {
|
||||
setComposeError("Pick a contact and add a subject to start the thread.");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingThread(true);
|
||||
try {
|
||||
const created = await createMessageThread({ recipientId: nextRecipientId, subject: subject().trim() });
|
||||
setRecipientId("");
|
||||
setSubject("");
|
||||
setComposeSuccess("Thread started. Open it to send the first message.");
|
||||
setRailMode("inbox");
|
||||
await refetchInbox();
|
||||
setSelectedThreadId(created.thread_id);
|
||||
await refetchThreadDetail();
|
||||
} catch (error) {
|
||||
setComposeError((error as Error).message || "We could not start that conversation right now.");
|
||||
} finally {
|
||||
setCreatingThread(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitReply = async (event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
setReplyError(null);
|
||||
setReplySuccess(null);
|
||||
|
||||
const threadId = selectedThreadId();
|
||||
if (!threadId || !replyBody().trim()) {
|
||||
setReplyError("Write a message before sending your reply.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingReply(true);
|
||||
try {
|
||||
await createThreadReply(threadId, { body: replyBody().trim() });
|
||||
setReplyBody("");
|
||||
setReplySuccess("Reply sent.");
|
||||
await Promise.all([refetchInbox(), refetchThreadDetail()]);
|
||||
} catch (error) {
|
||||
setReplyError((error as Error).message || "We could not send your reply right now.");
|
||||
} finally {
|
||||
setSendingReply(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitThreadTitle = async (event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
const thread = selectedThread();
|
||||
if (!thread) return;
|
||||
|
||||
const nextSubject = threadSubjectDraft().trim();
|
||||
setThreadActionError(null);
|
||||
setThreadActionSuccess(null);
|
||||
|
||||
if (!nextSubject) {
|
||||
setThreadActionError("Add a title before saving the conversation subject.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingThreadTitle(true);
|
||||
try {
|
||||
await updateThreadSubject(thread.id, { subject: nextSubject });
|
||||
setEditingThreadTitle(false);
|
||||
setThreadActionSuccess("Conversation title updated.");
|
||||
await Promise.all([refetchInbox(), refetchThreadDetail()]);
|
||||
} catch (error) {
|
||||
setThreadActionError((error as Error).message || "We could not update the conversation title right now.");
|
||||
} finally {
|
||||
setSavingThreadTitle(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeThread = async () => {
|
||||
const thread = selectedThread();
|
||||
if (!thread) return;
|
||||
|
||||
const confirmed = globalThis.confirm?.(`Delete the conversation “${thread.subject}” for everyone? This cannot be undone.`) ?? false;
|
||||
if (!confirmed) return;
|
||||
|
||||
setThreadActionError(null);
|
||||
setThreadActionSuccess(null);
|
||||
setDeletingThreadId(thread.id);
|
||||
try {
|
||||
await deleteThread(thread.id);
|
||||
const refreshed = await refetchInbox();
|
||||
const nextThreads = refreshed?.threads ?? [];
|
||||
setSelectedThreadId(nextThreads[0]?.id ?? null);
|
||||
} catch (error) {
|
||||
setThreadActionError((error as Error).message || "We could not delete this conversation right now.");
|
||||
} finally {
|
||||
setDeletingThreadId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const beginMessageEdit = (messageId: number, body: string) => {
|
||||
setEditingMessageId(messageId);
|
||||
setEditingMessageBody(body);
|
||||
setMessageActionError(null);
|
||||
setMessageActionSuccess(null);
|
||||
};
|
||||
|
||||
const cancelMessageEdit = () => {
|
||||
setEditingMessageId(null);
|
||||
setEditingMessageBody("");
|
||||
};
|
||||
|
||||
const submitMessageEdit = async (event: SubmitEvent, messageId: number) => {
|
||||
event.preventDefault();
|
||||
const threadId = selectedThreadId();
|
||||
if (!threadId) return;
|
||||
|
||||
const nextBody = editingMessageBody().trim();
|
||||
setMessageActionError(null);
|
||||
setMessageActionSuccess(null);
|
||||
|
||||
if (!nextBody) {
|
||||
setMessageActionError("Write something before saving your message.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingMessageId(messageId);
|
||||
try {
|
||||
await updateThreadMessage(threadId, messageId, { body: nextBody });
|
||||
setEditingMessageId(null);
|
||||
setEditingMessageBody("");
|
||||
setMessageActionSuccess("Message updated.");
|
||||
await Promise.all([refetchInbox(), refetchThreadDetail()]);
|
||||
} catch (error) {
|
||||
setMessageActionError((error as Error).message || "We could not update that message right now.");
|
||||
} finally {
|
||||
setSavingMessageId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMessage = async (messageId: number) => {
|
||||
const threadId = selectedThreadId();
|
||||
if (!threadId) return;
|
||||
|
||||
const confirmed = globalThis.confirm?.("Delete this message? This cannot be undone.") ?? false;
|
||||
if (!confirmed) return;
|
||||
|
||||
setMessageActionError(null);
|
||||
setMessageActionSuccess(null);
|
||||
setDeletingMessageId(messageId);
|
||||
try {
|
||||
await deleteThreadMessage(threadId, messageId);
|
||||
if (editingMessageId() === messageId) {
|
||||
setEditingMessageId(null);
|
||||
setEditingMessageBody("");
|
||||
}
|
||||
setMessageActionSuccess("Message deleted.");
|
||||
await Promise.all([refetchInbox(), refetchThreadDetail()]);
|
||||
} catch (error) {
|
||||
setMessageActionError((error as Error).message || "We could not delete that message right now.");
|
||||
} finally {
|
||||
setDeletingMessageId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<div classList={{ [styles.workspaceGrid]: true, [styles.workspaceGridMailboxCollapsed]: mailboxCollapsed() }}>
|
||||
<DashboardMessagesMailbox
|
||||
railMode={railMode()}
|
||||
recipients={recipients()}
|
||||
threads={threads()}
|
||||
filteredThreads={filteredThreads()}
|
||||
selectedThreadId={selectedThreadId()}
|
||||
currentUserId={props.currentUserId}
|
||||
searchQuery={searchQuery()}
|
||||
recipientId={recipientId()}
|
||||
subject={subject()}
|
||||
composeError={composeError()}
|
||||
composeSuccess={composeSuccess()}
|
||||
creatingThread={creatingThread()}
|
||||
inboxLoading={Boolean(inboxData.loading)}
|
||||
inboxError={Boolean(inboxData.error)}
|
||||
onSetRailMode={setRailMode}
|
||||
onRecipientInput={setRecipientId}
|
||||
onSubjectInput={setSubject}
|
||||
onSearchInput={setSearchQuery}
|
||||
onSelectThread={setSelectedThreadId}
|
||||
onSubmitNewThread={submitNewThread}
|
||||
/>
|
||||
|
||||
<DashboardMessagesThreadPanel
|
||||
selectedThreadId={selectedThreadId()}
|
||||
threadLoading={Boolean(threadDetail.loading)}
|
||||
threadError={Boolean(threadDetail.error)}
|
||||
thread={selectedThread()}
|
||||
currentUserId={props.currentUserId}
|
||||
editingThreadTitle={editingThreadTitle()}
|
||||
threadSubjectDraft={threadSubjectDraft()}
|
||||
threadActionError={threadActionError()}
|
||||
threadActionSuccess={threadActionSuccess()}
|
||||
messageActionError={messageActionError() || replyError()}
|
||||
messageActionSuccess={messageActionSuccess() || replySuccess()}
|
||||
replyBody={replyBody()}
|
||||
threadCanManage={Boolean(threadCanManage())}
|
||||
mailboxCollapsed={mailboxCollapsed()}
|
||||
markingThreadId={markingThreadId()}
|
||||
deletingThreadId={deletingThreadId()}
|
||||
savingThreadTitle={savingThreadTitle()}
|
||||
editingMessageId={editingMessageId()}
|
||||
editingMessageBody={editingMessageBody()}
|
||||
savingMessageId={savingMessageId()}
|
||||
deletingMessageId={deletingMessageId()}
|
||||
sendingReply={sendingReply()}
|
||||
onThreadSubjectDraftInput={setThreadSubjectDraft}
|
||||
onReplyBodyInput={setReplyBody}
|
||||
onEditingMessageBodyInput={setEditingMessageBody}
|
||||
onToggleMailbox={() => setMailboxCollapsed((value) => !value)}
|
||||
onBeginEditThreadTitle={() => setEditingThreadTitle(true)}
|
||||
onCancelEditThreadTitle={() => {
|
||||
setEditingThreadTitle(false);
|
||||
setThreadSubjectDraft(selectedThread()?.subject ?? "");
|
||||
}}
|
||||
onSubmitThreadTitle={submitThreadTitle}
|
||||
onRemoveThread={removeThread}
|
||||
onBeginMessageEdit={beginMessageEdit}
|
||||
onCancelMessageEdit={cancelMessageEdit}
|
||||
onSubmitMessageEdit={submitMessageEdit}
|
||||
onRemoveMessage={removeMessage}
|
||||
onSubmitReply={submitReply}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardMessagesPage;
|
||||
@@ -0,0 +1,315 @@
|
||||
// Path: Frontend/src/components/dashboard/messages/dashboard-messages.sections.tsx
|
||||
|
||||
import type { Component } from "solid-js";
|
||||
import { For, Show } from "solid-js";
|
||||
import { displayName, formatMessageTimestamp, formatShortTimestamp, getDashboardMessagesInboxData, getMessageThreadDetail, initialsFor, roleLabel, wasMessageEdited, type MessageParticipant } from "./dashboard-messages.data";
|
||||
import styles from "./dashboard-messages.module.scss";
|
||||
|
||||
type InboxData = Awaited<ReturnType<typeof getDashboardMessagesInboxData>>;
|
||||
type InboxThread = InboxData["threads"][number];
|
||||
type InboxRecipient = InboxData["recipients"][number];
|
||||
type ThreadDetail = Awaited<ReturnType<typeof getMessageThreadDetail>>;
|
||||
type ThreadMessage = ThreadDetail["messages"][number];
|
||||
|
||||
export const otherParticipants = (participants: MessageParticipant[], currentUserId: number) => participants.filter((participant) => participant.id !== currentUserId);
|
||||
|
||||
export const participantSummary = (participants: MessageParticipant[], currentUserId: number) => {
|
||||
const others = otherParticipants(participants, currentUserId);
|
||||
if (!others.length) return "Just you";
|
||||
return others.map((participant) => `${displayName(participant)} · ${roleLabel(participant.role)}`).join(" • ");
|
||||
};
|
||||
|
||||
type DashboardMessagesMailboxProps = {
|
||||
railMode: "inbox" | "compose";
|
||||
recipients: InboxRecipient[];
|
||||
threads: InboxThread[];
|
||||
filteredThreads: InboxThread[];
|
||||
selectedThreadId: number | null;
|
||||
currentUserId: number;
|
||||
searchQuery: string;
|
||||
recipientId: string;
|
||||
subject: string;
|
||||
composeError: string | null;
|
||||
composeSuccess: string | null;
|
||||
creatingThread: boolean;
|
||||
inboxLoading: boolean;
|
||||
inboxError: boolean;
|
||||
onSetRailMode: (mode: "inbox" | "compose") => void;
|
||||
onRecipientInput: (value: string) => void;
|
||||
onSubjectInput: (value: string) => void;
|
||||
onSearchInput: (value: string) => void;
|
||||
onSelectThread: (threadId: number) => void;
|
||||
onSubmitNewThread: (event: SubmitEvent) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const DashboardMessagesMailbox: Component<DashboardMessagesMailboxProps> = (props) => (
|
||||
<div class={styles.leftRail}>
|
||||
<article class={styles.panel}>
|
||||
<div class={styles.railHeader}>
|
||||
<div>
|
||||
<p class={styles.panelEyebrow}>Mailbox</p>
|
||||
<h2>{props.railMode === "inbox" ? "Inbox" : "Start a thread"}</h2>
|
||||
</div>
|
||||
<div class={styles.railToggle}>
|
||||
<button type="button" classList={{ [styles.toggleButton]: true, [styles.toggleButtonActive]: props.railMode === "inbox" }} onClick={() => props.onSetRailMode("inbox")}>
|
||||
Inbox
|
||||
</button>
|
||||
<button type="button" classList={{ [styles.toggleButton]: true, [styles.toggleButtonActive]: props.railMode === "compose" }} onClick={() => props.onSetRailMode("compose")}>
|
||||
New thread
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={props.railMode === "inbox"}
|
||||
fallback={
|
||||
<form class={styles.form} onSubmit={(event) => void props.onSubmitNewThread(event)}>
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>Contact</span>
|
||||
<select class={styles.selectInput} value={props.recipientId} onInput={(event) => props.onRecipientInput(event.currentTarget.value)}>
|
||||
<option value="">Choose a contact</option>
|
||||
<For each={props.recipients}>
|
||||
{(recipient) => (
|
||||
<option value={String(recipient.id)}>
|
||||
{displayName(recipient)} · {roleLabel(recipient.role)}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>Subject</span>
|
||||
<input class={styles.textInput} type="text" value={props.subject} placeholder="Homework check-in" onInput={(event) => props.onSubjectInput(event.currentTarget.value)} />
|
||||
</label>
|
||||
|
||||
<Show when={props.composeError}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackError}`}>{message()}</p>}</Show>
|
||||
<Show when={props.composeSuccess}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackSuccess}`}>{message()}</p>}</Show>
|
||||
|
||||
<p class={styles.composeNote}>The thread opens right away after creation, so you can send the first message from the conversation pane.</p>
|
||||
|
||||
<button class={styles.primaryAction} type="submit" disabled={props.creatingThread || props.recipients.length === 0}>
|
||||
{props.creatingThread ? "Starting thread..." : "Start thread"}
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
>
|
||||
<div class={styles.inboxTools}>
|
||||
<label class={styles.searchField}>
|
||||
<span class={styles.searchIcon} aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="6" />
|
||||
<path d="M20 20l-4.2-4.2" />
|
||||
</svg>
|
||||
</span>
|
||||
<input type="search" value={props.searchQuery} placeholder="Search subjects, contacts, or previews" onInput={(event) => props.onSearchInput(event.currentTarget.value)} />
|
||||
</label>
|
||||
<p class={styles.resultsNote}>
|
||||
{props.filteredThreads.length} of {props.threads.length} thread{props.threads.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={!props.inboxLoading} fallback={<div class={styles.emptyState}>Loading your inbox…</div>}>
|
||||
<Show when={!props.inboxError} fallback={<div class={styles.emptyState}>We could not load your threads right now.</div>}>
|
||||
<Show when={props.threads.length} fallback={<div class={styles.emptyState}>No conversations yet. Use New thread to start one.</div>}>
|
||||
<Show when={props.filteredThreads.length} fallback={<div class={styles.emptyState}>No threads match that search yet.</div>}>
|
||||
<div class={styles.threadList}>
|
||||
<For each={props.filteredThreads}>
|
||||
{(thread) => {
|
||||
const peers = () => otherParticipants(thread.participants, props.currentUserId);
|
||||
return (
|
||||
<button type="button" classList={{ [styles.threadButton]: true, [styles.threadButtonActive]: props.selectedThreadId === thread.id }} onClick={() => props.onSelectThread(thread.id)}>
|
||||
<div class={styles.threadButtonTop}>
|
||||
<div class={styles.senderWrap}>
|
||||
<span class={styles.avatar}>{initialsFor(peers()[0] ?? { full_name: thread.subject })}</span>
|
||||
<div class={styles.threadCopy}>
|
||||
<strong>{thread.subject}</strong>
|
||||
<p>{participantSummary(thread.participants, props.currentUserId)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class={styles.threadMeta}>
|
||||
<small>{formatShortTimestamp(thread.last_message_created_at ?? thread.updated_at)}</small>
|
||||
<Show when={thread.unread_count > 0}>
|
||||
<span class={styles.unreadBadge}>{thread.unread_count}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<p class={styles.preview}>{thread.last_message_body?.trim() || "No messages yet. Open this thread to send the first one."}</p>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
|
||||
type DashboardMessagesThreadPanelProps = {
|
||||
selectedThreadId: number | null;
|
||||
threadLoading: boolean;
|
||||
threadError: boolean;
|
||||
thread: ThreadDetail | null;
|
||||
currentUserId: number;
|
||||
editingThreadTitle: boolean;
|
||||
threadSubjectDraft: string;
|
||||
threadActionError: string | null;
|
||||
threadActionSuccess: string | null;
|
||||
messageActionError: string | null;
|
||||
messageActionSuccess: string | null;
|
||||
replyBody: string;
|
||||
threadCanManage: boolean;
|
||||
mailboxCollapsed: boolean;
|
||||
markingThreadId: number | null;
|
||||
deletingThreadId: number | null;
|
||||
savingThreadTitle: boolean;
|
||||
editingMessageId: number | null;
|
||||
editingMessageBody: string;
|
||||
savingMessageId: number | null;
|
||||
deletingMessageId: number | null;
|
||||
sendingReply: boolean;
|
||||
onThreadSubjectDraftInput: (value: string) => void;
|
||||
onReplyBodyInput: (value: string) => void;
|
||||
onEditingMessageBodyInput: (value: string) => void;
|
||||
onToggleMailbox: () => void;
|
||||
onBeginEditThreadTitle: () => void;
|
||||
onCancelEditThreadTitle: () => void;
|
||||
onSubmitThreadTitle: (event: SubmitEvent) => Promise<void> | void;
|
||||
onRemoveThread: () => Promise<void> | void;
|
||||
onBeginMessageEdit: (messageId: number, body: string) => void;
|
||||
onCancelMessageEdit: () => void;
|
||||
onSubmitMessageEdit: (event: SubmitEvent, messageId: number) => Promise<void> | void;
|
||||
onRemoveMessage: (messageId: number) => Promise<void> | void;
|
||||
onSubmitReply: (event: SubmitEvent) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const DashboardMessagesThreadPanel: Component<DashboardMessagesThreadPanelProps> = (props) => (
|
||||
<article class={`${styles.panel} ${styles.threadPanel}`}>
|
||||
<Show when={props.selectedThreadId} fallback={<div class={styles.emptyState}>Pick a thread or start a new conversation to open the message panel.</div>}>
|
||||
<Show when={!props.threadLoading} fallback={<div class={styles.emptyState}>Loading this conversation…</div>}>
|
||||
<Show when={!props.threadError && props.thread} keyed fallback={<div class={styles.emptyState}>We could not load this conversation right now.</div>}>
|
||||
{(thread) => (
|
||||
<>
|
||||
<div class={styles.threadPanelHeader}>
|
||||
<div>
|
||||
<p class={styles.panelEyebrow}>Conversation</p>
|
||||
<Show when={props.editingThreadTitle} fallback={<h2>{thread.subject}</h2>}>
|
||||
<form class={styles.inlineEditForm} onSubmit={(event) => void props.onSubmitThreadTitle(event)}>
|
||||
<input class={styles.textInput} type="text" value={props.threadSubjectDraft} onInput={(event) => props.onThreadSubjectDraftInput(event.currentTarget.value)} />
|
||||
<div class={styles.inlineEditActions}>
|
||||
<button class={styles.primaryAction} type="submit" disabled={props.savingThreadTitle}>
|
||||
{props.savingThreadTitle ? "Saving..." : "Save title"}
|
||||
</button>
|
||||
<button class={styles.secondaryAction} type="button" onClick={props.onCancelEditThreadTitle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Show>
|
||||
<p>{participantSummary(thread.participants, props.currentUserId)}</p>
|
||||
<Show when={props.threadActionError}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackError}`}>{message()}</p>}</Show>
|
||||
<Show when={props.threadActionSuccess}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackSuccess}`}>{message()}</p>}</Show>
|
||||
</div>
|
||||
<div class={styles.threadHeaderActions}>
|
||||
<Show when={props.threadCanManage && !props.editingThreadTitle}>
|
||||
<button type="button" class={styles.secondaryAction} onClick={props.onBeginEditThreadTitle}>
|
||||
Edit title
|
||||
</button>
|
||||
<button type="button" class={styles.dangerAction} onClick={() => void props.onRemoveThread()} disabled={props.deletingThreadId === thread.id}>
|
||||
{props.deletingThreadId === thread.id ? "Deleting..." : "Delete conversation"}
|
||||
</button>
|
||||
</Show>
|
||||
<button type="button" class={styles.secondaryAction} onClick={props.onToggleMailbox}>
|
||||
{props.mailboxCollapsed ? "Show mailbox" : "Collapse mailbox"}
|
||||
</button>
|
||||
<Show when={props.markingThreadId === thread.id}>
|
||||
<span class={styles.statusNote}>Marking as read…</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={thread.messages.length} fallback={<div class={styles.emptyThreadState}>No messages yet. Send the first reply below to get this thread moving.</div>}>
|
||||
<div class={styles.messageStream}>
|
||||
<For each={thread.messages}>
|
||||
{(message: ThreadMessage) => (
|
||||
<div classList={{ [styles.messageRow]: true, [styles.messageRowMine]: message.mine }}>
|
||||
<div classList={{ [styles.messageBubble]: true, [styles.messageBubbleMine]: message.mine }}>
|
||||
<div class={styles.messageMeta}>
|
||||
<div class={styles.senderWrap}>
|
||||
<span class={styles.avatar}>{initialsFor(message.sender)}</span>
|
||||
<div>
|
||||
<strong>{displayName(message.sender)}</strong>
|
||||
<p>{roleLabel(message.sender.role)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class={styles.messageMetaAside}>
|
||||
<small>{formatMessageTimestamp(message.created_at)}</small>
|
||||
<Show when={wasMessageEdited(message.created_at, message.updated_at)}>
|
||||
<span class={styles.editedNote}>Edited</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.editingMessageId === message.id} fallback={<p class={styles.messageBody}>{message.body}</p>}>
|
||||
<form class={styles.inlineEditForm} onSubmit={(event) => void props.onSubmitMessageEdit(event, message.id)}>
|
||||
<textarea class={styles.textArea} rows={4} value={props.editingMessageBody} onInput={(event) => props.onEditingMessageBodyInput(event.currentTarget.value)} />
|
||||
<div class={styles.inlineEditActions}>
|
||||
<button class={styles.primaryAction} type="submit" disabled={props.savingMessageId === message.id}>
|
||||
{props.savingMessageId === message.id ? "Saving..." : "Save message"}
|
||||
</button>
|
||||
<button class={styles.secondaryAction} type="button" onClick={props.onCancelMessageEdit}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Show>
|
||||
<Show when={message.mine && props.editingMessageId !== message.id}>
|
||||
<div class={styles.messageActions}>
|
||||
<button type="button" class={styles.messageActionButton} onClick={() => props.onBeginMessageEdit(message.id, message.body)}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`${styles.messageActionButton} ${styles.messageActionDanger}`}
|
||||
onClick={() => void props.onRemoveMessage(message.id)}
|
||||
disabled={props.deletingMessageId === message.id}
|
||||
>
|
||||
{props.deletingMessageId === message.id ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.messageActionError}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackError}`}>{message()}</p>}</Show>
|
||||
<Show when={props.messageActionSuccess}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackSuccess}`}>{message()}</p>}</Show>
|
||||
|
||||
<form class={styles.replyForm} onSubmit={(event) => void props.onSubmitReply(event)}>
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>Reply</span>
|
||||
<textarea class={styles.textArea} rows={4} value={props.replyBody} placeholder="Write your reply..." onInput={(event) => props.onReplyBodyInput(event.currentTarget.value)} />
|
||||
</label>
|
||||
|
||||
<Show when={props.messageActionError}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackError}`}>{message()}</p>}</Show>
|
||||
<Show when={props.messageActionSuccess}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackSuccess}`}>{message()}</p>}</Show>
|
||||
|
||||
<div class={styles.replyActions}>
|
||||
<button class={styles.primaryAction} type="submit" disabled={props.sendingReply}>
|
||||
{props.sendingReply ? "Sending..." : "Send reply"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</article>
|
||||
);
|
||||
@@ -1,8 +1,10 @@
|
||||
/* Path: Frontend/src/components/dashboard/settings/dashboard-settings-focus.module.scss */
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@include respond(desktop-sm) {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -11,12 +13,12 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.15rem;
|
||||
border-radius: 1.5rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
|
||||
@media (min-width: 880px) {
|
||||
@include respond(wide) {
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(14rem, 0.8fr);
|
||||
align-items: start;
|
||||
padding: 1.35rem;
|
||||
@@ -52,18 +54,24 @@
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1.15rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--border-soft);
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
> div {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,13 +80,19 @@
|
||||
place-items: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
|
||||
color: var(--action-primary-text);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatarImage {
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.statGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -88,7 +102,7 @@
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 880px) {
|
||||
@include respond(wide) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -97,13 +111,14 @@
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.95rem;
|
||||
border-radius: 1.15rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--border-soft);
|
||||
|
||||
strong {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
span {
|
||||
@@ -126,7 +141,7 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.15rem;
|
||||
border-radius: 1.35rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -164,7 +179,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--border-soft);
|
||||
|
||||
@@ -176,28 +191,130 @@
|
||||
strong {
|
||||
font-size: 0.92rem;
|
||||
text-align: right;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.panelActions {
|
||||
@media (max-width: 640px) {
|
||||
.rowItem {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rowItem strong {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
|
||||
@include respond(phablet) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
|
||||
span {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.fieldLabelFull {
|
||||
@include respond(phablet) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.textInput,
|
||||
.textArea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-panel-strong);
|
||||
box-shadow: var(--focus-ring-primary-soft-shadow);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.textArea {
|
||||
resize: vertical;
|
||||
min-height: 6.5rem;
|
||||
}
|
||||
|
||||
.formActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.formActions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.formActions > * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.feedbackMessage {
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.88rem;
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.feedbackError {
|
||||
background: var(--surface-warning-tint);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.feedbackSuccess {
|
||||
background: var(--surface-success-tint);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
background-color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
@@ -209,6 +326,13 @@
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--action-primary-shadow-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
transform: none;
|
||||
box-shadow: var(--action-primary-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
@@ -0,0 +1,255 @@
|
||||
// Path: Frontend/src/components/dashboard/settings/dashboard-settings-focus.tsx
|
||||
|
||||
import { createEffect, createMemo, createSignal, For, Show, type Component } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { useAuth, type UpdateProfileInput } from "~/context/auth/context";
|
||||
import styles from "./dashboard-settings-focus.module.scss";
|
||||
|
||||
const emptyForm: UpdateProfileInput = {
|
||||
fullName: "",
|
||||
preferredName: "",
|
||||
profileIconUrl: "",
|
||||
headline: "",
|
||||
bio: "",
|
||||
timezone: "",
|
||||
locale: "",
|
||||
gradeLevel: "",
|
||||
learningGoal: "",
|
||||
};
|
||||
|
||||
const initialsFor = (value: string) => {
|
||||
const cleaned = value.trim();
|
||||
if (!cleaned) return "U";
|
||||
|
||||
const parts = cleaned.split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
|
||||
return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase();
|
||||
};
|
||||
|
||||
const roleLabel = (role?: "student" | "teacher") => {
|
||||
if (role === "teacher") return "Teacher";
|
||||
if (role === "student") return "Student";
|
||||
return "Member";
|
||||
};
|
||||
|
||||
const formatJoinedDate = (value?: string) => {
|
||||
if (!value) return "Recently";
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "Recently";
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const DashboardSettingsFocus: Component = () => {
|
||||
const auth = useAuth();
|
||||
const [form, setForm] = createStore<UpdateProfileInput>(emptyForm);
|
||||
const [isSaving, setIsSaving] = createSignal(false);
|
||||
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = createSignal<string | null>(null);
|
||||
|
||||
createEffect(() => {
|
||||
const user = auth.user();
|
||||
if (!user) return;
|
||||
|
||||
setForm({
|
||||
fullName: user.full_name ?? "",
|
||||
preferredName: user.profile.preferred_name ?? "",
|
||||
profileIconUrl: user.profile.profile_icon_url ?? "",
|
||||
headline: user.profile.headline ?? "",
|
||||
bio: user.profile.bio ?? "",
|
||||
timezone: user.profile.timezone ?? "",
|
||||
locale: user.profile.locale ?? "",
|
||||
gradeLevel: user.profile.grade_level ?? "",
|
||||
learningGoal: user.profile.learning_goal ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
const displayName = createMemo(() => form.preferredName.trim() || form.fullName.trim() || "Your profile");
|
||||
const displaySubtitle = createMemo(() => form.headline.trim() || `${roleLabel(auth.user()?.role)} account`);
|
||||
const stats = createMemo(() => {
|
||||
const user = auth.user();
|
||||
return [
|
||||
{ label: "Role", value: roleLabel(user?.role) },
|
||||
{ label: "Email", value: user?.email ?? "Not available" },
|
||||
{ label: "Timezone", value: form.timezone.trim() || "Not set" },
|
||||
{ label: "Goal", value: form.learningGoal.trim() || "Add a learning goal" },
|
||||
];
|
||||
});
|
||||
|
||||
const handleInput = (field: keyof UpdateProfileInput) => (event: Event) => {
|
||||
const target = event.currentTarget as HTMLInputElement | HTMLTextAreaElement;
|
||||
setForm(field, target.value);
|
||||
setSuccessMessage(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
try {
|
||||
await auth.updateProfile({ ...form });
|
||||
setSuccessMessage("Profile updated.");
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Unable to save your profile right now.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<article class={styles.heroCard}>
|
||||
<div class={styles.heroCopy}>
|
||||
<p class={styles.eyebrow}>Settings</p>
|
||||
<h1>Your profile and study settings</h1>
|
||||
<p>Keep your preferred name, profile photo, and learning setup in sync so the dashboard feels like your own workspace.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.profileChip}>
|
||||
<Show when={form.profileIconUrl.trim()} fallback={<span class={styles.avatar}>{initialsFor(displayName())}</span>}>
|
||||
{(iconUrl) => <img src={iconUrl()} alt="" class={`${styles.avatar} ${styles.avatarImage}`} />}
|
||||
</Show>
|
||||
<div>
|
||||
<strong>{displayName()}</strong>
|
||||
<span>{displaySubtitle()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.statGrid}>
|
||||
<For each={stats()}>
|
||||
{(stat) => (
|
||||
<div class={styles.statCard}>
|
||||
<strong>{stat.value}</strong>
|
||||
<span>{stat.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<form class={styles.panelGrid} onSubmit={(event) => void handleSubmit(event)}>
|
||||
<article class={styles.panelCard}>
|
||||
<div class={styles.panelBody}>
|
||||
<div class={styles.panelCopy}>
|
||||
<h2>Profile details</h2>
|
||||
<p>These fields shape how your name and identity show up across the dashboard.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.fieldGrid}>
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>Preferred name</span>
|
||||
<input class={styles.textInput} value={form.preferredName} onInput={handleInput("preferredName")} placeholder="What should the dashboard call you?" />
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>Full name</span>
|
||||
<input class={styles.textInput} value={form.fullName} onInput={handleInput("fullName")} placeholder="Your full name" />
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>Headline</span>
|
||||
<input class={styles.textInput} value={form.headline} onInput={handleInput("headline")} placeholder="Short label under your name" />
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>Profile icon URL</span>
|
||||
<input class={styles.textInput} value={form.profileIconUrl} onInput={handleInput("profileIconUrl")} placeholder="https://..." />
|
||||
</label>
|
||||
|
||||
<label class={`${styles.fieldLabel} ${styles.fieldLabelFull}`}>
|
||||
<span>Bio</span>
|
||||
<textarea class={styles.textArea} value={form.bio} onInput={handleInput("bio")} rows={4} placeholder="A short note about you or how you like to work" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class={styles.panelCard}>
|
||||
<div class={styles.panelBody}>
|
||||
<div class={styles.panelCopy}>
|
||||
<h2>Learning setup</h2>
|
||||
<p>Use these profile fields to shape future reminders, dashboards, and classroom context.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.fieldGrid}>
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>Timezone</span>
|
||||
<input class={styles.textInput} value={form.timezone} onInput={handleInput("timezone")} placeholder="Asia/Kuala_Lumpur" />
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>Locale</span>
|
||||
<input class={styles.textInput} value={form.locale} onInput={handleInput("locale")} placeholder="en-MY" />
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>Grade level</span>
|
||||
<input class={styles.textInput} value={form.gradeLevel} onInput={handleInput("gradeLevel")} placeholder="Year 6" />
|
||||
</label>
|
||||
|
||||
<label class={`${styles.fieldLabel} ${styles.fieldLabelFull}`}>
|
||||
<span>Learning goal</span>
|
||||
<textarea class={styles.textArea} value={form.learningGoal} onInput={handleInput("learningGoal")} rows={4} placeholder="What are you working toward right now?" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class={styles.panelCard}>
|
||||
<div class={styles.panelBody}>
|
||||
<div class={styles.panelCopy}>
|
||||
<h2>Account snapshot</h2>
|
||||
<p>Core account details still come from your signed-in user record.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.rowList}>
|
||||
<div class={styles.rowItem}>
|
||||
<span>Email</span>
|
||||
<strong>{auth.user()?.email ?? "Not available"}</strong>
|
||||
</div>
|
||||
<div class={styles.rowItem}>
|
||||
<span>Role</span>
|
||||
<strong>{roleLabel(auth.user()?.role)}</strong>
|
||||
</div>
|
||||
<div class={styles.rowItem}>
|
||||
<span>Status</span>
|
||||
<strong>{auth.user()?.is_active ? "Active" : "Inactive"}</strong>
|
||||
</div>
|
||||
<div class={styles.rowItem}>
|
||||
<span>Member since</span>
|
||||
<strong>{formatJoinedDate(auth.user()?.created_at)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.formActions}>
|
||||
<button type="submit" class={styles.primaryAction} disabled={isSaving()}>
|
||||
{isSaving() ? "Saving…" : "Save profile"}
|
||||
</button>
|
||||
<button type="button" class={styles.secondaryAction} onClick={() => void auth.refresh()}>
|
||||
Refresh from server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={errorMessage()}>
|
||||
<p class={`${styles.feedbackMessage} ${styles.feedbackError}`}>{errorMessage()}</p>
|
||||
</Show>
|
||||
|
||||
<Show when={successMessage()}>
|
||||
<p class={`${styles.feedbackMessage} ${styles.feedbackSuccess}`}>{successMessage()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSettingsFocus;
|
||||
@@ -1,13 +1,21 @@
|
||||
import { createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from "solid-js";
|
||||
// Path: Frontend/src/components/dashboard/shared/dashboard-shell.tsx
|
||||
|
||||
import { useLocation } from "@solidjs/router";
|
||||
import { Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from "solid-js";
|
||||
import { Transition } from "solid-transition-group";
|
||||
import styles from "../../../routes/dashboard/dashboard.module.scss";
|
||||
import DashboardSidebar from "./dashboard-sidebar";
|
||||
import DashboardTopbar from "./dashboard-topbar";
|
||||
import styles from "../../routes/dashboard/dashboard.module.scss";
|
||||
import type { DashboardHomeShellData } from "./dashboard-types";
|
||||
|
||||
type DashboardShellProps = {
|
||||
children: JSX.Element;
|
||||
sidebarData?: Pick<DashboardHomeShellData, "classroomSummary" | "sidebarLinks" | "sidebarSupport">;
|
||||
topbarData?: Pick<DashboardHomeShellData, "topbarSummary" | "topbarNotifications" | "topbarMessages">;
|
||||
};
|
||||
|
||||
const DashboardShell: Component<DashboardShellProps> = (props) => {
|
||||
const location = useLocation();
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false);
|
||||
const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = createSignal(false);
|
||||
|
||||
@@ -48,13 +56,7 @@ const DashboardShell: Component<DashboardShellProps> = (props) => {
|
||||
<main class={styles.dashboardPage}>
|
||||
<div classList={{ [styles.dashboardLayout]: true, [styles.dashboardLayoutSidebarCollapsed]: desktopSidebarCollapsed() }}>
|
||||
<div class={styles.sidebarRail}>
|
||||
<button
|
||||
type="button"
|
||||
classList={{ [styles.sidebarBackdrop]: true, [styles.sidebarBackdropVisible]: sidebarOpen() }}
|
||||
aria-label="Close sidebar"
|
||||
tabIndex={sidebarOpen() ? 0 : -1}
|
||||
onClick={closeSidebar}
|
||||
/>
|
||||
<button type="button" classList={{ [styles.sidebarBackdrop]: true, [styles.sidebarBackdropVisible]: sidebarOpen() }} aria-label="Close sidebar" tabIndex={sidebarOpen() ? 0 : -1} onClick={closeSidebar} />
|
||||
|
||||
<div
|
||||
classList={{
|
||||
@@ -64,14 +66,24 @@ const DashboardShell: Component<DashboardShellProps> = (props) => {
|
||||
}}
|
||||
>
|
||||
<div class={styles.sidebarPanelInner}>
|
||||
<DashboardSidebar onNavigate={closeSidebar} />
|
||||
<DashboardSidebar onNavigate={closeSidebar} data={props.sidebarData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class={styles.dashboardMain}>
|
||||
<DashboardTopbar isSidebarOpen={isSidebarVisible()} onMenuToggle={toggleSidebar} />
|
||||
{props.children}
|
||||
<DashboardTopbar isSidebarOpen={isSidebarVisible()} onMenuToggle={toggleSidebar} data={props.topbarData} />
|
||||
<div class={styles.dashboardContentShell}>
|
||||
<Transition name="dashboard-content-fade">
|
||||
<Show when={location.pathname} keyed>
|
||||
{(path) => (
|
||||
<div class={styles.dashboardContentStage} data-route={path}>
|
||||
{props.children}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
@@ -1,17 +1,21 @@
|
||||
/* Path: Frontend/src/components/dashboard/shared/dashboard-sidebar.module.scss */
|
||||
|
||||
.sidebar {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 1.5rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
box-shadow: var(--shadow-soft);
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@include respond(mobile) {
|
||||
padding: 1.15rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@include respond(tablet) {
|
||||
gap: 1.25rem;
|
||||
padding: 1.3rem;
|
||||
position: sticky;
|
||||
@@ -20,26 +24,15 @@
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.logoMark {
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 0.9rem;
|
||||
background: linear-gradient(135deg, var(--action-primary-start), var(--info));
|
||||
color: var(--action-primary-text);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--action-primary-shadow);
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
.brandLogo {
|
||||
width: min(100%, 8.5rem);
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brandMeta {
|
||||
@@ -51,19 +44,11 @@
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
|
||||
@media (max-width: 519px) {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 0.55rem;
|
||||
padding-bottom: 0.1rem;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
@include respond-max(mobile-narrow) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 520px) and (max-width: 1023px) {
|
||||
@media (min-width: #{$mobile-narrow + 1px}) and (max-width: #{$tablet - 1px}) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -73,7 +58,7 @@
|
||||
align-items: flex-start;
|
||||
gap: 0.8rem;
|
||||
padding: 0.78rem 0.85rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
@@ -88,15 +73,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 519px) {
|
||||
@include respond-max(mobile-narrow) {
|
||||
.link {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 0.82rem;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,16 +103,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 519px) {
|
||||
@include respond-max(mobile-narrow) {
|
||||
.linkCopy {
|
||||
gap: 0;
|
||||
gap: 0.08rem;
|
||||
|
||||
strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
small {
|
||||
display: none;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,10 +155,11 @@
|
||||
padding: 1rem;
|
||||
background: linear-gradient(180deg, var(--surface-panel-strong), var(--surface-soft));
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 1.25rem;
|
||||
border-radius: var(--radius-xl);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 519px) {
|
||||
@include respond-max(mobile-narrow) {
|
||||
.supportCard {
|
||||
gap: 0.7rem;
|
||||
padding: 0.9rem;
|
||||
@@ -194,7 +179,7 @@
|
||||
height: 2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid var(--surface-panel);
|
||||
background: var(--surface-accent-soft);
|
||||
color: var(--primary);
|
||||
@@ -222,15 +207,20 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.8rem 1rem;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
|
||||
color: var(--action-primary-text);
|
||||
box-shadow: var(--action-primary-shadow);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
@@ -0,0 +1,64 @@
|
||||
// Path: Frontend/src/components/dashboard/shared/dashboard-sidebar.tsx
|
||||
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import styles from "./dashboard-sidebar.module.scss";
|
||||
import type { DashboardHomeShellData } from "./dashboard-types";
|
||||
import { classroomSummary, sidebarLinks, sidebarSupport } from "./dashboard.data";
|
||||
|
||||
type DashboardSidebarProps = {
|
||||
onNavigate?: () => void;
|
||||
data?: Pick<DashboardHomeShellData, "classroomSummary" | "sidebarLinks" | "sidebarSupport">;
|
||||
};
|
||||
|
||||
const DashboardSidebar: Component<DashboardSidebarProps> = (props) => {
|
||||
const location = useLocation();
|
||||
const summary = () => props.data?.classroomSummary ?? classroomSummary;
|
||||
const links = () => props.data?.sidebarLinks ?? sidebarLinks;
|
||||
const support = () => props.data?.sidebarSupport ?? sidebarSupport;
|
||||
|
||||
const isActiveLink = (href?: string) => {
|
||||
if (!href) return false;
|
||||
if (location.pathname === href) return true;
|
||||
const isHomeLink = links().some((link) => link.href === href && link.active);
|
||||
if (isHomeLink) return false;
|
||||
return location.pathname === href || location.pathname.startsWith(`${href}/`);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside class={styles.sidebar}>
|
||||
<div class={styles.brand}>
|
||||
<img class={styles.brandLogo} src="/brand/boost-ai-logo-purple.png" alt="Boost AI" />
|
||||
<p class={styles.brandMeta}>{summary().name}</p>
|
||||
</div>
|
||||
|
||||
<nav class={styles.navigation} aria-label="Dashboard navigation">
|
||||
<For each={links()}>
|
||||
{(link) => (
|
||||
<A href={link.href ?? "#"} classList={{ [styles.link]: true, [styles.active]: isActiveLink(link.href) || (!!link.active && !!link.href && location.pathname === link.href) }} onClick={() => props.onNavigate?.()}>
|
||||
<span class={styles.iconSlot}>{link.icon}</span>
|
||||
<span class={styles.linkCopy}>
|
||||
<strong>{link.label}</strong>
|
||||
{link.detail && <small>{link.detail}</small>}
|
||||
</span>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
|
||||
<div class={styles.supportCard}>
|
||||
<div class={styles.avatarRow}>
|
||||
<For each={support().avatars}>{(avatar) => <span>{avatar}</span>}</For>
|
||||
</div>
|
||||
<h2>{support().title}</h2>
|
||||
<p>{support().description}</p>
|
||||
<A href={support().buttonHref} class={styles.supportButton} onClick={() => props.onNavigate?.()}>
|
||||
{support().buttonLabel}
|
||||
</A>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSidebar;
|
||||
@@ -1,10 +1,12 @@
|
||||
/* Path: Frontend/src/components/dashboard/shared/dashboard-theme-toggle.module.scss */
|
||||
|
||||
.toggleButton {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
padding: 0;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel);
|
||||
color: var(--text-muted);
|
||||
@@ -21,7 +23,7 @@
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@include respond(mobile) {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Path: Frontend/src/components/dashboard/shared/dashboard-theme-toggle.tsx
|
||||
|
||||
import type { Component } from "solid-js";
|
||||
import { useTheme } from "../../context/theme/context";
|
||||
import { useTheme } from "../../../context/theme/context";
|
||||
import styles from "./dashboard-theme-toggle.module.scss";
|
||||
|
||||
const DashboardThemeToggle: Component = () => {
|
||||
@@ -1,3 +1,5 @@
|
||||
/* Path: Frontend/src/components/dashboard/shared/dashboard-topbar.module.scss */
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
@@ -6,23 +8,26 @@
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
|
||||
@media (max-width: 519px) {
|
||||
@include respond-max(mobile-narrow) {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 880px) {
|
||||
@media (max-width: 1023px) {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
@include respond(wide) {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.menuButton,
|
||||
.iconButton {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel);
|
||||
color: var(--text-muted);
|
||||
@@ -67,7 +72,7 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: currentColor;
|
||||
transform-origin: center;
|
||||
transition:
|
||||
@@ -108,7 +113,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@include respond(mobile) {
|
||||
.menuButton,
|
||||
.iconButton {
|
||||
width: 3rem;
|
||||
@@ -121,7 +126,7 @@
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0 0.85rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -137,7 +142,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.searchToggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.searchDropdown {
|
||||
width: min(28rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.searchFieldSheet {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
@include respond(mobile) {
|
||||
.searchField {
|
||||
padding: 0 1rem;
|
||||
|
||||
@@ -171,6 +188,7 @@
|
||||
gap: 0.55rem;
|
||||
grid-column: 1 / -1;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
padding-bottom: 0.15rem;
|
||||
scrollbar-width: none;
|
||||
|
||||
@@ -178,7 +196,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 880px) {
|
||||
@include respond(wide) {
|
||||
grid-column: auto;
|
||||
justify-content: flex-end;
|
||||
overflow: visible;
|
||||
@@ -191,6 +209,43 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mobileMenuBackdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.searchField {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.searchToggle {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-column: auto;
|
||||
justify-content: flex-end;
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.menuGroup {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.mobileMenuBackdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: hsl(0 0% 0% / 0.44);
|
||||
backdrop-filter: blur(3px);
|
||||
z-index: 70;
|
||||
}
|
||||
}
|
||||
|
||||
.countBadge {
|
||||
position: absolute;
|
||||
top: -0.15rem;
|
||||
@@ -200,7 +255,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0 0.2rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--primary);
|
||||
color: var(--action-primary-text);
|
||||
font-size: 0.68rem;
|
||||
@@ -217,7 +272,7 @@
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 0.95rem;
|
||||
border-radius: 1.25rem;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
@@ -225,6 +280,43 @@
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.dropdown {
|
||||
position: fixed;
|
||||
top: calc(env(safe-area-inset-top, 0rem) + 5.6rem);
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
width: auto;
|
||||
max-height: calc(100dvh - env(safe-area-inset-top, 0rem) - 6.6rem);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 1rem;
|
||||
border-radius: max(var(--radius-xl), 1.25rem);
|
||||
box-shadow: 0 24px 64px hsl(220 35% 12% / 0.28);
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.searchDropdown {
|
||||
top: calc(env(safe-area-inset-top, 0rem) + 1rem);
|
||||
}
|
||||
|
||||
.dropdownHeader {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.dropdownList {
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.dropdownItem {
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.searchFieldSheet {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -237,6 +329,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdownEyebrow {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
@@ -245,6 +344,12 @@
|
||||
margin-bottom: 0.12rem;
|
||||
}
|
||||
|
||||
.dropdownMeta {
|
||||
margin-top: 0.22rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdownLink {
|
||||
color: var(--primary);
|
||||
font-size: 0.85rem;
|
||||
@@ -252,18 +357,62 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobileDropdownClose {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.mobileDropdownClose {
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel);
|
||||
color: var(--text-muted);
|
||||
box-shadow: var(--shadow-soft);
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownLink {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownList {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.emptyDropdownState {
|
||||
padding: 0.9rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--border-soft);
|
||||
background: var(--surface-panel);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dropdownItem {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: start;
|
||||
gap: 0.7rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
transition:
|
||||
@@ -285,6 +434,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownButtonReset {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
appearance: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.itemCopy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
@@ -310,7 +468,7 @@
|
||||
.messageAvatar {
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -347,13 +505,17 @@
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.5rem 0.4rem 0.8rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel);
|
||||
box-shadow: var(--shadow-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profileCopy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profileButton {
|
||||
padding: 0;
|
||||
border: none;
|
||||
@@ -402,12 +564,23 @@
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@include respond(mobile) {
|
||||
.profile {
|
||||
padding: 0.45rem 0.55rem 0.45rem 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.profile {
|
||||
padding: 0.4rem 0.55rem;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.profileCopy {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.profileName {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
@@ -420,7 +593,7 @@
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
@media (max-width: 519px) {
|
||||
@include respond-max(mobile-narrow) {
|
||||
.profileRole {
|
||||
display: none;
|
||||
}
|
||||
@@ -431,7 +604,8 @@
|
||||
height: 2.2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-accent-emphasis);
|
||||
color: var(--text-accent-strong);
|
||||
font-weight: 600;
|
||||
@@ -442,10 +616,18 @@
|
||||
height: 2.2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-accent-emphasis);
|
||||
color: var(--text-accent-strong);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profileAvatarImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
286
Frontend/src/components/dashboard/shared/dashboard-topbar.tsx
Normal file
286
Frontend/src/components/dashboard/shared/dashboard-topbar.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
// Path: Frontend/src/components/dashboard/shared/dashboard-topbar.tsx
|
||||
|
||||
import { A } from "@solidjs/router";
|
||||
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type Component } from "solid-js";
|
||||
import { useAuth } from "~/context/auth/context";
|
||||
import { getDashboardMessagesHref, getDashboardSettingsHref } from "../../../lib/routes";
|
||||
import { getDashboardTopbarMessagesData } from "../messages/dashboard-messages.data";
|
||||
import DashboardThemeToggle from "./dashboard-theme-toggle";
|
||||
import styles from "./dashboard-topbar.module.scss";
|
||||
import type { DashboardHomeShellData } from "./dashboard-types";
|
||||
import { topbarMessages, topbarSummary } from "./dashboard.data";
|
||||
|
||||
type DashboardTopbarProps = {
|
||||
isSidebarOpen: boolean;
|
||||
onMenuToggle: () => void;
|
||||
data?: Pick<DashboardHomeShellData, "topbarSummary" | "topbarNotifications" | "topbarMessages">;
|
||||
};
|
||||
|
||||
const initialsFor = (value: string) => {
|
||||
const cleaned = value.trim();
|
||||
if (!cleaned) return "U";
|
||||
|
||||
const parts = cleaned.split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
|
||||
return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase();
|
||||
};
|
||||
|
||||
const roleLabel = (role?: "student" | "teacher") => {
|
||||
if (role === "teacher") return "Teacher";
|
||||
if (role === "student") return "Student";
|
||||
return null;
|
||||
};
|
||||
|
||||
const DashboardTopbar: Component<DashboardTopbarProps> = (props) => {
|
||||
const auth = useAuth();
|
||||
const [openMenu, setOpenMenu] = createSignal<"search" | "messages" | "profile" | null>(null);
|
||||
const currentUser = createMemo(() => (auth.isReady() ? (auth.user() ?? null) : null));
|
||||
const topbarMessagesSource = createMemo(() => {
|
||||
const user = currentUser();
|
||||
if (!user) return null;
|
||||
return { userId: user.id, role: user.role };
|
||||
});
|
||||
const [liveMessages] = createResource(topbarMessagesSource, ({ userId, role }) => getDashboardTopbarMessagesData(userId, role));
|
||||
let menuRoot: HTMLDivElement | undefined;
|
||||
const dashboardRole = createMemo(() => currentUser()?.role ?? "student");
|
||||
const inboxHref = createMemo(() => getDashboardMessagesHref(dashboardRole()));
|
||||
const settingsHref = createMemo(() => getDashboardSettingsHref(dashboardRole()));
|
||||
const summary = () => {
|
||||
const baseSummary = props.data?.topbarSummary ?? topbarSummary;
|
||||
const live = liveMessages();
|
||||
if (!live) return baseSummary;
|
||||
|
||||
return {
|
||||
...baseSummary,
|
||||
messageCount: live.messageCount,
|
||||
};
|
||||
};
|
||||
const messages = () => liveMessages()?.topbarMessages ?? props.data?.topbarMessages ?? topbarMessages;
|
||||
const unreadThreadCount = () => liveMessages()?.unreadThreadCount ?? summary().messageCount;
|
||||
|
||||
const toggleMenu = (menu: "messages" | "profile") => {
|
||||
setOpenMenu((current) => (current === menu ? null : menu));
|
||||
};
|
||||
|
||||
const profileName = () => auth.user()?.profile.preferred_name ?? auth.user()?.full_name ?? summary().profileName;
|
||||
const profileRole = () => auth.user()?.profile.headline ?? roleLabel(auth.user()?.role) ?? summary().profileRole;
|
||||
const profileBadge = () => initialsFor(profileName() || summary().profileName);
|
||||
const profileIconUrl = () => auth.user()?.profile.profile_icon_url ?? null;
|
||||
let mobileSearchInput: HTMLInputElement | undefined;
|
||||
|
||||
const handleLogout = async () => {
|
||||
await auth.logout();
|
||||
setOpenMenu(null);
|
||||
window.location.href = "/auth/login";
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!menuRoot?.contains(event.target as Node)) {
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
onCleanup(() => document.removeEventListener("pointerdown", handlePointerDown));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (openMenu() !== "search") return;
|
||||
queueMicrotask(() => mobileSearchInput?.focus());
|
||||
});
|
||||
|
||||
return (
|
||||
<header class={styles.topbar}>
|
||||
<button type="button" classList={{ [styles.menuButton]: true, [styles.menuButtonOpen]: props.isSidebarOpen }} aria-label={props.isSidebarOpen ? "Close menu" : "Open menu"} aria-expanded={props.isSidebarOpen} onClick={props.onMenuToggle}>
|
||||
<span class={styles.menuGlyph} aria-hidden="true">
|
||||
<span class={styles.menuLine} />
|
||||
<span class={styles.menuLine} />
|
||||
<span class={styles.menuLine} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<label class={styles.searchField}>
|
||||
<span class={styles.searchIcon} aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="6" />
|
||||
<path d="M20 20l-4.2-4.2" />
|
||||
</svg>
|
||||
</span>
|
||||
<input type="search" placeholder={summary().searchPlaceholder} />
|
||||
</label>
|
||||
|
||||
<div class={styles.actions} ref={menuRoot}>
|
||||
<Show when={openMenu()}>
|
||||
<button type="button" class={styles.mobileMenuBackdrop} aria-label="Close menu" tabIndex={-1} onClick={() => setOpenMenu(null)} />
|
||||
</Show>
|
||||
|
||||
<div class={styles.menuGroup}>
|
||||
<button type="button" classList={{ [styles.iconButton]: true, [styles.searchToggle]: true }} aria-label="Search" aria-expanded={openMenu() === "search"} onClick={() => toggleMenu("search")}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="6" />
|
||||
<path d="M20 20l-4.2-4.2" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Show when={openMenu() === "search"}>
|
||||
<div class={`${styles.dropdown} ${styles.searchDropdown}`}>
|
||||
<div class={styles.dropdownHeader}>
|
||||
<div>
|
||||
<p class={styles.dropdownEyebrow}>Search</p>
|
||||
<strong>Find something quickly</strong>
|
||||
<p class={styles.dropdownMeta}>{summary().searchPlaceholder}</p>
|
||||
</div>
|
||||
<div class={styles.dropdownHeaderActions}>
|
||||
<button type="button" class={styles.mobileDropdownClose} aria-label="Close search" onClick={() => setOpenMenu(null)}>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="M5 5l10 10M15 5 5 15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class={`${styles.searchField} ${styles.searchFieldSheet}`}>
|
||||
<span class={styles.searchIcon} aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="6" />
|
||||
<path d="M20 20l-4.2-4.2" />
|
||||
</svg>
|
||||
</span>
|
||||
<input ref={mobileSearchInput} type="search" placeholder={summary().searchPlaceholder} />
|
||||
</label>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<DashboardThemeToggle />
|
||||
|
||||
<div class={styles.menuGroup}>
|
||||
<button type="button" class={styles.iconButton} aria-label="Messages" aria-expanded={openMenu() === "messages"} onClick={() => toggleMenu("messages")}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 6h16v10H8l-4 3V6Z" />
|
||||
</svg>
|
||||
<Show when={summary().messageCount > 0}>
|
||||
<span class={styles.countBadge}>{summary().messageCount}</span>
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<Show when={openMenu() === "messages"}>
|
||||
<div class={styles.dropdown}>
|
||||
<div class={styles.dropdownHeader}>
|
||||
<div>
|
||||
<p class={styles.dropdownEyebrow}>Inbox</p>
|
||||
<strong>Messages</strong>
|
||||
<p class={styles.dropdownMeta}>
|
||||
{unreadThreadCount()} unread thread{unreadThreadCount() === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
<div class={styles.dropdownHeaderActions}>
|
||||
<A href={inboxHref()} class={styles.dropdownLink} onClick={() => setOpenMenu(null)}>
|
||||
Open inbox
|
||||
</A>
|
||||
<button type="button" class={styles.mobileDropdownClose} aria-label="Close messages menu" onClick={() => setOpenMenu(null)}>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="M5 5l10 10M15 5 5 15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.dropdownList}>
|
||||
<Show when={messages().length} fallback={<div class={styles.emptyDropdownState}>No active threads yet.</div>}>
|
||||
<For each={messages()}>
|
||||
{(item) => (
|
||||
<A href={item.actionHref} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
|
||||
<span class={styles.messageAvatar}>{item.initials}</span>
|
||||
<div class={styles.itemCopy}>
|
||||
<strong>{item.sender}</strong>
|
||||
<p>{item.preview}</p>
|
||||
</div>
|
||||
<small>{item.timestamp}</small>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={styles.menuGroup}>
|
||||
<button type="button" classList={{ [styles.profileButton]: true, [styles.profileButtonOpen]: openMenu() === "profile" }} aria-label="Profile menu" aria-expanded={openMenu() === "profile"} onClick={() => toggleMenu("profile")}>
|
||||
<div class={styles.profile}>
|
||||
<div class={styles.profileCopy}>
|
||||
<p class={styles.profileName}>{profileName()}</p>
|
||||
<p class={styles.profileRole}>{profileRole()}</p>
|
||||
</div>
|
||||
<span class={styles.profileBadge}>
|
||||
<Show when={profileIconUrl()} fallback={profileBadge()}>
|
||||
{(iconUrl) => <img src={iconUrl()} alt="" class={styles.profileAvatarImage} />}
|
||||
</Show>
|
||||
</span>
|
||||
<span class={styles.profileChevron} aria-hidden="true">
|
||||
<svg viewBox="0 0 20 20">
|
||||
<path d="m5 7.5 5 5 5-5" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Show when={openMenu() === "profile"}>
|
||||
<div class={styles.dropdown}>
|
||||
<div class={styles.dropdownHeader}>
|
||||
<div>
|
||||
<p class={styles.dropdownEyebrow}>Signed in as</p>
|
||||
<strong>{profileName()}</strong>
|
||||
<p class={styles.dropdownMeta}>{profileRole()}</p>
|
||||
</div>
|
||||
<div class={styles.dropdownHeaderActions}>
|
||||
<span class={styles.profileMenuBadge}>
|
||||
<Show when={profileIconUrl()} fallback={profileBadge()}>
|
||||
{(iconUrl) => <img src={iconUrl()} alt="" class={styles.profileAvatarImage} />}
|
||||
</Show>
|
||||
</span>
|
||||
<button type="button" class={styles.mobileDropdownClose} aria-label="Close profile menu" onClick={() => setOpenMenu(null)}>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path d="M5 5l10 10M15 5 5 15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.dropdownList}>
|
||||
<A href={settingsHref()} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
|
||||
<span class={`${styles.itemTone} ${styles["tone-blue"]}`} aria-hidden="true" />
|
||||
<div class={styles.itemCopy}>
|
||||
<strong>Profile</strong>
|
||||
<p>Update your learner details and view your profile settings.</p>
|
||||
</div>
|
||||
</A>
|
||||
|
||||
<A href={settingsHref()} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
|
||||
<span class={`${styles.itemTone} ${styles["tone-yellow"]}`} aria-hidden="true" />
|
||||
<div class={styles.itemCopy}>
|
||||
<strong>Settings</strong>
|
||||
<p>Manage goals, preferences, reminders, and dashboard options.</p>
|
||||
</div>
|
||||
</A>
|
||||
|
||||
<button type="button" class={`${styles.dropdownItem} ${styles.dropdownButtonReset}`} onClick={() => void handleLogout()}>
|
||||
<span class={`${styles.itemTone} ${styles["tone-teal"]}`} aria-hidden="true" />
|
||||
<div class={styles.itemCopy}>
|
||||
<strong>Log out</strong>
|
||||
<p>End your current session and return to the sign-in screen.</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTopbar;
|
||||
156
Frontend/src/components/dashboard/shared/dashboard-types.ts
Normal file
156
Frontend/src/components/dashboard/shared/dashboard-types.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// Path: Frontend/src/components/dashboard/shared/dashboard-types.ts
|
||||
|
||||
export type AccentTone = "yellow" | "pink" | "teal" | "blue";
|
||||
type SpotlightTone = "purple" | "yellow" | "blue";
|
||||
export type PerformanceTone = "blue" | "purple" | "teal" | "pink" | "yellow";
|
||||
type UsageTone = "blue" | "purple" | "teal" | "yellow";
|
||||
|
||||
export type SidebarLink = {
|
||||
label: string;
|
||||
detail?: string;
|
||||
icon: string;
|
||||
href?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
type SpotlightStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: SpotlightTone;
|
||||
};
|
||||
|
||||
type QuickStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type AssignmentCard = {
|
||||
title: string;
|
||||
badge: string;
|
||||
lessons: string;
|
||||
accent: AccentTone;
|
||||
cta: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type TopbarNotificationItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
href: string;
|
||||
tone: "blue" | "yellow" | "teal";
|
||||
};
|
||||
|
||||
export type TopbarMessageItem = {
|
||||
sender: string;
|
||||
initials: string;
|
||||
preview: string;
|
||||
timestamp: string;
|
||||
actionHref: string;
|
||||
};
|
||||
|
||||
export type StudentSupportCard = {
|
||||
name: string;
|
||||
meta: string;
|
||||
initials: string;
|
||||
actionLabel: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type HighlightCard = {
|
||||
value: string;
|
||||
label: string;
|
||||
note: string;
|
||||
tone: "yellow" | "pink";
|
||||
};
|
||||
|
||||
export type PerformanceBar = {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: PerformanceTone;
|
||||
};
|
||||
|
||||
export type UsageItem = {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: UsageTone;
|
||||
};
|
||||
|
||||
export type DashboardHomeShellData = {
|
||||
classroomSummary: {
|
||||
name: string;
|
||||
targetLevel?: number;
|
||||
inviteCode?: string;
|
||||
tutorName: string;
|
||||
tutorRole: string;
|
||||
tutorInitials: string;
|
||||
};
|
||||
sidebarLinks: SidebarLink[];
|
||||
sidebarSupport: {
|
||||
avatars: string[];
|
||||
title: string;
|
||||
description: string;
|
||||
buttonLabel: string;
|
||||
buttonHref: string;
|
||||
};
|
||||
topbarSummary: {
|
||||
searchPlaceholder: string;
|
||||
profileName: string;
|
||||
profileRole: string;
|
||||
profileBadge: string;
|
||||
notificationCount: number;
|
||||
messageCount: number;
|
||||
};
|
||||
topbarNotifications: TopbarNotificationItem[];
|
||||
topbarMessages: TopbarMessageItem[];
|
||||
};
|
||||
|
||||
export type DashboardHeroData = {
|
||||
heroSummary: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
visualBadges: string[];
|
||||
};
|
||||
spotlightStats: SpotlightStat[];
|
||||
heroSideCard: {
|
||||
title: string;
|
||||
description: string;
|
||||
buttonLabel: string;
|
||||
buttonHref: string;
|
||||
};
|
||||
quickStats: QuickStat[];
|
||||
};
|
||||
|
||||
export type DashboardActivityData = {
|
||||
activitySummary: {
|
||||
title: string;
|
||||
note: string;
|
||||
badge: string;
|
||||
};
|
||||
progressLabels: string[];
|
||||
progressPoints: number[];
|
||||
highlightCards: HighlightCard[];
|
||||
};
|
||||
|
||||
export type DashboardInsightsData = {
|
||||
masteryTitle: string;
|
||||
overallTitle: string;
|
||||
overallLabel: string;
|
||||
usageTitle: string;
|
||||
overallPassRate: number;
|
||||
topicMasteryBars: PerformanceBar[];
|
||||
solveModeUsage: UsageItem[];
|
||||
usageSummary: {
|
||||
note: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DashboardHomeData = {
|
||||
shell: DashboardHomeShellData;
|
||||
hero: DashboardHeroData;
|
||||
assignmentCards: AssignmentCard[];
|
||||
activity: DashboardActivityData;
|
||||
studentSupportList: StudentSupportCard[];
|
||||
insights: DashboardInsightsData;
|
||||
};
|
||||
210
Frontend/src/components/dashboard/shared/dashboard.data.ts
Normal file
210
Frontend/src/components/dashboard/shared/dashboard.data.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
// Path: Frontend/src/components/dashboard/shared/dashboard.data.ts
|
||||
|
||||
import rawDataset from "../../../../../Mock-Data/dataset.json";
|
||||
import { getAssignmentWorkHref, getDashboardAssignmentsHref, getDashboardHomeHref, getDashboardMessagesHref, getDashboardPracticeHref, getDashboardProgressHref, getDashboardSettingsHref } from "../../../lib/routes";
|
||||
import type { DashboardHomeShellData, TopbarMessageItem } from "./dashboard-types";
|
||||
|
||||
type Dataset = {
|
||||
_meta: {
|
||||
reference_today: string;
|
||||
};
|
||||
classroom: {
|
||||
name: string;
|
||||
invite_code: string;
|
||||
target_level: number;
|
||||
};
|
||||
tutor: {
|
||||
fullname: string;
|
||||
role: string;
|
||||
};
|
||||
students: Array<{
|
||||
id: number;
|
||||
fullname: string;
|
||||
_persona: string;
|
||||
}>;
|
||||
assignments: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
topic: string;
|
||||
due_date: number;
|
||||
}>;
|
||||
assignment_assignees: Array<{
|
||||
id: number;
|
||||
assignment_id: number;
|
||||
student_id: number;
|
||||
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
|
||||
total_marks: number;
|
||||
}>;
|
||||
student_answers: Array<{
|
||||
assignee_id: number;
|
||||
_is_correct: boolean;
|
||||
_question_topic: string;
|
||||
_answered_at: number;
|
||||
}>;
|
||||
activity_logs: Array<{
|
||||
timestamp: number;
|
||||
duration_seconds: number;
|
||||
_student_id: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
type StudentAssignment = Dataset["assignment_assignees"][number] & {
|
||||
assignment: Dataset["assignments"][number];
|
||||
answerCount: number;
|
||||
accuracy: number;
|
||||
};
|
||||
|
||||
const dataset = rawDataset as Dataset;
|
||||
const referenceTime = new Date(`${dataset._meta.reference_today}T00:00:00Z`).getTime();
|
||||
const msPerDay = 1000 * 60 * 60 * 24;
|
||||
const defaultStudentId = 201;
|
||||
|
||||
const initialsFor = (name: string) =>
|
||||
name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
const formatLastSeen = (timestamp: number) => {
|
||||
const dayDiff = Math.max(0, Math.round((referenceTime - timestamp) / msPerDay));
|
||||
if (dayDiff <= 0) return "today";
|
||||
if (dayDiff === 1) return "yesterday";
|
||||
return `${dayDiff} days ago`;
|
||||
};
|
||||
|
||||
const daysUntil = (timestamp: number) => Math.max(0, Math.ceil((timestamp - referenceTime) / msPerDay));
|
||||
|
||||
const formatDueDate = (timestamp: number) =>
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(timestamp));
|
||||
|
||||
const students = dataset.students;
|
||||
const assignments = [...dataset.assignments].sort((left, right) => left.due_date - right.due_date);
|
||||
const assignees = dataset.assignment_assignees;
|
||||
const answers = dataset.student_answers;
|
||||
const activityLogs = dataset.activity_logs;
|
||||
|
||||
const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0];
|
||||
const firstName = student.fullname.split(" ")[0];
|
||||
|
||||
const assignmentById = new Map(assignments.map((assignment) => [assignment.id, assignment]));
|
||||
const assigneeById = new Map(assignees.map((assignee) => [assignee.id, assignee]));
|
||||
|
||||
const studentAnswers = answers.filter((answer) => assigneeById.get(answer.assignee_id)?.student_id === student.id);
|
||||
const studentLogs = activityLogs.filter((log) => log._student_id === student.id);
|
||||
|
||||
const studentAssignments: StudentAssignment[] = assignees
|
||||
.filter((assignee) => assignee.student_id === student.id)
|
||||
.map((assignee) => {
|
||||
const assignment = assignmentById.get(assignee.assignment_id);
|
||||
if (!assignment) throw new Error(`Missing assignment ${assignee.assignment_id}`);
|
||||
|
||||
const assignmentAnswers = studentAnswers.filter((answer) => answer.assignee_id === assignee.id);
|
||||
const answerCount = assignmentAnswers.length;
|
||||
const correct = assignmentAnswers.filter((answer) => answer._is_correct).length;
|
||||
|
||||
return {
|
||||
...assignee,
|
||||
assignment,
|
||||
answerCount,
|
||||
accuracy: answerCount > 0 ? Math.round((correct / answerCount) * 100) : 0,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.assignment.due_date - right.assignment.due_date);
|
||||
|
||||
const totalCorrectAnswers = studentAnswers.filter((answer) => answer._is_correct).length;
|
||||
const overallAccuracy = studentAnswers.length > 0 ? Math.round((totalCorrectAnswers / studentAnswers.length) * 100) : 0;
|
||||
const latestActivityTime = studentLogs.length > 0 ? Math.max(...studentLogs.map((log) => log.timestamp)) : referenceTime;
|
||||
|
||||
const inProgressAssignments = studentAssignments.filter((assignment) => assignment.status === "IN_PROGRESS");
|
||||
const notStartedAssignments = studentAssignments.filter((assignment) => assignment.status === "NOT_STARTED");
|
||||
const pendingAssignments = studentAssignments.filter((assignment) => assignment.status !== "SUBMITTED");
|
||||
const currentAssignment = inProgressAssignments[0] ?? notStartedAssignments[0] ?? studentAssignments[studentAssignments.length - 1];
|
||||
const nextNotStartedAssignment = notStartedAssignments[0] ?? currentAssignment;
|
||||
|
||||
const topicBuckets = new Map<string, { correct: number; total: number }>();
|
||||
for (const answer of studentAnswers) {
|
||||
const bucket = topicBuckets.get(answer._question_topic) ?? { correct: 0, total: 0 };
|
||||
bucket.correct += Number(answer._is_correct);
|
||||
bucket.total += 1;
|
||||
topicBuckets.set(answer._question_topic, bucket);
|
||||
}
|
||||
|
||||
const topicPerformance = [...topicBuckets.entries()].map(([label, bucket]) => ({
|
||||
label,
|
||||
value: Math.round((bucket.correct / bucket.total) * 100),
|
||||
total: bucket.total,
|
||||
}));
|
||||
|
||||
const weakestTopic = [...topicPerformance].sort((left, right) => left.value - right.value || right.total - left.total)[0];
|
||||
|
||||
const assignmentShortName = (assignment: StudentAssignment | undefined) => assignment?.assignment.name.replace("HW", "H").split("—")[0].trim() ?? "Task";
|
||||
|
||||
export const classroomSummary: DashboardHomeShellData["classroomSummary"] = {
|
||||
name: dataset.classroom.name,
|
||||
targetLevel: dataset.classroom.target_level,
|
||||
inviteCode: dataset.classroom.invite_code,
|
||||
tutorName: dataset.tutor.fullname,
|
||||
tutorRole: "Lead tutor",
|
||||
tutorInitials: initialsFor(dataset.tutor.fullname),
|
||||
};
|
||||
|
||||
export const sidebarLinks: DashboardHomeShellData["sidebarLinks"] = [
|
||||
{ label: "Home", detail: "Today", icon: "⌂", href: getDashboardHomeHref("student"), active: true },
|
||||
{ label: "Assignments", detail: `${pendingAssignments.length} live`, icon: "✓", href: getDashboardAssignmentsHref("student") },
|
||||
{ label: "Progress", detail: `${overallAccuracy}% accuracy`, icon: "↗", href: getDashboardProgressHref() },
|
||||
{ label: "Practice", detail: weakestTopic?.label ?? "Mixed skills", icon: "✦", href: getDashboardPracticeHref() },
|
||||
{ label: "Messages", detail: dataset.tutor.fullname.split(" ")[0], icon: "✉", href: getDashboardMessagesHref("student") },
|
||||
{ label: "Settings", detail: "Profile & goals", icon: "⋯", href: getDashboardSettingsHref("student") },
|
||||
];
|
||||
|
||||
export const sidebarSupport: DashboardHomeShellData["sidebarSupport"] = {
|
||||
avatars: [assignmentShortName(currentAssignment), weakestTopic?.label.slice(0, 2).toUpperCase() ?? "WK", assignmentShortName(nextNotStartedAssignment)],
|
||||
title: "Today’s study plan",
|
||||
description: currentAssignment
|
||||
? `Pick up ${assignmentShortName(currentAssignment)}, spend 15 minutes on ${weakestTopic?.label ?? "your focus topic"}, then start ${assignmentShortName(nextNotStartedAssignment)} while it still feels easy.`
|
||||
: "Open your next task and build a short, focused study block around it.",
|
||||
buttonLabel: "Open my plan",
|
||||
buttonHref: currentAssignment ? getAssignmentWorkHref(currentAssignment.assignment.id) : getDashboardAssignmentsHref("student"),
|
||||
};
|
||||
|
||||
export const topbarSummary: DashboardHomeShellData["topbarSummary"] = {
|
||||
searchPlaceholder: "Search assignments, hints, or question topics",
|
||||
profileName: student.fullname,
|
||||
profileRole: `${dataset.classroom.name} · Student`,
|
||||
profileBadge: initialsFor(student.fullname),
|
||||
notificationCount: 0,
|
||||
messageCount: 3,
|
||||
};
|
||||
|
||||
export const topbarMessages: TopbarMessageItem[] = [
|
||||
{
|
||||
sender: dataset.tutor.fullname,
|
||||
initials: initialsFor(dataset.tutor.fullname),
|
||||
preview: `Let’s spend the next session tightening up ${weakestTopic?.label?.toLowerCase() ?? "your focus topic"}. A couple of calm wins here should help a lot.`,
|
||||
timestamp: "Today",
|
||||
actionHref: getDashboardMessagesHref("student"),
|
||||
},
|
||||
{
|
||||
sender: currentAssignment ? assignmentShortName(currentAssignment) : "Assignment check-in",
|
||||
initials: currentAssignment ? assignmentShortName(currentAssignment) : "AC",
|
||||
preview: currentAssignment
|
||||
? currentAssignment.status === "SUBMITTED"
|
||||
? "Nice work finishing this one. Open the review and tighten any missed marks."
|
||||
: `${currentAssignment.answerCount} questions are already done. Finish it before ${formatDueDate(currentAssignment.assignment.due_date)} while it still feels familiar.`
|
||||
: "Open your assignments to pick the next best task.",
|
||||
timestamp: currentAssignment ? (currentAssignment.status === "SUBMITTED" ? "Yesterday" : `${daysUntil(currentAssignment.assignment.due_date)}d left`) : formatLastSeen(latestActivityTime),
|
||||
actionHref: currentAssignment ? getAssignmentWorkHref(currentAssignment.assignment.id) : getDashboardAssignmentsHref("student"),
|
||||
},
|
||||
{
|
||||
sender: "Study coach",
|
||||
initials: "SC",
|
||||
preview: `End today with one easier win after you practise ${weakestTopic?.label?.toLowerCase() ?? "your focus topic"}.`,
|
||||
timestamp: formatLastSeen(latestActivityTime),
|
||||
actionHref: getDashboardProgressHref(),
|
||||
},
|
||||
];
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 519px) {
|
||||
@include respond-max(mobile-narrow) {
|
||||
.header a {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
.progressCard,
|
||||
.highlightCard {
|
||||
border-radius: 1.35rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
@@ -42,11 +42,11 @@
|
||||
padding: 1rem;
|
||||
background: var(--surface-panel);
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@include respond(phablet) {
|
||||
padding: 1.15rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@include respond(desktop-sm) {
|
||||
padding: 1.35rem;
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
.progressBadge {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-info);
|
||||
color: var(--info);
|
||||
font-size: 0.85rem;
|
||||
@@ -117,7 +117,7 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 519px) {
|
||||
@include respond-max(mobile-narrow) {
|
||||
.monthRow {
|
||||
font-size: 0.72rem;
|
||||
gap: 0.25rem;
|
||||
@@ -3,26 +3,27 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { getDashboardProgressHref } from "../../../lib/routes";
|
||||
import styles from "./dashboard-activity.module.scss";
|
||||
import { activitySummary, highlightCards, progressLabels, progressPoints } from "./dashboard.data";
|
||||
import type { DashboardActivityData } from "../shared/dashboard-types";
|
||||
|
||||
const points = progressPoints.map((value, index) => `${index * 54},${120 - value}`).join(" ");
|
||||
const DashboardActivity: Component<DashboardActivityData> = (props) => {
|
||||
const points = () => props.progressPoints.map((value, index) => `${index * 54},${120 - value}`).join(" ");
|
||||
|
||||
const DashboardActivity: Component = () => {
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<div class={styles.header}>
|
||||
<h2>Your progress</h2>
|
||||
<A href="/dashboard/progress">Open progress</A>
|
||||
<A href={getDashboardProgressHref()}>Open progress</A>
|
||||
</div>
|
||||
|
||||
<article class={styles.progressCard}>
|
||||
<div class={styles.progressHeader}>
|
||||
<div>
|
||||
<h3>{activitySummary.title}</h3>
|
||||
<p>{activitySummary.note}</p>
|
||||
<h3>{props.activitySummary.title}</h3>
|
||||
<p>{props.activitySummary.note}</p>
|
||||
</div>
|
||||
<div class={styles.progressBadge}>{activitySummary.badge}</div>
|
||||
<div class={styles.progressBadge}>{props.activitySummary.badge}</div>
|
||||
</div>
|
||||
|
||||
<svg viewBox="0 0 270 140" class={styles.chart} role="img" aria-label="My progress line chart">
|
||||
@@ -34,17 +35,17 @@ const DashboardActivity: Component = () => {
|
||||
</defs>
|
||||
|
||||
<line x1="0" y1="120" x2="270" y2="120" class={styles.axis} />
|
||||
<polyline points={`0,120 ${points} 270,120`} class={styles.area} />
|
||||
<polyline points={points} class={styles.line} />
|
||||
<polyline points={`0,120 ${points()} 270,120`} class={styles.area} />
|
||||
<polyline points={points()} class={styles.line} />
|
||||
</svg>
|
||||
|
||||
<div class={styles.monthRow}>
|
||||
<For each={progressLabels}>{(label) => <span>{label}</span>}</For>
|
||||
<For each={props.progressLabels}>{(label) => <span>{label}</span>}</For>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class={styles.highlightGrid}>
|
||||
<For each={highlightCards}>
|
||||
<For each={props.highlightCards}>
|
||||
{(card) => (
|
||||
<article classList={{ [styles.highlightCard]: true, [styles[card.tone]]: true }}>
|
||||
<strong>{card.value}</strong>
|
||||
@@ -2,7 +2,7 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@include respond(desktop-sm) {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,12 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.15rem;
|
||||
border-radius: 1.5rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
|
||||
@media (min-width: 880px) {
|
||||
@include respond(wide) {
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr);
|
||||
align-items: center;
|
||||
padding: 1.35rem;
|
||||
@@ -56,7 +56,7 @@
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 880px) {
|
||||
@include respond(wide) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.95rem;
|
||||
border-radius: 1.15rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--border-soft);
|
||||
|
||||
@@ -85,6 +85,15 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 1.1rem;
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
color: var(--text-muted);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
@@ -117,7 +126,7 @@
|
||||
min-width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
padding: 0 0.7rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-info-emphasis);
|
||||
color: var(--text-info-strong);
|
||||
font-weight: 600;
|
||||
@@ -137,7 +146,7 @@
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1.3rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -156,7 +165,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.42rem 0.68rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
@@ -218,7 +227,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
@@ -0,0 +1,94 @@
|
||||
// Path: Frontend/src/components/dashboard/dashboard-assignments-focus.tsx
|
||||
|
||||
import { A } from "@solidjs/router";
|
||||
import type { Component } from "solid-js";
|
||||
import { For, Show, createResource, createSignal, onMount } from "solid-js";
|
||||
import { dashboardUiCopy } from "~/content/ui-copy";
|
||||
import { useAuth } from "~/context/auth/context";
|
||||
import styles from "./dashboard-assignments-focus.module.scss";
|
||||
import { getDashboardAssignmentsFocusData } from "./dashboard-assignments.data";
|
||||
|
||||
const DashboardAssignmentsFocus: Component = () => {
|
||||
const auth = useAuth();
|
||||
const [studentId, setStudentId] = createSignal<number | null>(null);
|
||||
const [data] = createResource(studentId, getDashboardAssignmentsFocusData);
|
||||
|
||||
onMount(() => {
|
||||
if (auth.user()?.role === "student") {
|
||||
setStudentId(auth.user()!.id);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<article class={styles.heroCard}>
|
||||
<div class={styles.heroCopy}>
|
||||
<p class={styles.eyebrow}>{dashboardUiCopy.studentAssignmentsFocus.eyebrow}</p>
|
||||
<h1>{dashboardUiCopy.studentAssignmentsFocus.title}</h1>
|
||||
<p>{dashboardUiCopy.studentAssignmentsFocus.description}</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.statGrid}>
|
||||
<For each={data()?.stats ?? []}>
|
||||
{(stat) => (
|
||||
<div class={styles.statCard}>
|
||||
<strong>{stat.value}</strong>
|
||||
<span>{stat.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class={styles.groupList}>
|
||||
<Show when={!data.loading} fallback={<section class={styles.infoCard}>{dashboardUiCopy.studentAssignmentsFocus.loading}</section>}>
|
||||
<Show when={data()?.groups.length} fallback={<section class={styles.infoCard}>{dashboardUiCopy.studentAssignmentsFocus.empty}</section>}>
|
||||
<For each={data()?.groups ?? []}>
|
||||
{(group) => (
|
||||
<section class={styles.group}>
|
||||
<div class={styles.groupHeader}>
|
||||
<div>
|
||||
<h2>{group.title}</h2>
|
||||
<p>{group.description}</p>
|
||||
</div>
|
||||
<span class={styles.groupCount}>{group.items.length}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.cardGrid}>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<article class={styles.assignmentCard}>
|
||||
<div class={styles.cardTop}>
|
||||
<span classList={{ [styles.statusChip]: true, [styles[item.tone]]: true }}>{item.statusLabel}</span>
|
||||
<span class={styles.progressText}>{item.progressText}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.cardBody}>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.meta}</p>
|
||||
<small>{item.subMeta}</small>
|
||||
</div>
|
||||
|
||||
<div class={styles.cardActions}>
|
||||
<A href={item.primaryHref} class={styles.primaryAction}>
|
||||
{item.primaryLabel}
|
||||
</A>
|
||||
<A href={item.secondaryHref} class={styles.secondaryAction}>
|
||||
{item.secondaryLabel}
|
||||
</A>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardAssignmentsFocus;
|
||||
@@ -0,0 +1,169 @@
|
||||
import { apiFetchJson } from "../../../lib/api";
|
||||
import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiListResponse } from "../../../lib/api-types";
|
||||
import { studentDashboardLabels } from "../../../content/dashboard-labels";
|
||||
import { getAssignmentReviewHref, getAssignmentWorkHref } from "../../../lib/routes";
|
||||
|
||||
type AssignmentFocusStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type AssignmentFocusItem = {
|
||||
title: string;
|
||||
meta: string;
|
||||
subMeta: string;
|
||||
statusLabel: string;
|
||||
progressText: string;
|
||||
tone: "yellow" | "teal" | "blue";
|
||||
primaryLabel: string;
|
||||
primaryHref: string;
|
||||
secondaryLabel: string;
|
||||
secondaryHref: string;
|
||||
};
|
||||
|
||||
type AssignmentFocusGroup = {
|
||||
title: string;
|
||||
description: string;
|
||||
items: AssignmentFocusItem[];
|
||||
};
|
||||
|
||||
type DashboardAssignmentsFocusData = {
|
||||
stats: AssignmentFocusStat[];
|
||||
groups: AssignmentFocusGroup[];
|
||||
};
|
||||
|
||||
const formatDueDate = (value: string | null) => {
|
||||
if (!value) return "No due date";
|
||||
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
|
||||
const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/i)?.[1]?.trim();
|
||||
if (fromInstructions) return fromInstructions;
|
||||
return questions[0]?.subject ?? "Assignment";
|
||||
};
|
||||
|
||||
const deriveStatus = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
|
||||
const total = questions.length;
|
||||
const answered = questions.filter((question) => question.answer_id).length;
|
||||
const reviewed = questions.filter((question) => question.answer_status === "reviewed").length;
|
||||
|
||||
if (answered === 0) return "NOT_STARTED" as const;
|
||||
if (reviewed === total || assignment.status === "closed") return "SUBMITTED" as const;
|
||||
return "IN_PROGRESS" as const;
|
||||
};
|
||||
|
||||
const mapAssignmentItem = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]): AssignmentFocusItem => {
|
||||
const derivedStatus = deriveStatus(assignment, questions);
|
||||
const answered = questions.filter((question) => question.answer_id).length;
|
||||
const reviewed = questions.filter((question) => question.answer_status === "reviewed").length;
|
||||
const topic = extractTopic(assignment, questions);
|
||||
|
||||
if (derivedStatus === "SUBMITTED") {
|
||||
return {
|
||||
title: assignment.title,
|
||||
meta: topic,
|
||||
subMeta: `${reviewed}/${questions.length} reviewed · Due ${formatDueDate(assignment.due_at)}`,
|
||||
statusLabel: "Completed",
|
||||
progressText: `${reviewed}/${questions.length} reviewed`,
|
||||
tone: "teal",
|
||||
primaryLabel: "Open review",
|
||||
primaryHref: getAssignmentReviewHref("student", assignment.id),
|
||||
secondaryLabel: "Open workspace",
|
||||
secondaryHref: getAssignmentWorkHref(assignment.id),
|
||||
};
|
||||
}
|
||||
|
||||
if (derivedStatus === "IN_PROGRESS") {
|
||||
return {
|
||||
title: assignment.title,
|
||||
meta: topic,
|
||||
subMeta: `${answered}/${questions.length} answered · Due ${formatDueDate(assignment.due_at)}`,
|
||||
statusLabel: "In progress",
|
||||
progressText: `${answered}/${questions.length} answered`,
|
||||
tone: "blue",
|
||||
primaryLabel: "Resume work",
|
||||
primaryHref: getAssignmentWorkHref(assignment.id),
|
||||
secondaryLabel: "See review",
|
||||
secondaryHref: getAssignmentReviewHref("student", assignment.id),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: assignment.title,
|
||||
meta: topic,
|
||||
subMeta: `Due ${formatDueDate(assignment.due_at)} · Ready when you are`,
|
||||
statusLabel: "Ready to start",
|
||||
progressText: `${questions.length} questions`,
|
||||
tone: "yellow",
|
||||
primaryLabel: "Start assignment",
|
||||
primaryHref: getAssignmentWorkHref(assignment.id),
|
||||
secondaryLabel: "See review",
|
||||
secondaryHref: getAssignmentReviewHref("student", assignment.id),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDashboardAssignmentsFocusData = async (studentId: number): Promise<DashboardAssignmentsFocusData> => {
|
||||
const assignmentsResponse = await apiFetchJson<ApiListResponse<ApiAssignment>>(`/api/students/${studentId}/assignments`);
|
||||
const assignmentDetails = await Promise.all(
|
||||
assignmentsResponse.data.map(async (assignment) => ({
|
||||
assignment,
|
||||
questions: (await apiFetchJson<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/api/assignments/${assignment.id}/students/${studentId}/questions`)).data,
|
||||
})),
|
||||
);
|
||||
|
||||
const items = assignmentDetails.map(({ assignment, questions }) => ({
|
||||
assignment,
|
||||
questions,
|
||||
item: mapAssignmentItem(assignment, questions),
|
||||
derivedStatus: deriveStatus(assignment, questions),
|
||||
}));
|
||||
|
||||
const completed = items.filter((entry) => entry.derivedStatus === "SUBMITTED");
|
||||
const inProgress = items.filter((entry) => entry.derivedStatus === "IN_PROGRESS");
|
||||
const notStarted = items.filter((entry) => entry.derivedStatus === "NOT_STARTED");
|
||||
const liveItems = items.filter((entry) => entry.derivedStatus !== "SUBMITTED");
|
||||
const nextDue = liveItems
|
||||
.map((entry) => entry.assignment.due_at)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort((left, right) => new Date(left).getTime() - new Date(right).getTime())[0] ?? null;
|
||||
const completionPercentages = assignmentDetails.map(({ assignment, questions }) => {
|
||||
const reviewed = questions.filter((question) => question.answer_status === "reviewed").length;
|
||||
if (assignment.status === "closed") return 100;
|
||||
if (questions.length === 0) return 0;
|
||||
return Math.round((reviewed / questions.length) * 100);
|
||||
});
|
||||
const averageCompletion = completionPercentages.length
|
||||
? Math.round(completionPercentages.reduce((sum, value) => sum + value, 0) / completionPercentages.length)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
stats: [
|
||||
{ label: studentDashboardLabels.assignments.liveNow, value: `${inProgress.length + notStarted.length}` },
|
||||
{ label: studentDashboardLabels.assignments.completed, value: `${completed.length}` },
|
||||
{ label: studentDashboardLabels.assignments.averageReviewed, value: `${averageCompletion}%` },
|
||||
{ label: studentDashboardLabels.assignments.nextDue, value: formatDueDate(nextDue) },
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
title: "Continue now",
|
||||
description: "Assignments that already have momentum and are best finished next.",
|
||||
items: inProgress.map((entry) => entry.item),
|
||||
},
|
||||
{
|
||||
title: "Coming up",
|
||||
description: "Work that is live but not started yet, so you can choose what to begin early.",
|
||||
items: notStarted.map((entry) => entry.item),
|
||||
},
|
||||
{
|
||||
title: "Completed",
|
||||
description: "Reviewed work you can revisit for correction and confidence boosts.",
|
||||
items: completed.map((entry) => entry.item),
|
||||
},
|
||||
].filter((group) => group.items.length > 0),
|
||||
};
|
||||
};
|
||||
@@ -31,15 +31,20 @@
|
||||
.courseCard {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas:
|
||||
"badge body"
|
||||
"cta cta";
|
||||
gap: 0.85rem;
|
||||
padding: 0.95rem;
|
||||
border-radius: 1.25rem;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
min-width: 0;
|
||||
|
||||
@media (min-width: 720px) {
|
||||
@include respond(compact) {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-areas: "badge body cta";
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -49,7 +54,7 @@
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0.68rem 0.95rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-info);
|
||||
color: var(--info);
|
||||
font-weight: 500;
|
||||
@@ -58,6 +63,7 @@
|
||||
transform 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
text-decoration: none;
|
||||
grid-area: cta;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
@@ -67,7 +73,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
@include respond(compact) {
|
||||
.courseCard .ctaLink {
|
||||
width: auto;
|
||||
}
|
||||
@@ -78,8 +84,9 @@
|
||||
height: 2.8rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 0.9rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
grid-area: badge;
|
||||
}
|
||||
|
||||
.yellow { background: var(--yellow-200); color: var(--yellow-500); }
|
||||
@@ -90,14 +97,19 @@
|
||||
.courseBody {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
grid-area: body;
|
||||
min-width: 0;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { assignmentCards } from "./dashboard.data";
|
||||
import { getDashboardAssignmentsHref } from "../../../lib/routes";
|
||||
import type { AssignmentCard } from "../shared/dashboard-types";
|
||||
import styles from "./dashboard-courses.module.scss";
|
||||
|
||||
const DashboardCourses: Component = () => {
|
||||
type DashboardCoursesProps = {
|
||||
assignmentCards: AssignmentCard[];
|
||||
};
|
||||
|
||||
const DashboardCourses: Component<DashboardCoursesProps> = (props) => {
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<div class={styles.header}>
|
||||
<h2>Keep going</h2>
|
||||
<A href="/dashboard/assignments">See all</A>
|
||||
<A href={getDashboardAssignmentsHref("student")}>See all</A>
|
||||
</div>
|
||||
|
||||
<div class={styles.courseList}>
|
||||
<For each={assignmentCards}>
|
||||
<For each={props.assignmentCards}>
|
||||
{(assignment) => (
|
||||
<article class={styles.courseCard}>
|
||||
<div classList={{ [styles.badge]: true, [styles[assignment.accent]]: true }}>{assignment.title.split("—")[0].trim().replace("HW", "H")}</div>
|
||||
<div classList={{ [styles.badge]: true, [styles[assignment.accent]]: true }}>{assignment.badge}</div>
|
||||
<div class={styles.courseBody}>
|
||||
<h3>{assignment.title}</h3>
|
||||
<p>{assignment.lessons}</p>
|
||||
@@ -2,11 +2,11 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@include respond(desktop-sm) {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1120px) {
|
||||
@include respond(desktop-lg) {
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(18rem, 0.75fr);
|
||||
}
|
||||
}
|
||||
@@ -22,16 +22,16 @@
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
padding: 1.15rem;
|
||||
border-radius: 1.5rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end));
|
||||
color: var(--text-on-accent);
|
||||
overflow: hidden;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@include respond(phablet) {
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@include respond(desktop-sm) {
|
||||
gap: 1.6rem;
|
||||
padding: 1.75rem;
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
font-size: 0.94rem;
|
||||
color: var(--text-on-accent-muted);
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@include respond(phablet) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.62rem 0.8rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-overlay-soft);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border-overlay);
|
||||
@@ -110,7 +110,7 @@
|
||||
height: 2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 700;
|
||||
color: var(--text-accent-strong);
|
||||
}
|
||||
@@ -136,11 +136,11 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@include respond(phablet) {
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@include respond(desktop-sm) {
|
||||
min-height: 13rem;
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,7 @@
|
||||
.visualCard {
|
||||
position: absolute;
|
||||
padding: 0.5rem 0.72rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-overlay-strong);
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
@@ -182,14 +182,14 @@
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1.5rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
background: var(--surface-panel);
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@include respond(phablet) {
|
||||
padding: 1.15rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@include respond(desktop-sm) {
|
||||
gap: 1rem;
|
||||
padding: 1.35rem;
|
||||
}
|
||||
@@ -213,7 +213,7 @@
|
||||
justify-content: center;
|
||||
padding: 0.82rem 0.95rem;
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
|
||||
color: var(--action-primary-text);
|
||||
font-weight: 600;
|
||||
@@ -1,20 +1,22 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { heroSideCard, heroSummary, quickStats, spotlightStats } from "./dashboard.data";
|
||||
import type { DashboardHeroData } from "../shared/dashboard-types";
|
||||
import styles from "./dashboard-hero.module.scss";
|
||||
|
||||
const DashboardHero: Component = () => {
|
||||
type DashboardHeroProps = DashboardHeroData;
|
||||
|
||||
const DashboardHero: Component<DashboardHeroProps> = (props) => {
|
||||
return (
|
||||
<section class={styles.heroGrid}>
|
||||
<div class={styles.heroCard}>
|
||||
<div class={styles.heroCopy}>
|
||||
<p class={styles.eyebrow}>{heroSummary.eyebrow}</p>
|
||||
<h1>{heroSummary.title}</h1>
|
||||
<p>{heroSummary.description}</p>
|
||||
<p class={styles.eyebrow}>{props.heroSummary.eyebrow}</p>
|
||||
<h1>{props.heroSummary.title}</h1>
|
||||
<p>{props.heroSummary.description}</p>
|
||||
|
||||
<div class={styles.metricRow}>
|
||||
<For each={spotlightStats}>
|
||||
<For each={props.spotlightStats}>
|
||||
{(stat) => (
|
||||
<div class={styles.metricPill}>
|
||||
<span classList={{ [styles.metricIcon]: true, [styles[stat.tone]]: true }}>{stat.label.slice(0, 1)}</span>
|
||||
@@ -30,19 +32,19 @@ const DashboardHero: Component = () => {
|
||||
|
||||
<div class={styles.heroVisual} aria-hidden="true">
|
||||
<div class={styles.orbit}></div>
|
||||
<For each={heroSummary.visualBadges}>{(badge) => <div class={styles.visualCard}>{badge}</div>}</For>
|
||||
<For each={props.heroSummary.visualBadges}>{(badge) => <div class={styles.visualCard}>{badge}</div>}</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.sideCard}>
|
||||
<h2>{heroSideCard.title}</h2>
|
||||
<p>{heroSideCard.description}</p>
|
||||
<A href={heroSideCard.buttonHref} class={styles.sideCardAction}>
|
||||
{heroSideCard.buttonLabel}
|
||||
<h2>{props.heroSideCard.title}</h2>
|
||||
<p>{props.heroSideCard.description}</p>
|
||||
<A href={props.heroSideCard.buttonHref} class={styles.sideCardAction}>
|
||||
{props.heroSideCard.buttonLabel}
|
||||
</A>
|
||||
|
||||
<div class={styles.quickStats}>
|
||||
<For each={quickStats}>
|
||||
<For each={props.quickStats}>
|
||||
{(stat) => (
|
||||
<div class={styles.statCard}>
|
||||
<p>{stat.label}</p>
|
||||
321
Frontend/src/components/dashboard/student/dashboard-home.data.ts
Normal file
321
Frontend/src/components/dashboard/student/dashboard-home.data.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
getAssignmentReviewHref,
|
||||
getAssignmentWorkHref,
|
||||
getDashboardAssignmentsHref,
|
||||
getDashboardMessagesHref,
|
||||
getDashboardPracticeHref,
|
||||
getDashboardProgressHref,
|
||||
getDashboardSettingsHref,
|
||||
getDashboardHomeHref,
|
||||
} from "../../../lib/routes";
|
||||
import { apiFetchJson } from "../../../lib/api";
|
||||
import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiClassroom, ApiListResponse, ApiUser } from "../../../lib/api-types";
|
||||
import { studentDashboardLabels } from "../../../content/dashboard-labels";
|
||||
import type { DashboardHomeData } from "../shared/dashboard-types";
|
||||
import {
|
||||
buildAssignmentCards,
|
||||
buildDerivedAssignments,
|
||||
buildProgressSeries,
|
||||
buildTopicAggregates,
|
||||
buildUsageItems,
|
||||
daysUntil,
|
||||
formatAssignmentBadge,
|
||||
formatAssignmentShortTitle,
|
||||
formatDueCountdown,
|
||||
formatRelativeTime,
|
||||
initialsFor,
|
||||
masteryTones,
|
||||
sortByDueDate,
|
||||
toneAt,
|
||||
} from "./dashboard-home.helpers";
|
||||
|
||||
export const getDashboardHomeData = async (studentId: number): Promise<DashboardHomeData> => {
|
||||
const assignmentsResponse = await apiFetchJson<ApiListResponse<ApiAssignment>>(`/api/students/${studentId}/assignments`);
|
||||
const assignments = assignmentsResponse.data;
|
||||
|
||||
const questionEntries = await Promise.all(
|
||||
assignments.map(async (assignment) => ({
|
||||
assignmentId: assignment.id,
|
||||
questions: (await apiFetchJson<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/api/assignments/${assignment.id}/students/${studentId}/questions`)).data,
|
||||
})),
|
||||
);
|
||||
|
||||
const questionsByAssignment = new Map(questionEntries.map((entry) => [entry.assignmentId, entry.questions]));
|
||||
const derivedAssignments = buildDerivedAssignments(assignments, questionsByAssignment);
|
||||
const activeAssignments = derivedAssignments.filter((assignment) => assignment.status !== "SUBMITTED").sort(sortByDueDate);
|
||||
const completedAssignments = derivedAssignments.filter((assignment) => assignment.status === "SUBMITTED").sort(sortByDueDate);
|
||||
const currentAssignment = activeAssignments.find((assignment) => assignment.status === "IN_PROGRESS") ?? activeAssignments[0] ?? completedAssignments[0] ?? null;
|
||||
const nextDueAssignment = activeAssignments.find((assignment) => Boolean(assignment.assignment.due_at)) ?? null;
|
||||
const nextAssignment = activeAssignments.find((assignment) => assignment.assignment.id !== currentAssignment?.assignment.id) ?? null;
|
||||
const topicAggregates = buildTopicAggregates(derivedAssignments);
|
||||
const weakestTopic = topicAggregates[0] ?? null;
|
||||
const strongestTopic = topicAggregates.slice().sort((left, right) => right.completion - left.completion || right.total - left.total)[0] ?? null;
|
||||
const latestTouch =
|
||||
derivedAssignments
|
||||
.map((assignment) => assignment.lastTouchedAt)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort((left, right) => new Date(right).getTime() - new Date(left).getTime())[0] ?? null;
|
||||
|
||||
const totalQuestions = derivedAssignments.reduce((sum, assignment) => sum + assignment.questions.length, 0);
|
||||
const totalAnswered = derivedAssignments.reduce((sum, assignment) => sum + assignment.answeredCount, 0);
|
||||
const totalReviewed = derivedAssignments.reduce((sum, assignment) => sum + assignment.reviewedCount, 0);
|
||||
const overallProgress = totalQuestions > 0 ? Math.round((totalReviewed / totalQuestions) * 100) : 0;
|
||||
const answerCoverage = totalQuestions > 0 ? Math.round((totalAnswered / totalQuestions) * 100) : 0;
|
||||
const remainingCount = totalQuestions - totalAnswered;
|
||||
const inProgressCount = derivedAssignments.filter((assignment) => assignment.status === "IN_PROGRESS").length;
|
||||
const notStartedCount = derivedAssignments.filter((assignment) => assignment.status === "NOT_STARTED").length;
|
||||
|
||||
let teacher: ApiUser | null = null;
|
||||
let classroom: ApiClassroom | null = null;
|
||||
if (currentAssignment?.assignment.teacher_id) {
|
||||
teacher = await apiFetchJson<ApiUser>(`/api/users/${currentAssignment.assignment.teacher_id}`);
|
||||
const classroomsResponse = await apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${currentAssignment.assignment.teacher_id}/classrooms`);
|
||||
classroom = classroomsResponse.data.find((entry) => entry.id === currentAssignment.assignment.classroom_id) ?? classroomsResponse.data[0] ?? null;
|
||||
}
|
||||
|
||||
const firstName = teacher ? teacher.full_name.split(" ")[0] : "Coach";
|
||||
const studentName = teacher?.full_name ? undefined : undefined;
|
||||
const progressSeries = buildProgressSeries(derivedAssignments);
|
||||
const currentAssignmentTitle = currentAssignment?.assignment.title ?? "your next assignment";
|
||||
const currentAssignmentShort = currentAssignment ? formatAssignmentShortTitle(currentAssignment.assignment.title, "your next task") : "your next task";
|
||||
const nextAssignmentShort = nextAssignment ? formatAssignmentShortTitle(nextAssignment.assignment.title, "another task") : "another task";
|
||||
const currentAssignmentBadge = currentAssignment ? formatAssignmentBadge(currentAssignment.assignment.title) : "NT";
|
||||
const nextAssignmentBadge = nextAssignment ? formatAssignmentBadge(nextAssignment.assignment.title) : "AT";
|
||||
const currentActionHref = currentAssignment
|
||||
? currentAssignment.status === "SUBMITTED"
|
||||
? getAssignmentReviewHref("student", currentAssignment.assignment.id)
|
||||
: getAssignmentWorkHref(currentAssignment.assignment.id)
|
||||
: getDashboardAssignmentsHref("student");
|
||||
const currentDueDays = currentAssignment && currentAssignment.status !== "SUBMITTED" && currentAssignment.assignment.due_at ? daysUntil(currentAssignment.assignment.due_at) : null;
|
||||
const nextDueDays = nextDueAssignment?.assignment.due_at ? daysUntil(nextDueAssignment.assignment.due_at) : null;
|
||||
const usageItems = buildUsageItems(totalQuestions, totalReviewed, totalAnswered, remainingCount);
|
||||
|
||||
const tutorName = teacher?.full_name ?? "Your tutor";
|
||||
const tutorInitials = initialsFor(tutorName);
|
||||
const studentFirstName = "Learner";
|
||||
|
||||
return {
|
||||
shell: {
|
||||
classroomSummary: {
|
||||
name: classroom?.name ?? "Live dashboard",
|
||||
targetLevel: undefined,
|
||||
inviteCode: classroom?.code ?? undefined,
|
||||
tutorName,
|
||||
tutorRole: teacher ? "Lead tutor" : "Learning support",
|
||||
tutorInitials,
|
||||
},
|
||||
sidebarLinks: [
|
||||
{ label: "Home", detail: "Today", icon: "⌂", href: getDashboardHomeHref("student"), active: true },
|
||||
{ label: "Assignments", detail: `${inProgressCount + notStartedCount} live`, icon: "✓", href: getDashboardAssignmentsHref("student") },
|
||||
{ label: "Progress", detail: `${overallProgress}% reviewed`, icon: "↗", href: getDashboardProgressHref() },
|
||||
{ label: "Practice", detail: weakestTopic?.label ?? "Mixed skills", icon: "✦", href: getDashboardPracticeHref() },
|
||||
{ label: "Messages", detail: tutorName.split(" ")[0], icon: "✉", href: getDashboardMessagesHref("student") },
|
||||
{ label: "Settings", detail: "Profile & goals", icon: "⋯", href: getDashboardSettingsHref("student") },
|
||||
],
|
||||
sidebarSupport: {
|
||||
avatars: [currentAssignmentBadge, (weakestTopic?.label ?? "Focus").slice(0, 2).toUpperCase(), nextAssignmentBadge],
|
||||
title: "Today’s study plan",
|
||||
description: currentAssignment
|
||||
? `Pick up ${currentAssignmentShort}, spend 15 minutes on ${weakestTopic?.label ?? "your current topic"}, then move into ${nextAssignmentShort} while the flow is still there.`
|
||||
: "Start with your assignment list, pick one live task, and use the dashboard to stay in a simple study rhythm.",
|
||||
buttonLabel: currentAssignment ? "Open my plan" : "See assignments",
|
||||
buttonHref: currentAssignment ? currentActionHref : getDashboardAssignmentsHref("student"),
|
||||
},
|
||||
topbarSummary: {
|
||||
searchPlaceholder: "Search assignments, hints, or question topics",
|
||||
profileName: `Student ${studentId}`,
|
||||
profileRole: classroom ? `${classroom.name} · Student` : "Student dashboard",
|
||||
profileBadge: tutorInitials,
|
||||
notificationCount: 3,
|
||||
messageCount: 3,
|
||||
},
|
||||
topbarNotifications: [
|
||||
{
|
||||
title: currentAssignment ? `${currentAssignmentShort} is your live priority` : "No live assignment yet",
|
||||
description: currentAssignment
|
||||
? currentAssignment.status === "SUBMITTED"
|
||||
? `You already finished it. Open the review and tighten any marked answers.`
|
||||
: `${currentAssignment.answeredCount}/${currentAssignment.questions.length || 0} questions touched so far. Keep the momentum going.`
|
||||
: "Open your assignments list to start the next live task.",
|
||||
timestamp: currentDueDays !== null ? formatDueCountdown(currentDueDays, "labelled") : "Today",
|
||||
href: currentAssignment ? currentActionHref : getDashboardAssignmentsHref("student"),
|
||||
tone: "blue",
|
||||
},
|
||||
{
|
||||
title: `${weakestTopic?.label ?? "Your focus topic"} needs a short pass`,
|
||||
description: weakestTopic
|
||||
? `${weakestTopic.reviewed}/${weakestTopic.total} questions are fully reviewed here. A short focused block will lift your coverage fastest.`
|
||||
: "Use a short practice block to warm up before you dive into assignment work.",
|
||||
timestamp: "Practice now",
|
||||
href: getDashboardPracticeHref(),
|
||||
tone: "yellow",
|
||||
},
|
||||
{
|
||||
title: `${tutorName.split(" ")[0]} has your next step ready`,
|
||||
description: currentAssignment
|
||||
? `Use ${currentAssignmentShort} as your anchor task, then finish on ${strongestTopic?.label ?? "a stronger topic"} for confidence.`
|
||||
: "Check your messages for study guidance and quick next-step prompts.",
|
||||
timestamp: "Today",
|
||||
href: getDashboardMessagesHref("student"),
|
||||
tone: "teal",
|
||||
},
|
||||
],
|
||||
topbarMessages: [
|
||||
{
|
||||
sender: tutorName,
|
||||
initials: tutorInitials,
|
||||
preview: weakestTopic
|
||||
? `Let’s steady ${weakestTopic.label.toLowerCase()} first, then return to ${currentAssignmentShort}.`
|
||||
: "Pick one live task and stay with it until you finish a clear chunk.",
|
||||
timestamp: "Today",
|
||||
actionHref: getDashboardMessagesHref("student"),
|
||||
},
|
||||
{
|
||||
sender: "Assignment check-in",
|
||||
initials: currentAssignmentBadge,
|
||||
preview: currentAssignment
|
||||
? currentAssignment.status === "SUBMITTED"
|
||||
? `Nice work finishing ${currentAssignmentTitle}. Revisit the review before moving on.`
|
||||
: `${currentAssignment.answeredCount}/${currentAssignment.questions.length || 0} questions are already moving. Resume while it is still fresh.`
|
||||
: "No assignment is active yet. Start with the next live task in your list.",
|
||||
timestamp: currentDueDays !== null ? formatDueCountdown(currentDueDays, "labelled") : "Soon",
|
||||
actionHref: currentAssignment ? currentActionHref : getDashboardAssignmentsHref("student"),
|
||||
},
|
||||
{
|
||||
sender: "Study coach",
|
||||
initials: "SC",
|
||||
preview: strongestTopic
|
||||
? `Finish today with ${strongestTopic.label.toLowerCase()} after you steady ${weakestTopic?.label?.toLowerCase() ?? "your main topic"}.`
|
||||
: "Finish the session with one easier win after your main assignment push.",
|
||||
timestamp: formatRelativeTime(latestTouch),
|
||||
actionHref: getDashboardProgressHref(),
|
||||
},
|
||||
],
|
||||
},
|
||||
hero: {
|
||||
heroSummary: {
|
||||
eyebrow: `Welcome back`,
|
||||
title: currentAssignment
|
||||
? currentAssignment.status === "SUBMITTED"
|
||||
? `Nice work finishing ${currentAssignmentShort}`
|
||||
: `Let’s keep ${currentAssignmentShort} moving`
|
||||
: "Your live dashboard is ready",
|
||||
description: currentAssignment
|
||||
? `You have ${completedAssignments.length} finished assignments so far. Right now, the clearest next step is to keep ${currentAssignmentShort} moving and tighten ${weakestTopic?.label ?? "your current focus"}.`
|
||||
: "Your dashboard home is now loading from the live backend. Start with one assignment and build momentum from there.",
|
||||
visualBadges: [
|
||||
`${overallProgress}% reviewed`,
|
||||
currentAssignment ? `${currentAssignmentShort} live` : "Live backend",
|
||||
weakestTopic ? `${weakestTopic.label} focus` : "Focus next",
|
||||
],
|
||||
},
|
||||
spotlightStats: [
|
||||
{ label: studentDashboardLabels.spotlight.assignmentsDone, value: `${completedAssignments.length}/${derivedAssignments.length}`, tone: "purple" },
|
||||
{ label: studentDashboardLabels.spotlight.currentFocus, value: weakestTopic?.label ?? "Mixed practice", tone: "yellow" },
|
||||
{ label: studentDashboardLabels.spotlight.bestTopic, value: strongestTopic?.label ?? "Building", tone: "blue" },
|
||||
],
|
||||
heroSideCard: {
|
||||
title: currentAssignment
|
||||
? currentAssignment.status === "SUBMITTED"
|
||||
? `Review ${currentAssignmentShort}`
|
||||
: `Pick up where you left off`
|
||||
: "Open your assignment hub",
|
||||
description: currentAssignment
|
||||
? currentAssignment.status === "SUBMITTED"
|
||||
? `${currentAssignment.reviewedCount}/${currentAssignment.questions.length || 0} questions are fully reviewed. A quick recap now will make the next task easier.`
|
||||
: `${currentAssignment.answeredCount}/${currentAssignment.questions.length || 0} questions are already done. Finish it while the method is still fresh.`
|
||||
: "No active assignment is selected yet. Head to your assignments list and start the next live task.",
|
||||
buttonLabel: currentAssignment ? (currentAssignment.status === "SUBMITTED" ? "Open review" : "Resume assignment") : "See assignments",
|
||||
buttonHref: currentAssignment ? currentActionHref : getDashboardAssignmentsHref("student"),
|
||||
},
|
||||
quickStats: [
|
||||
{ label: studentDashboardLabels.quickStats.nextDue, value: formatDueCountdown(nextDueDays) },
|
||||
{ label: studentDashboardLabels.quickStats.latestUpdate, value: formatRelativeTime(latestTouch) },
|
||||
],
|
||||
},
|
||||
assignmentCards: buildAssignmentCards(derivedAssignments).length
|
||||
? buildAssignmentCards(derivedAssignments)
|
||||
: [
|
||||
{
|
||||
title: "Start your first live assignment",
|
||||
badge: formatAssignmentBadge("Start your first live assignment"),
|
||||
lessons: "Your dashboard is connected to the backend and ready for real work.",
|
||||
accent: "blue",
|
||||
cta: "Open assignments",
|
||||
href: getDashboardAssignmentsHref("student"),
|
||||
},
|
||||
],
|
||||
activity: {
|
||||
activitySummary: {
|
||||
title: `${overallProgress}% reviewed so far`,
|
||||
note: currentAssignment
|
||||
? `${answerCoverage}% of questions have at least one answer. Use ${currentAssignmentShort} as the next push.`
|
||||
: "As you work through live assignments, your progress trend will update here from backend data.",
|
||||
badge: currentAssignment ? `${currentAssignment.answeredCount}/${currentAssignment.questions.length || 0} current` : "Live",
|
||||
},
|
||||
progressLabels: progressSeries.labels,
|
||||
progressPoints: progressSeries.points,
|
||||
highlightCards: [
|
||||
{
|
||||
value: `${completedAssignments.length}`,
|
||||
label: studentDashboardLabels.activity.completed,
|
||||
note: "Assignments fully reviewed and ready to revisit.",
|
||||
tone: "yellow",
|
||||
},
|
||||
{
|
||||
value: `${answerCoverage}%`,
|
||||
label: studentDashboardLabels.activity.answerCoverage,
|
||||
note: `${totalAnswered}/${totalQuestions || 0} questions have active work on them.`,
|
||||
tone: "pink",
|
||||
},
|
||||
],
|
||||
},
|
||||
studentSupportList: [
|
||||
{
|
||||
name: `Tighten ${weakestTopic?.label ?? "your focus topic"}`,
|
||||
meta: weakestTopic
|
||||
? `${weakestTopic.reviewed}/${weakestTopic.total} reviewed so far · quickest lift`
|
||||
: "Use one short focused block before the main assignment push.",
|
||||
initials: (weakestTopic?.label ?? "Focus").slice(0, 2).toUpperCase(),
|
||||
actionLabel: "Open practice",
|
||||
href: getDashboardPracticeHref(),
|
||||
},
|
||||
{
|
||||
name: currentAssignment ? `Finish ${currentAssignmentShort}` : "Start the next task",
|
||||
meta: currentAssignment
|
||||
? `${currentAssignment.answeredCount}/${currentAssignment.questions.length || 0} answered · best next move`
|
||||
: "Choose one live assignment and make the first small step.",
|
||||
initials: currentAssignmentBadge,
|
||||
actionLabel: currentAssignment ? "Resume work" : "See assignments",
|
||||
href: currentAssignment ? currentActionHref : getDashboardAssignmentsHref("student"),
|
||||
},
|
||||
{
|
||||
name: `Finish on ${strongestTopic?.label ?? "a stronger topic"}`,
|
||||
meta: strongestTopic
|
||||
? `${strongestTopic.completion}% reviewed here · good confidence finish`
|
||||
: "End the session with one easier win after the hard part.",
|
||||
initials: (strongestTopic?.label ?? "Win").slice(0, 2).toUpperCase(),
|
||||
actionLabel: "View progress",
|
||||
href: getDashboardProgressHref(),
|
||||
},
|
||||
],
|
||||
insights: {
|
||||
masteryTitle: "My topic coverage",
|
||||
overallTitle: "My review progress",
|
||||
overallLabel: studentDashboardLabels.insights.reviewed,
|
||||
usageTitle: "Question status mix",
|
||||
overallPassRate: overallProgress,
|
||||
topicMasteryBars: (topicAggregates.length ? topicAggregates : [{ label: "Getting started", total: 1, answered: 0, reviewed: 0, completion: 0 }]).slice(0, 5).map((topic, index) => ({
|
||||
label: topic.label,
|
||||
value: topic.completion,
|
||||
tone: toneAt(masteryTones, index),
|
||||
})),
|
||||
solveModeUsage: usageItems,
|
||||
usageSummary: {
|
||||
note: currentAssignment
|
||||
? `${remainingCount} questions still need first-pass answers. Finish ${currentAssignmentShort}, then revisit reviewed work for a cleaner close.`
|
||||
: "As backend work comes in, this panel will show how much of your live question load is answered and fully reviewed.",
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,232 @@
|
||||
import { getAssignmentReviewHref, getAssignmentWorkHref } from "../../../lib/routes";
|
||||
import type { ApiAssignment, ApiAssignmentStudentQuestionDetail } from "../../../lib/api-types";
|
||||
import { studentDashboardLabels } from "../../../content/dashboard-labels";
|
||||
import type { AccentTone, AssignmentCard, PerformanceTone, UsageItem } from "../shared/dashboard-types";
|
||||
|
||||
type DerivedAssignment = {
|
||||
assignment: ApiAssignment;
|
||||
questions: ApiAssignmentStudentQuestionDetail[];
|
||||
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
|
||||
answeredCount: number;
|
||||
reviewedCount: number;
|
||||
topic: string;
|
||||
lastTouchedAt: string | null;
|
||||
};
|
||||
|
||||
type TopicAggregate = {
|
||||
label: string;
|
||||
total: number;
|
||||
answered: number;
|
||||
reviewed: number;
|
||||
completion: number;
|
||||
};
|
||||
|
||||
const formatShortDate = (value: string | null) => {
|
||||
if (!value) return "No due date";
|
||||
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
export const initialsFor = (name: string) =>
|
||||
name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
export const formatAssignmentBadge = (title: string) => {
|
||||
const leadSegment = title.split(/[—-]/)[0]?.trim() ?? title.trim();
|
||||
const tokens = leadSegment.match(/[A-Za-z]+|\d+/g) ?? [];
|
||||
|
||||
if (tokens.length === 0) return "HW";
|
||||
|
||||
const firstWord = tokens.find((token) => /^[A-Za-z]+$/.test(token));
|
||||
const firstNumber = tokens.find((token) => /^\d+$/.test(token));
|
||||
|
||||
if (firstWord && firstNumber) {
|
||||
return `${firstWord[0]?.toUpperCase() ?? "H"}${firstNumber}`;
|
||||
}
|
||||
|
||||
if (tokens.length === 1) {
|
||||
return tokens[0]!.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
return tokens
|
||||
.slice(0, 2)
|
||||
.map((token) => (/^\d+$/.test(token) ? token : token[0]?.toUpperCase() ?? ""))
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
};
|
||||
|
||||
export const formatAssignmentShortTitle = (title: string, fallback = "Task") => {
|
||||
const leadSegment = title.split(/[—-]/)[0]?.trim() ?? title.trim();
|
||||
return leadSegment || fallback;
|
||||
};
|
||||
|
||||
export const formatRelativeTime = (value: string | null) => {
|
||||
if (!value) return "No recent updates";
|
||||
|
||||
const deltaMs = Date.now() - new Date(value).getTime();
|
||||
const deltaMinutes = Math.max(0, Math.round(deltaMs / (1000 * 60)));
|
||||
|
||||
if (deltaMinutes < 60) return `${deltaMinutes || 1}m ago`;
|
||||
const deltaHours = Math.round(deltaMinutes / 60);
|
||||
if (deltaHours < 24) return `${deltaHours}h ago`;
|
||||
const deltaDays = Math.round(deltaHours / 24);
|
||||
if (deltaDays < 7) return `${deltaDays}d ago`;
|
||||
return formatShortDate(value);
|
||||
};
|
||||
|
||||
export const daysUntil = (value: string | null) => {
|
||||
if (!value) return null;
|
||||
const msPerDay = 1000 * 60 * 60 * 24;
|
||||
return Math.ceil((new Date(value).getTime() - Date.now()) / msPerDay);
|
||||
};
|
||||
|
||||
export const formatDueCountdown = (days: number | null, mode: "compact" | "labelled" = "compact") => {
|
||||
if (days === null) return studentDashboardLabels.quickStats.noDeadline;
|
||||
if (days < 0) {
|
||||
return mode === "labelled" ? `${Math.abs(days)}d ${studentDashboardLabels.quickStats.overdue.toLowerCase()}` : studentDashboardLabels.quickStats.overdue;
|
||||
}
|
||||
if (days === 0) return studentDashboardLabels.quickStats.dueToday;
|
||||
return mode === "labelled" ? `${days}d left` : `${days}d`;
|
||||
};
|
||||
|
||||
const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
|
||||
const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/im)?.[1]?.trim();
|
||||
if (fromInstructions) return fromInstructions;
|
||||
return questions[0]?.subject ?? "Assignment";
|
||||
};
|
||||
|
||||
const deriveAssignmentStatus = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
|
||||
const total = questions.length;
|
||||
const answered = questions.filter((question) => question.answer_id).length;
|
||||
const reviewed = questions.filter((question) => question.answer_status === "reviewed").length;
|
||||
|
||||
if (answered === 0) return "NOT_STARTED" as const;
|
||||
if ((total > 0 && reviewed === total) || assignment.status === "closed") return "SUBMITTED" as const;
|
||||
return "IN_PROGRESS" as const;
|
||||
};
|
||||
|
||||
export const toneAt = <T extends string>(tones: T[], index: number) => tones[index % tones.length];
|
||||
|
||||
export const sortByDueDate = (left: DerivedAssignment, right: DerivedAssignment) => {
|
||||
const leftTime = left.assignment.due_at ? new Date(left.assignment.due_at).getTime() : Number.POSITIVE_INFINITY;
|
||||
const rightTime = right.assignment.due_at ? new Date(right.assignment.due_at).getTime() : Number.POSITIVE_INFINITY;
|
||||
return leftTime - rightTime;
|
||||
};
|
||||
|
||||
export const buildDerivedAssignments = (
|
||||
assignments: ApiAssignment[],
|
||||
questionsByAssignment: Map<number, ApiAssignmentStudentQuestionDetail[]>,
|
||||
) =>
|
||||
assignments.map((assignment) => {
|
||||
const questions = questionsByAssignment.get(assignment.id) ?? [];
|
||||
const answeredCount = questions.filter((question) => question.answer_id).length;
|
||||
const reviewedCount = questions.filter((question) => question.answer_status === "reviewed").length;
|
||||
const lastTouchedAt =
|
||||
questions
|
||||
.flatMap((question) => [question.reviewed_at, question.submitted_at, question.answer_updated_at, question.answer_created_at])
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort((left, right) => new Date(right).getTime() - new Date(left).getTime())[0] ?? assignment.updated_at;
|
||||
|
||||
return {
|
||||
assignment,
|
||||
questions,
|
||||
status: deriveAssignmentStatus(assignment, questions),
|
||||
answeredCount,
|
||||
reviewedCount,
|
||||
topic: extractTopic(assignment, questions),
|
||||
lastTouchedAt,
|
||||
};
|
||||
});
|
||||
|
||||
export const buildTopicAggregates = (assignments: DerivedAssignment[]) => {
|
||||
const topicMap = new Map<string, TopicAggregate>();
|
||||
|
||||
for (const assignment of assignments) {
|
||||
for (const question of assignment.questions) {
|
||||
const key = question.subject || assignment.topic || "Assignment";
|
||||
const current = topicMap.get(key) ?? {
|
||||
label: key,
|
||||
total: 0,
|
||||
answered: 0,
|
||||
reviewed: 0,
|
||||
completion: 0,
|
||||
};
|
||||
|
||||
current.total += 1;
|
||||
if (question.answer_id) current.answered += 1;
|
||||
if (question.answer_status === "reviewed") current.reviewed += 1;
|
||||
current.completion = current.total === 0 ? 0 : Math.round((current.reviewed / current.total) * 100);
|
||||
topicMap.set(key, current);
|
||||
}
|
||||
}
|
||||
|
||||
return [...topicMap.values()].sort((left, right) => left.completion - right.completion || right.total - left.total);
|
||||
};
|
||||
|
||||
export const buildAssignmentCards = (assignments: DerivedAssignment[]): AssignmentCard[] => {
|
||||
const tones: AccentTone[] = ["yellow", "blue", "teal", "pink"];
|
||||
|
||||
return assignments
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const statusWeight = {
|
||||
IN_PROGRESS: 0,
|
||||
NOT_STARTED: 1,
|
||||
SUBMITTED: 2,
|
||||
} as const;
|
||||
return statusWeight[left.status] - statusWeight[right.status] || sortByDueDate(left, right);
|
||||
})
|
||||
.slice(0, 3)
|
||||
.map((item, index) => ({
|
||||
title: item.assignment.title,
|
||||
badge: formatAssignmentBadge(item.assignment.title),
|
||||
lessons:
|
||||
item.status === "SUBMITTED"
|
||||
? `${item.reviewedCount}/${item.questions.length || 0} reviewed · revisit feedback`
|
||||
: item.status === "IN_PROGRESS"
|
||||
? `${item.answeredCount}/${item.questions.length || 0} answered · due ${formatShortDate(item.assignment.due_at)}`
|
||||
: `${item.questions.length || 0} questions · starts with ${item.topic}`,
|
||||
accent: toneAt(tones, index),
|
||||
cta: item.status === "SUBMITTED" ? "Open review" : item.status === "IN_PROGRESS" ? "Resume work" : "Start now",
|
||||
href: item.status === "SUBMITTED" ? getAssignmentReviewHref("student", item.assignment.id) : getAssignmentWorkHref(item.assignment.id),
|
||||
}));
|
||||
};
|
||||
|
||||
export const buildProgressSeries = (assignments: DerivedAssignment[]) => {
|
||||
const recent = assignments
|
||||
.slice()
|
||||
.sort((left, right) => new Date(left.assignment.created_at).getTime() - new Date(right.assignment.created_at).getTime())
|
||||
.slice(-5);
|
||||
|
||||
if (recent.length === 0) {
|
||||
return {
|
||||
labels: ["Plan", "Start", "Work", "Review", "Finish"],
|
||||
points: [20, 32, 44, 58, 66],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
labels: recent.map((item) => item.assignment.title.split(" ")[0]),
|
||||
points: recent.map((item) => {
|
||||
const total = item.questions.length || 1;
|
||||
const progress = Math.round((item.reviewedCount / total) * 100);
|
||||
return Math.max(12, Math.min(100, progress));
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildUsageItems = (totalQuestions: number, totalReviewed: number, totalAnswered: number, remainingCount: number): UsageItem[] => [
|
||||
{ label: studentDashboardLabels.insights.reviewed, value: totalQuestions > 0 ? Math.round((totalReviewed / totalQuestions) * 100) : 0, tone: "teal" },
|
||||
{ label: studentDashboardLabels.insights.answered, value: totalQuestions > 0 ? Math.round((totalAnswered / totalQuestions) * 100) : 0, tone: "blue" },
|
||||
{ label: studentDashboardLabels.insights.remaining, value: totalQuestions > 0 ? Math.round((remainingCount / totalQuestions) * 100) : 0, tone: "yellow" },
|
||||
];
|
||||
|
||||
export const masteryTones: PerformanceTone[] = ["blue", "purple", "teal", "pink", "yellow"];
|
||||
@@ -2,11 +2,11 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@include respond(desktop-sm) {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1120px) {
|
||||
@include respond(desktop-lg) {
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(16rem, 0.8fr) minmax(16rem, 0.9fr);
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,16 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1.35rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@include respond(phablet) {
|
||||
padding: 1.15rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@include respond(desktop-sm) {
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
@@ -68,14 +68,14 @@
|
||||
.dot {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
display: inline-block;
|
||||
margin-right: 0.55rem;
|
||||
}
|
||||
|
||||
.track {
|
||||
height: 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -98,7 +98,7 @@
|
||||
position: relative;
|
||||
min-height: 12rem;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@include respond(phablet) {
|
||||
padding: 1rem 0;
|
||||
min-height: 15rem;
|
||||
}
|
||||
@@ -111,7 +111,7 @@
|
||||
background: conic-gradient(var(--purple-400) 0 54%, var(--blue-400) 54% 74%, var(--surface-soft) 74% 100%);
|
||||
position: relative;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@include respond(phablet) {
|
||||
width: 12rem;
|
||||
height: 12rem;
|
||||
}
|
||||
@@ -124,7 +124,7 @@
|
||||
background: var(--surface-panel-strong);
|
||||
border: 1px solid var(--border-divider);
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@include respond(phablet) {
|
||||
inset: 1.8rem;
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@include respond(phablet) {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import { overallPassRate, topicMasteryBars, solveModeUsage, usageSummary } from "./dashboard.data";
|
||||
import type { DashboardInsightsData } from "../shared/dashboard-types";
|
||||
import styles from "./dashboard-insights.module.scss";
|
||||
|
||||
const DashboardInsights: Component = () => {
|
||||
const DashboardInsights: Component<DashboardInsightsData> = (props) => {
|
||||
return (
|
||||
<section class={styles.grid}>
|
||||
<article class={styles.panel}>
|
||||
<h2>My topic mastery</h2>
|
||||
<h2>{props.masteryTitle}</h2>
|
||||
<div class={styles.barChart}>
|
||||
<For each={topicMasteryBars}>
|
||||
<For each={props.topicMasteryBars}>
|
||||
{(item) => (
|
||||
<div class={styles.barRow}>
|
||||
<div class={styles.barMeta}>
|
||||
@@ -26,21 +26,21 @@ const DashboardInsights: Component = () => {
|
||||
</article>
|
||||
|
||||
<article class={styles.panel}>
|
||||
<h2>My overall accuracy</h2>
|
||||
<h2>{props.overallTitle}</h2>
|
||||
<div class={styles.donutWrap}>
|
||||
<div class={styles.donut} style={{ background: `conic-gradient(var(--purple-400) 0 ${Math.max(0, overallPassRate - 18)}%, var(--blue-400) ${Math.max(0, overallPassRate - 18)}% ${overallPassRate}%, var(--surface-soft) ${overallPassRate}% 100%)` }}></div>
|
||||
<div class={styles.donut} style={{ background: `conic-gradient(var(--purple-400) 0 ${Math.max(0, props.overallPassRate - 18)}%, var(--blue-400) ${Math.max(0, props.overallPassRate - 18)}% ${props.overallPassRate}%, var(--surface-soft) ${props.overallPassRate}% 100%)` }}></div>
|
||||
<div class={styles.donutLabel}>
|
||||
<strong>{overallPassRate}%</strong>
|
||||
<span>Accuracy</span>
|
||||
<strong>{props.overallPassRate}%</strong>
|
||||
<span>{props.overallLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class={styles.panel}>
|
||||
<h2>Solve mode usage</h2>
|
||||
<p class={styles.usageNote}>{usageSummary.note}</p>
|
||||
<h2>{props.usageTitle}</h2>
|
||||
<p class={styles.usageNote}>{props.usageSummary.note}</p>
|
||||
<div class={styles.usageList}>
|
||||
<For each={solveModeUsage}>
|
||||
<For each={props.solveModeUsage}>
|
||||
{(item) => (
|
||||
<div class={styles.usageItem}>
|
||||
<div class={styles.usageMeta}>
|
||||
@@ -33,12 +33,12 @@
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.85rem;
|
||||
padding: 0.95rem;
|
||||
border-radius: 1.25rem;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
|
||||
@media (min-width: 720px) {
|
||||
@include respond(compact) {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.95rem;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
@@ -70,7 +70,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
@include respond(compact) {
|
||||
.card .actionLink {
|
||||
width: auto;
|
||||
}
|
||||
@@ -81,7 +81,7 @@
|
||||
height: 3rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--surface-accent-soft), var(--surface-info));
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
@@ -3,19 +3,24 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import { getDashboardPracticeHref } from "../../../lib/routes";
|
||||
import styles from "./dashboard-instructors.module.scss";
|
||||
import { studentSupportList } from "./dashboard.data";
|
||||
import type { StudentSupportCard } from "../shared/dashboard-types";
|
||||
|
||||
const DashboardInstructors: Component = () => {
|
||||
type DashboardInstructorsProps = {
|
||||
studentSupportList: StudentSupportCard[];
|
||||
};
|
||||
|
||||
const DashboardInstructors: Component<DashboardInstructorsProps> = (props) => {
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<div class={styles.header}>
|
||||
<h2>Try these next</h2>
|
||||
<A href="/dashboard/practice">View plan</A>
|
||||
<A href={getDashboardPracticeHref()}>View plan</A>
|
||||
</div>
|
||||
|
||||
<div class={styles.list}>
|
||||
<For each={studentSupportList}>
|
||||
<For each={props.studentSupportList}>
|
||||
{(student) => (
|
||||
<article class={styles.card}>
|
||||
<div class={styles.avatar}>{student.initials}</div>
|
||||
@@ -2,7 +2,7 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@include respond(desktop-sm) {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,12 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.15rem;
|
||||
border-radius: 1.5rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
|
||||
@media (min-width: 880px) {
|
||||
@include respond(wide) {
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr);
|
||||
align-items: center;
|
||||
padding: 1.35rem;
|
||||
@@ -56,7 +56,7 @@
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 880px) {
|
||||
@include respond(wide) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.95rem;
|
||||
border-radius: 1.15rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--border-soft);
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
display: grid;
|
||||
gap: 0.95rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1.3rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
@@ -106,7 +106,7 @@
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 0.42rem 0.72rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
@@ -158,7 +158,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
@@ -1,21 +1,23 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { practiceFocusCards, practiceFocusStats } from "./dashboard.data";
|
||||
import type { DashboardPracticeData } from "./dashboard-practice.data";
|
||||
import styles from "./dashboard-practice-focus.module.scss";
|
||||
|
||||
const DashboardPracticeFocus: Component = () => {
|
||||
type DashboardPracticeFocusProps = DashboardPracticeData;
|
||||
|
||||
const DashboardPracticeFocus: Component<DashboardPracticeFocusProps> = (props) => {
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<article class={styles.heroCard}>
|
||||
<div class={styles.heroCopy}>
|
||||
<p class={styles.eyebrow}>Practice</p>
|
||||
<h1>Your practice space</h1>
|
||||
<p>Stay in dashboard mode to focus on the skill that needs the most attention, choose the right support mode, and jump into the next useful question set.</p>
|
||||
<p class={styles.eyebrow}>{props.hero.eyebrow}</p>
|
||||
<h1>{props.hero.title}</h1>
|
||||
<p>{props.hero.description}</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.statGrid}>
|
||||
<For each={practiceFocusStats}>
|
||||
<For each={props.hero.stats}>
|
||||
{(stat) => (
|
||||
<div class={styles.statCard}>
|
||||
<strong>{stat.value}</strong>
|
||||
@@ -27,7 +29,7 @@ const DashboardPracticeFocus: Component = () => {
|
||||
</article>
|
||||
|
||||
<div class={styles.cardGrid}>
|
||||
<For each={practiceFocusCards}>
|
||||
<For each={props.cards}>
|
||||
{(card) => (
|
||||
<article class={styles.practiceCard}>
|
||||
<span classList={{ [styles.toneChip]: true, [styles[card.tone]]: true }}>{card.meta}</span>
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { DashboardHomeShellData, PerformanceBar } from "../shared/dashboard-types";
|
||||
import { getDashboardAssignmentsHref, getDashboardHomeHref, getDashboardProgressHref } from "../../../lib/routes";
|
||||
import { studentDashboardLabels } from "../../../content/dashboard-labels";
|
||||
import { dashboardUiCopy } from "../../../content/ui-copy";
|
||||
import { getDashboardHomeData } from "./dashboard-home.data";
|
||||
|
||||
type PracticeFocusStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type PracticeFocusCard = {
|
||||
title: string;
|
||||
description: string;
|
||||
meta: string;
|
||||
tone: "yellow" | "blue" | "teal";
|
||||
primaryLabel: string;
|
||||
primaryHref: string;
|
||||
secondaryLabel: string;
|
||||
secondaryHref: string;
|
||||
};
|
||||
|
||||
export type DashboardPracticeData = {
|
||||
shell: DashboardHomeShellData;
|
||||
hero: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
stats: PracticeFocusStat[];
|
||||
};
|
||||
cards: PracticeFocusCard[];
|
||||
};
|
||||
|
||||
const findSpotlightValue = (items: { label: string; value: string }[], label: string, fallback: string) =>
|
||||
items.find((item) => item.label === label)?.value ?? fallback;
|
||||
|
||||
const findTopicBar = (items: PerformanceBar[], label: string) => items.find((item) => item.label === label) ?? null;
|
||||
|
||||
const summarizeTopicCoverage = (topic: PerformanceBar | null) => (topic ? `${topic.value}% reviewed in ${topic.label}` : "Short, focused practice is the fastest win.");
|
||||
|
||||
export const getDashboardPracticeData = async (studentId: number): Promise<DashboardPracticeData> => {
|
||||
const home = await getDashboardHomeData(studentId);
|
||||
const focusTopic = findSpotlightValue(home.hero.spotlightStats, studentDashboardLabels.spotlight.currentFocus, "Mixed practice");
|
||||
const bestTopic = findSpotlightValue(home.hero.spotlightStats, studentDashboardLabels.spotlight.bestTopic, "Building");
|
||||
const focusBar = findTopicBar(home.insights.topicMasteryBars, focusTopic);
|
||||
const bestBar = findTopicBar(home.insights.topicMasteryBars, bestTopic);
|
||||
const liveTargetTitle = home.hero.heroSideCard.title;
|
||||
const liveTargetHref = home.hero.heroSideCard.buttonHref;
|
||||
const liveTargetLabel = home.hero.heroSideCard.buttonLabel;
|
||||
const latestUpdate = home.hero.quickStats.find((item) => item.label === studentDashboardLabels.quickStats.latestUpdate)?.value ?? "No recent updates";
|
||||
|
||||
return {
|
||||
shell: home.shell,
|
||||
hero: {
|
||||
eyebrow: dashboardUiCopy.studentPractice.eyebrow,
|
||||
title: dashboardUiCopy.studentPractice.title,
|
||||
description: `Use ${focusTopic.toLowerCase()} as the main repair point, keep one live assignment moving, and finish on ${bestTopic.toLowerCase()} so the session ends with momentum.`,
|
||||
stats: [
|
||||
{ label: studentDashboardLabels.practice.focusTopic, value: focusTopic },
|
||||
{ label: studentDashboardLabels.practice.topicCoverage, value: focusBar ? `${focusBar.value}%` : "--" },
|
||||
{ label: studentDashboardLabels.practice.bestTopic, value: bestTopic },
|
||||
{ label: studentDashboardLabels.practice.latestUpdate, value: latestUpdate },
|
||||
],
|
||||
},
|
||||
cards: [
|
||||
{
|
||||
title: `Rebuild ${focusTopic}`,
|
||||
description: `Start with the topic that still has the thinnest review coverage, then return straight to your live assignment while the method is fresh.`,
|
||||
meta: summarizeTopicCoverage(focusBar),
|
||||
tone: "yellow",
|
||||
primaryLabel: liveTargetLabel,
|
||||
primaryHref: liveTargetHref,
|
||||
secondaryLabel: dashboardUiCopy.studentPractice.buttons.viewProgress,
|
||||
secondaryHref: getDashboardProgressHref(),
|
||||
},
|
||||
{
|
||||
title: dashboardUiCopy.studentPractice.cards.firstPassTitle,
|
||||
description: `Answer one question on your own before switching into review mode. That gives you a stronger signal on what still needs support.`,
|
||||
meta: `Live target: ${liveTargetTitle}`,
|
||||
tone: "blue",
|
||||
primaryLabel: liveTargetLabel,
|
||||
primaryHref: liveTargetHref,
|
||||
secondaryLabel: dashboardUiCopy.studentPractice.buttons.seeAssignments,
|
||||
secondaryHref: getDashboardAssignmentsHref("student"),
|
||||
},
|
||||
{
|
||||
title: `Finish with ${bestTopic}`,
|
||||
description: `Once the harder practice block is done, close the session on a stronger area so the next study block starts with confidence.`,
|
||||
meta: bestBar ? `${bestBar.value}% reviewed in ${bestBar.label}` : "Pick a familiar topic for a strong finish.",
|
||||
tone: "teal",
|
||||
primaryLabel: dashboardUiCopy.studentPractice.buttons.seeAssignments,
|
||||
primaryHref: getDashboardAssignmentsHref("student"),
|
||||
secondaryLabel: dashboardUiCopy.studentPractice.buttons.openDashboard,
|
||||
secondaryHref: getDashboardHomeHref("student"),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@include respond(desktop-sm) {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,12 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.15rem;
|
||||
border-radius: 1.5rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
|
||||
@media (min-width: 880px) {
|
||||
@include respond(wide) {
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr);
|
||||
align-items: center;
|
||||
padding: 1.35rem;
|
||||
@@ -56,7 +56,7 @@
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 880px) {
|
||||
@include respond(wide) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.95rem;
|
||||
border-radius: 1.15rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--border-soft);
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@include respond(desktop-sm) {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,23 @@ import type { Component } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import DashboardActivity from "./dashboard-activity";
|
||||
import DashboardInsights from "./dashboard-insights";
|
||||
import { progressFocusStats } from "./dashboard.data";
|
||||
import type { DashboardProgressData } from "./dashboard-progress.data";
|
||||
import styles from "./dashboard-progress-focus.module.scss";
|
||||
|
||||
const DashboardProgressFocus: Component = () => {
|
||||
type DashboardProgressFocusProps = DashboardProgressData;
|
||||
|
||||
const DashboardProgressFocus: Component<DashboardProgressFocusProps> = (props) => {
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<article class={styles.heroCard}>
|
||||
<div class={styles.heroCopy}>
|
||||
<p class={styles.eyebrow}>Progress</p>
|
||||
<h1>Your learning progress</h1>
|
||||
<p>Stay inside the dashboard to review your results, track improvement, and spot where a small practice block could lift your score fastest.</p>
|
||||
<p class={styles.eyebrow}>{props.hero.eyebrow}</p>
|
||||
<h1>{props.hero.title}</h1>
|
||||
<p>{props.hero.description}</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.statGrid}>
|
||||
<For each={progressFocusStats}>
|
||||
<For each={props.hero.stats}>
|
||||
{(stat) => (
|
||||
<div class={styles.statCard}>
|
||||
<strong>{stat.value}</strong>
|
||||
@@ -28,8 +30,8 @@ const DashboardProgressFocus: Component = () => {
|
||||
</article>
|
||||
|
||||
<div class={styles.contentStack}>
|
||||
<DashboardActivity />
|
||||
<DashboardInsights />
|
||||
<DashboardActivity {...props.activity} />
|
||||
<DashboardInsights {...props.insights} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { DashboardActivityData, DashboardHomeShellData, DashboardInsightsData } from "../shared/dashboard-types";
|
||||
import { studentDashboardLabels } from "../../../content/dashboard-labels";
|
||||
import { dashboardUiCopy } from "../../../content/ui-copy";
|
||||
import { getDashboardHomeData } from "./dashboard-home.data";
|
||||
|
||||
type ProgressFocusStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type DashboardProgressData = {
|
||||
shell: DashboardHomeShellData;
|
||||
hero: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
stats: ProgressFocusStat[];
|
||||
};
|
||||
activity: DashboardActivityData;
|
||||
insights: DashboardInsightsData;
|
||||
};
|
||||
|
||||
const findSpotlightValue = (items: { label: string; value: string }[], label: string, fallback: string) =>
|
||||
items.find((item) => item.label === label)?.value ?? fallback;
|
||||
|
||||
const findQuickStatValue = (items: { label: string; value: string }[], label: string, fallback: string) =>
|
||||
items.find((item) => item.label === label)?.value ?? fallback;
|
||||
|
||||
export const getDashboardProgressData = async (studentId: number): Promise<DashboardProgressData> => {
|
||||
const home = await getDashboardHomeData(studentId);
|
||||
const currentFocus = findSpotlightValue(home.hero.spotlightStats, studentDashboardLabels.spotlight.currentFocus, "Mixed practice");
|
||||
const strongestTopic = findSpotlightValue(home.hero.spotlightStats, studentDashboardLabels.spotlight.bestTopic, "Building");
|
||||
const latestUpdate = findQuickStatValue(home.hero.quickStats, studentDashboardLabels.quickStats.latestUpdate, "No recent updates");
|
||||
const answerCoverage = home.activity.highlightCards[1]?.value ?? "0%";
|
||||
|
||||
return {
|
||||
shell: home.shell,
|
||||
hero: {
|
||||
eyebrow: dashboardUiCopy.studentProgress.eyebrow,
|
||||
title: dashboardUiCopy.studentProgress.title,
|
||||
description: `Track what has been reviewed, see how much of your question load is active, and use ${currentFocus.toLowerCase()} as the fastest next improvement area.`,
|
||||
stats: [
|
||||
{ label: studentDashboardLabels.progress.reviewed, value: `${home.insights.overallPassRate}%` },
|
||||
{ label: studentDashboardLabels.progress.coverage, value: answerCoverage },
|
||||
{ label: studentDashboardLabels.progress.strongest, value: strongestTopic },
|
||||
{ label: studentDashboardLabels.progress.latestUpdate, value: latestUpdate },
|
||||
],
|
||||
},
|
||||
activity: home.activity,
|
||||
insights: home.insights,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { AssignmentSetupForm, DifficultyValue, QuestionGenerationForm, SelectOption } from "./dashboard-teacher-assignment-create.types";
|
||||
|
||||
export const FIXED_PASS_THRESHOLD_LABEL = "60% (6.0/10)";
|
||||
|
||||
export const GENERATION_TOPIC_OPTIONS: readonly SelectOption[] = [
|
||||
{ value: "place_value", label: "Place Value" },
|
||||
{ value: "arithmetic", label: "Arithmetic" },
|
||||
{ value: "negative_numbers", label: "Negative Numbers" },
|
||||
{ value: "bidmas", label: "BIDMAS" },
|
||||
{ value: "fractions", label: "Fractions" },
|
||||
{ value: "algebra", label: "Algebra" },
|
||||
{ value: "geometry", label: "Geometry" },
|
||||
{ value: "data", label: "Data" },
|
||||
];
|
||||
|
||||
export const GENERATION_DIFFICULTY_OPTIONS: readonly SelectOption<DifficultyValue>[] = [
|
||||
{ value: "easy", label: "Easy" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "hard", label: "Hard" },
|
||||
];
|
||||
|
||||
export const emptySetupForm: AssignmentSetupForm = {
|
||||
classroomId: "",
|
||||
title: "",
|
||||
instructions: "",
|
||||
dueAt: "",
|
||||
selectedQuestionIds: [],
|
||||
useMixedGeneration: false,
|
||||
primaryTopic: "fractions",
|
||||
primaryDifficulty: "easy",
|
||||
totalQuestions: "10",
|
||||
personalizedRatio: "30",
|
||||
seed: "",
|
||||
personalizedDifficulty: "easy",
|
||||
subject: "Maths",
|
||||
};
|
||||
|
||||
export const defaultGenerationForm: QuestionGenerationForm = {
|
||||
topic: "arithmetic",
|
||||
difficulty: "easy",
|
||||
count: "3",
|
||||
seed: "",
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { MixedGenerationInput } from "./dashboard-teacher-assignments.data";
|
||||
import type { AssignmentSetupForm, QuestionGenerationForm } from "./dashboard-teacher-assignment-create.types";
|
||||
import { GENERATION_TOPIC_OPTIONS } from "./dashboard-teacher-assignment-create.constants";
|
||||
|
||||
export const parseOptionalInteger = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isInteger(parsed) ? parsed : Number.NaN;
|
||||
};
|
||||
|
||||
export const buildMixedGenerationInput = (form: AssignmentSetupForm): MixedGenerationInput | null => {
|
||||
if (!form.useMixedGeneration) return null;
|
||||
|
||||
const parsedTotalQuestions = Number(form.totalQuestions);
|
||||
const parsedPersonalizedRatio = Number(form.personalizedRatio);
|
||||
const parsedSeed = parseOptionalInteger(form.seed);
|
||||
|
||||
return {
|
||||
primaryTopic: form.primaryTopic,
|
||||
primaryDifficulty: form.primaryDifficulty,
|
||||
totalQuestions: parsedTotalQuestions,
|
||||
personalizedRatio: Number.isFinite(parsedPersonalizedRatio) ? parsedPersonalizedRatio / 100 : undefined,
|
||||
seed: parsedSeed,
|
||||
personalizedDifficulty: form.personalizedDifficulty,
|
||||
subject: form.subject,
|
||||
questionStatus: "published",
|
||||
questionSource: "assignment_student_generated",
|
||||
};
|
||||
};
|
||||
|
||||
export const mergeSelectedQuestionIds = (currentIds: number[], generatedIds: number[]) => Array.from(new Set([...currentIds, ...generatedIds]));
|
||||
|
||||
export const buildGenerationSuccessMessage = (generationForm: QuestionGenerationForm, count: number, seed: number) => {
|
||||
const topicLabel = GENERATION_TOPIC_OPTIONS.find((option) => option.value === generationForm.topic)?.label ?? "Generated";
|
||||
return `Generated ${count} ${generationForm.difficulty} ${topicLabel} question${count === 1 ? "" : "s"} and auto-selected ${count === 1 ? "it" : "them"} for this assignment. Seed: ${seed}.`;
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import type { Component, JSX } from "solid-js";
|
||||
import { For, Show } from "solid-js";
|
||||
import { dashboardUiCopy } from "~/content/ui-copy";
|
||||
import { getDashboardAssignmentsHref, getDashboardHomeHref } from "../../../lib/routes";
|
||||
import styles from "./dashboard-teacher-assignments.module.scss";
|
||||
import { FIXED_PASS_THRESHOLD_LABEL, GENERATION_DIFFICULTY_OPTIONS, GENERATION_TOPIC_OPTIONS } from "./dashboard-teacher-assignment-create.constants";
|
||||
import type { AssignmentSetupForm, QuestionGenerationForm } from "./dashboard-teacher-assignment-create.types";
|
||||
import type { TeacherAssignmentSetupData } from "./dashboard-teacher-assignments.data";
|
||||
|
||||
type InputHandler = (event: Event) => void;
|
||||
|
||||
export const AssignmentCreateHero: Component = () => (
|
||||
<article class={styles.heroCard}>
|
||||
<div class={styles.heroCopy}>
|
||||
<p class={styles.eyebrow}>{dashboardUiCopy.teacherAssignmentCreate.hero.eyebrow}</p>
|
||||
<h1>{dashboardUiCopy.teacherAssignmentCreate.hero.title}</h1>
|
||||
<p>Create live homework from here without crowding the main Assignments hub.</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.formActions}>
|
||||
<A href={getDashboardAssignmentsHref("teacher")} class={styles.primaryAction}>
|
||||
{dashboardUiCopy.teacherAssignmentCreate.hero.backToAssignments}
|
||||
</A>
|
||||
<A href={getDashboardHomeHref("teacher")} class={styles.secondaryAction}>
|
||||
{dashboardUiCopy.teacherAssignmentCreate.hero.teachingOverview}
|
||||
</A>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
type AssignmentDetailsSectionProps = {
|
||||
form: AssignmentSetupForm;
|
||||
classrooms: TeacherAssignmentSetupData["classrooms"];
|
||||
selectedQuestionCount: number;
|
||||
onInput: (field: "classroomId" | "title" | "instructions" | "dueAt") => InputHandler;
|
||||
};
|
||||
|
||||
export const AssignmentDetailsSection: Component<AssignmentDetailsSectionProps> = (props) => (
|
||||
<>
|
||||
<div class={styles.setupHeader}>
|
||||
<div>
|
||||
<p class={styles.eyebrow}>{dashboardUiCopy.teacherAssignmentCreate.details.eyebrow}</p>
|
||||
<h2>{dashboardUiCopy.teacherAssignmentCreate.details.title}</h2>
|
||||
<p>Pick a class, then either attach shared bank questions or publish a mixed personalized homework set for every student.</p>
|
||||
</div>
|
||||
<div class={styles.setupBadge}>{props.selectedQuestionCount} {dashboardUiCopy.teacherAssignmentCreate.details.selectedSuffix}</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.fieldGrid}>
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.details.classroom}</span>
|
||||
<select class={styles.selectInput} value={props.form.classroomId} onInput={props.onInput("classroomId")}>
|
||||
<option value="">{dashboardUiCopy.teacherAssignmentCreate.details.chooseClassroom}</option>
|
||||
<For each={props.classrooms}>{(classroom) => <option value={classroom.id}>{classroom.name}</option>}</For>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.details.passThreshold}</span>
|
||||
<input class={styles.textInput} value={FIXED_PASS_THRESHOLD_LABEL} disabled />
|
||||
</div>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.details.dueDate}</span>
|
||||
<input class={styles.textInput} type="datetime-local" value={props.form.dueAt} onInput={props.onInput("dueAt")} />
|
||||
</label>
|
||||
|
||||
<label class={`${styles.fieldLabel} ${styles.fieldLabelFull}`}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.details.assignmentTitle}</span>
|
||||
<input class={styles.textInput} value={props.form.title} onInput={props.onInput("title")} placeholder={dashboardUiCopy.teacherAssignmentCreate.details.assignmentTitlePlaceholder} />
|
||||
</label>
|
||||
|
||||
<label class={`${styles.fieldLabel} ${styles.fieldLabelFull}`}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.details.teacherNotes}</span>
|
||||
<textarea
|
||||
class={styles.textArea}
|
||||
rows={4}
|
||||
value={props.form.instructions}
|
||||
onInput={props.onInput("instructions")}
|
||||
placeholder={dashboardUiCopy.teacherAssignmentCreate.details.teacherNotesPlaceholder}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
type PersonalizedGenerationSectionProps = {
|
||||
form: AssignmentSetupForm;
|
||||
mixedGenerationEnabled: boolean;
|
||||
mixedGenerationQuestionCount: number;
|
||||
mixedGenerationRatioLabel: string;
|
||||
onInput: (field: "primaryTopic" | "primaryDifficulty" | "totalQuestions" | "personalizedRatio" | "seed" | "personalizedDifficulty" | "subject") => InputHandler;
|
||||
onToggle: InputHandler;
|
||||
};
|
||||
|
||||
export const PersonalizedGenerationSection: Component<PersonalizedGenerationSectionProps> = (props) => (
|
||||
<div class={styles.generatorPanel}>
|
||||
<div class={styles.questionPickerHeader}>
|
||||
<div>
|
||||
<h3>{dashboardUiCopy.teacherAssignmentCreate.personalized.title}</h3>
|
||||
<p>Optionally generate a mixed question set for each student at publish time using one shared homework blueprint.</p>
|
||||
</div>
|
||||
<small>{props.mixedGenerationEnabled ? dashboardUiCopy.teacherAssignmentCreate.personalized.enabled : dashboardUiCopy.teacherAssignmentCreate.personalized.disabled}</small>
|
||||
</div>
|
||||
|
||||
<label class={styles.toggleRow}>
|
||||
<input type="checkbox" checked={props.form.useMixedGeneration} onChange={props.onToggle} />
|
||||
<div>
|
||||
<strong>{dashboardUiCopy.teacherAssignmentCreate.personalized.toggleTitle}</strong>
|
||||
<span>Uses the backend 70/30 mixed planner during assignment publish instead of relying on one shared question list.</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<Show when={props.mixedGenerationEnabled}>
|
||||
<div class={styles.fieldGrid}>
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.primaryTopic}</span>
|
||||
<select class={styles.selectInput} value={props.form.primaryTopic} onInput={props.onInput("primaryTopic")}>
|
||||
<For each={GENERATION_TOPIC_OPTIONS}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.primaryDifficulty}</span>
|
||||
<select class={styles.selectInput} value={props.form.primaryDifficulty} onInput={props.onInput("primaryDifficulty")}>
|
||||
<For each={GENERATION_DIFFICULTY_OPTIONS}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.totalQuestions}</span>
|
||||
<input class={styles.textInput} type="number" min="1" value={props.form.totalQuestions} onInput={props.onInput("totalQuestions")} />
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.personalizedShare}</span>
|
||||
<input class={styles.textInput} type="number" min="0" max="90" value={props.form.personalizedRatio} onInput={props.onInput("personalizedRatio")} />
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.personalizedDifficulty}</span>
|
||||
<select class={styles.selectInput} value={props.form.personalizedDifficulty} onInput={props.onInput("personalizedDifficulty")}>
|
||||
<For each={GENERATION_DIFFICULTY_OPTIONS}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.subjectLabel}</span>
|
||||
<input class={styles.textInput} value={props.form.subject} onInput={props.onInput("subject")} placeholder={dashboardUiCopy.teacherAssignmentCreate.personalized.subjectPlaceholder} />
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.seed}</span>
|
||||
<input class={styles.textInput} type="number" step="1" value={props.form.seed} onInput={props.onInput("seed")} placeholder={dashboardUiCopy.teacherAssignmentCreate.personalized.seedPlaceholder} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class={styles.generatorNote}>
|
||||
Each student will receive {props.mixedGenerationQuestionCount || 0} questions with roughly {props.mixedGenerationRatioLabel} and the rest from the main topic.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
||||
type SharedGenerationSectionProps = {
|
||||
form: QuestionGenerationForm;
|
||||
isGenerating: boolean;
|
||||
isSubmitting: boolean;
|
||||
onInput: (field: keyof QuestionGenerationForm) => InputHandler;
|
||||
onGenerate: (event: Event) => void;
|
||||
};
|
||||
|
||||
export const SharedGenerationSection: Component<SharedGenerationSectionProps> = (props) => (
|
||||
<div class={styles.generatorPanel}>
|
||||
<div class={styles.questionPickerHeader}>
|
||||
<div>
|
||||
<h3>{dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.title}</h3>
|
||||
<p>Create RNG-based shared questions by topic and difficulty, then attach them into this assignment when you want the whole class to receive the same set.</p>
|
||||
</div>
|
||||
<small>{dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.autoSelected}</small>
|
||||
</div>
|
||||
|
||||
<div class={styles.fieldGrid}>
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.topic}</span>
|
||||
<select class={styles.selectInput} value={props.form.topic} onInput={props.onInput("topic")}>
|
||||
<For each={GENERATION_TOPIC_OPTIONS}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.difficulty}</span>
|
||||
<select class={styles.selectInput} value={props.form.difficulty} onInput={props.onInput("difficulty")}>
|
||||
<For each={GENERATION_DIFFICULTY_OPTIONS}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.count}</span>
|
||||
<input class={styles.textInput} type="number" min="1" max="25" value={props.form.count} onInput={props.onInput("count")} />
|
||||
</label>
|
||||
|
||||
<label class={styles.fieldLabel}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.seed}</span>
|
||||
<input class={styles.textInput} type="number" step="1" value={props.form.seed} onInput={props.onInput("seed")} placeholder={dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.seedPlaceholder} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class={styles.generatorActions}>
|
||||
<button type="button" class={styles.secondaryAction} onClick={props.onGenerate} disabled={props.isGenerating || props.isSubmitting}>
|
||||
{props.isGenerating ? dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.generating : dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.generateQuestions}
|
||||
</button>
|
||||
<p class={styles.generatorNote}>Use the same seed later if you want to recreate the same generated set.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
type QuestionBankSectionProps = {
|
||||
questions: TeacherAssignmentSetupData["questions"];
|
||||
loading: boolean;
|
||||
mixedGenerationEnabled: boolean;
|
||||
selectedQuestionIds: number[];
|
||||
onToggle: (questionId: number) => InputHandler;
|
||||
};
|
||||
|
||||
export const QuestionBankSection: Component<QuestionBankSectionProps> = (props) => (
|
||||
<div class={styles.questionPicker}>
|
||||
<div class={styles.questionPickerHeader}>
|
||||
<div>
|
||||
<h3>{dashboardUiCopy.teacherAssignmentCreate.questionBank.title}</h3>
|
||||
<p>{props.mixedGenerationEnabled ? "Optional shared questions. Leave these unselected if you want this homework to rely only on per-student mixed generation." : "Select the questions you want to include in this assignment."}</p>
|
||||
</div>
|
||||
<small>{props.questions.length} {dashboardUiCopy.teacherAssignmentCreate.questionBank.availableSuffix}</small>
|
||||
</div>
|
||||
|
||||
<Show when={props.loading}>
|
||||
<div class={styles.questionEmptyState}>{dashboardUiCopy.teacherAssignmentCreate.questionBank.loading}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.loading && !props.questions.length}>
|
||||
<div class={styles.questionEmptyState}>{dashboardUiCopy.teacherAssignmentCreate.questionBank.empty}</div>
|
||||
</Show>
|
||||
|
||||
<div class={styles.questionList}>
|
||||
<For each={props.questions}>
|
||||
{(question) => (
|
||||
<label class={styles.questionCard}>
|
||||
<div class={styles.questionCheck}>
|
||||
<input type="checkbox" checked={props.selectedQuestionIds.includes(question.id)} onChange={props.onToggle(question.id)} />
|
||||
</div>
|
||||
<div class={styles.questionCopy}>
|
||||
<div class={styles.questionMeta}>
|
||||
<span>{question.subjectLabel}</span>
|
||||
<span>{question.difficultyLabel}</span>
|
||||
<span>{question.sourceLabel}</span>
|
||||
<span>{question.statusLabel}</span>
|
||||
</div>
|
||||
<strong>{question.title}</strong>
|
||||
<p>{question.promptPreview}</p>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
type SubmissionSectionProps = {
|
||||
isSubmitting: boolean;
|
||||
errorMessage: string | null;
|
||||
successMessage: string | null;
|
||||
onReset: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>;
|
||||
};
|
||||
|
||||
export const SubmissionSection: Component<SubmissionSectionProps> = (props) => (
|
||||
<>
|
||||
<div class={styles.formActions}>
|
||||
<button type="submit" class={styles.primaryAction} disabled={props.isSubmitting}>
|
||||
{props.isSubmitting ? dashboardUiCopy.teacherAssignmentCreate.submission.creating : dashboardUiCopy.teacherAssignmentCreate.submission.createAssignment}
|
||||
</button>
|
||||
<button type="button" class={styles.secondaryAction} onClick={props.onReset} disabled={props.isSubmitting}>
|
||||
{dashboardUiCopy.teacherAssignmentCreate.submission.resetForm}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={props.errorMessage}>
|
||||
<p class={`${styles.feedbackMessage} ${styles.feedbackError}`}>{props.errorMessage}</p>
|
||||
</Show>
|
||||
|
||||
<Show when={props.successMessage}>
|
||||
<p class={`${styles.feedbackMessage} ${styles.feedbackSuccess}`}>{props.successMessage}</p>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
type SetupSummarySectionProps = {
|
||||
classroomName: string;
|
||||
rosterLabel: string;
|
||||
mixedGenerationEnabled: boolean;
|
||||
mixedGenerationQuestionCount: number;
|
||||
selectedQuestionCount: number;
|
||||
};
|
||||
|
||||
export const SetupSummarySection: Component<SetupSummarySectionProps> = (props) => (
|
||||
<aside class={styles.setupSummaryCard}>
|
||||
<h2>{dashboardUiCopy.teacherAssignmentCreate.summary.title}</h2>
|
||||
<div class={styles.summaryList}>
|
||||
<div class={styles.summaryRow}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.summary.classroom}</span>
|
||||
<strong>{props.classroomName || dashboardUiCopy.teacherAssignmentCreate.summary.notChosenYet}</strong>
|
||||
</div>
|
||||
<div class={styles.summaryRow}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.summary.roster}</span>
|
||||
<strong>{props.rosterLabel || dashboardUiCopy.teacherAssignmentCreate.summary.chooseClassroom}</strong>
|
||||
</div>
|
||||
<div class={styles.summaryRow}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.summary.questions}</span>
|
||||
<strong>{props.mixedGenerationEnabled ? `${props.mixedGenerationQuestionCount || 0} per student` : `${props.selectedQuestionCount} selected`}</strong>
|
||||
</div>
|
||||
<div class={styles.summaryRow}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.summary.mode}</span>
|
||||
<strong>{props.mixedGenerationEnabled ? dashboardUiCopy.teacherAssignmentCreate.summary.mixedPersonalized : dashboardUiCopy.teacherAssignmentCreate.summary.sharedQuestionSet}</strong>
|
||||
</div>
|
||||
<div class={styles.summaryRow}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.summary.passThreshold}</span>
|
||||
<strong>{FIXED_PASS_THRESHOLD_LABEL}</strong>
|
||||
</div>
|
||||
<div class={styles.summaryRow}>
|
||||
<span>{dashboardUiCopy.teacherAssignmentCreate.summary.delivery}</span>
|
||||
<strong>{dashboardUiCopy.teacherAssignmentCreate.summary.liveAssignment}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class={styles.setupNote}>
|
||||
{props.mixedGenerationEnabled
|
||||
? "This flow creates live homework immediately and generates a mixed personalized question set for each assigned student during publish."
|
||||
: "This flow creates live homework immediately and assigns the selected shared question set to the selected classroom. Editing an existing assignment later will need a backend update route."}
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { Component } from "solid-js";
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { useAuth } from "~/context/auth/context";
|
||||
import styles from "./dashboard-teacher-assignments.module.scss";
|
||||
import { createTeacherAssignment, generateTeacherQuestions, getTeacherAssignmentSetupData } from "./dashboard-teacher-assignments.data";
|
||||
import { defaultGenerationForm, emptySetupForm } from "./dashboard-teacher-assignment-create.constants";
|
||||
import { buildGenerationSuccessMessage, buildMixedGenerationInput, mergeSelectedQuestionIds, parseOptionalInteger } from "./dashboard-teacher-assignment-create.helpers";
|
||||
import {
|
||||
AssignmentCreateHero,
|
||||
AssignmentDetailsSection,
|
||||
PersonalizedGenerationSection,
|
||||
QuestionBankSection,
|
||||
SetupSummarySection,
|
||||
SharedGenerationSection,
|
||||
SubmissionSection,
|
||||
} from "./dashboard-teacher-assignment-create.sections";
|
||||
import type { AssignmentSetupForm, QuestionGenerationForm } from "./dashboard-teacher-assignment-create.types";
|
||||
|
||||
const DashboardTeacherAssignmentCreate: Component = () => {
|
||||
const auth = useAuth();
|
||||
const [teacherId, setTeacherId] = createSignal<number | null>(null);
|
||||
const [setupData, { mutate: mutateSetupData }] = createResource(teacherId, getTeacherAssignmentSetupData);
|
||||
const [form, setForm] = createStore<AssignmentSetupForm>(emptySetupForm);
|
||||
const [generationForm, setGenerationForm] = createStore<QuestionGenerationForm>(defaultGenerationForm);
|
||||
const [isSubmitting, setIsSubmitting] = createSignal(false);
|
||||
const [isGenerating, setIsGenerating] = createSignal(false);
|
||||
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = createSignal<string | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
if (auth.user()?.role === "teacher") {
|
||||
setTeacherId(auth.user()!.id);
|
||||
}
|
||||
});
|
||||
|
||||
const selectedClassroom = createMemo(() => setupData()?.classrooms.find((classroom) => `${classroom.id}` === form.classroomId));
|
||||
const selectedQuestionCount = createMemo(() => form.selectedQuestionIds.length);
|
||||
const mixedGenerationEnabled = createMemo(() => form.useMixedGeneration);
|
||||
const mixedGenerationQuestionCount = createMemo(() => Number(form.totalQuestions) || 0);
|
||||
const mixedGenerationRatioLabel = createMemo(() => {
|
||||
const parsed = Number(form.personalizedRatio);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return "30% personalized";
|
||||
return `${parsed}% personalized`;
|
||||
});
|
||||
|
||||
const handleTextInput =
|
||||
(
|
||||
field:
|
||||
| "classroomId"
|
||||
| "title"
|
||||
| "instructions"
|
||||
| "dueAt"
|
||||
| "primaryTopic"
|
||||
| "primaryDifficulty"
|
||||
| "totalQuestions"
|
||||
| "personalizedRatio"
|
||||
| "seed"
|
||||
| "personalizedDifficulty"
|
||||
| "subject",
|
||||
) =>
|
||||
(event: Event) => {
|
||||
const target = event.currentTarget as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
||||
setForm(field, target.value);
|
||||
setSuccessMessage(null);
|
||||
};
|
||||
|
||||
const handleMixedGenerationToggle = (event: Event) => {
|
||||
const target = event.currentTarget as HTMLInputElement;
|
||||
setForm("useMixedGeneration", target.checked);
|
||||
setSuccessMessage(null);
|
||||
};
|
||||
|
||||
const handleGenerationInput = (field: keyof QuestionGenerationForm) => (event: Event) => {
|
||||
const target = event.currentTarget as HTMLInputElement | HTMLSelectElement;
|
||||
setGenerationForm(field, target.value as QuestionGenerationForm[keyof QuestionGenerationForm]);
|
||||
setSuccessMessage(null);
|
||||
};
|
||||
|
||||
const handleQuestionToggle = (questionId: number) => (event: Event) => {
|
||||
const target = event.currentTarget as HTMLInputElement;
|
||||
setForm("selectedQuestionIds", target.checked ? [...form.selectedQuestionIds, questionId] : form.selectedQuestionIds.filter((id) => id !== questionId));
|
||||
setSuccessMessage(null);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ ...emptySetupForm, classroomId: form.classroomId });
|
||||
};
|
||||
|
||||
const mergeGeneratedQuestions = (generatedQuestionIds: number[], questions: Awaited<ReturnType<typeof generateTeacherQuestions>>["questions"]) => {
|
||||
mutateSetupData((current) => {
|
||||
if (!current) return current;
|
||||
const existingQuestions = current.questions.filter((question) => !generatedQuestionIds.includes(question.id));
|
||||
return {
|
||||
...current,
|
||||
questions: [...questions, ...existingQuestions],
|
||||
};
|
||||
});
|
||||
|
||||
setForm("selectedQuestionIds", mergeSelectedQuestionIds(form.selectedQuestionIds, generatedQuestionIds));
|
||||
};
|
||||
|
||||
const handleGenerateQuestions = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const currentTeacherId = teacherId();
|
||||
if (!currentTeacherId) {
|
||||
setErrorMessage("Your teacher session is still loading.");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedCount = Number(generationForm.count);
|
||||
const parsedSeed = parseOptionalInteger(generationForm.seed);
|
||||
|
||||
if (!Number.isInteger(parsedCount) || parsedCount < 1 || parsedCount > 25) {
|
||||
setErrorMessage("Choose a question count between 1 and 25.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (generationForm.seed.trim() && (!Number.isInteger(parsedSeed) || Number.isNaN(parsedSeed))) {
|
||||
setErrorMessage("Seed must be a whole number when provided.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
try {
|
||||
const result = await generateTeacherQuestions({
|
||||
topic: generationForm.topic,
|
||||
difficulty: generationForm.difficulty,
|
||||
count: parsedCount,
|
||||
seed: parsedSeed,
|
||||
});
|
||||
|
||||
mergeGeneratedQuestions(result.generatedQuestionIds, result.questions);
|
||||
setSuccessMessage(buildGenerationSuccessMessage(generationForm, result.count, result.seed));
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Unable to generate questions right now.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const currentTeacherId = teacherId();
|
||||
if (!currentTeacherId) {
|
||||
setErrorMessage("Your teacher session is still loading.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
try {
|
||||
const mixedGeneration = buildMixedGenerationInput(form);
|
||||
|
||||
await createTeacherAssignment({
|
||||
teacherId: currentTeacherId,
|
||||
classroomId: Number(form.classroomId),
|
||||
title: form.title,
|
||||
instructions: form.instructions,
|
||||
dueAt: form.dueAt,
|
||||
selectedQuestionIds: form.selectedQuestionIds,
|
||||
mixedGeneration,
|
||||
});
|
||||
setSuccessMessage(
|
||||
mixedGenerationEnabled()
|
||||
? "Personalized homework created and assigned. Each student now has a mixed generated question set."
|
||||
: "Homework created and assigned to the selected class.",
|
||||
);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : "Unable to create homework right now.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<AssignmentCreateHero />
|
||||
|
||||
<form class={styles.setupGrid} onSubmit={(event) => void handleSubmit(event)}>
|
||||
<article class={styles.setupCard}>
|
||||
<AssignmentDetailsSection
|
||||
form={form}
|
||||
classrooms={setupData()?.classrooms ?? []}
|
||||
selectedQuestionCount={selectedQuestionCount()}
|
||||
onInput={handleTextInput}
|
||||
/>
|
||||
|
||||
<PersonalizedGenerationSection
|
||||
form={form}
|
||||
mixedGenerationEnabled={mixedGenerationEnabled()}
|
||||
mixedGenerationQuestionCount={mixedGenerationQuestionCount()}
|
||||
mixedGenerationRatioLabel={mixedGenerationRatioLabel()}
|
||||
onInput={handleTextInput}
|
||||
onToggle={handleMixedGenerationToggle}
|
||||
/>
|
||||
|
||||
<SharedGenerationSection
|
||||
form={generationForm}
|
||||
isGenerating={isGenerating()}
|
||||
isSubmitting={isSubmitting()}
|
||||
onInput={handleGenerationInput}
|
||||
onGenerate={(event) => void handleGenerateQuestions(event)}
|
||||
/>
|
||||
|
||||
<QuestionBankSection
|
||||
questions={setupData()?.questions ?? []}
|
||||
loading={setupData.loading}
|
||||
mixedGenerationEnabled={mixedGenerationEnabled()}
|
||||
selectedQuestionIds={form.selectedQuestionIds}
|
||||
onToggle={handleQuestionToggle}
|
||||
/>
|
||||
|
||||
<SubmissionSection
|
||||
isSubmitting={isSubmitting()}
|
||||
errorMessage={errorMessage()}
|
||||
successMessage={successMessage()}
|
||||
onReset={resetForm}
|
||||
/>
|
||||
</article>
|
||||
|
||||
<SetupSummarySection
|
||||
classroomName={selectedClassroom()?.name ?? ""}
|
||||
rosterLabel={selectedClassroom()?.studentCountLabel ?? ""}
|
||||
mixedGenerationEnabled={mixedGenerationEnabled()}
|
||||
mixedGenerationQuestionCount={mixedGenerationQuestionCount()}
|
||||
selectedQuestionCount={selectedQuestionCount()}
|
||||
/>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTeacherAssignmentCreate;
|
||||
@@ -0,0 +1,29 @@
|
||||
export type DifficultyValue = "easy" | "medium" | "hard";
|
||||
|
||||
export type SelectOption<T extends string = string> = {
|
||||
value: T;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type AssignmentSetupForm = {
|
||||
classroomId: string;
|
||||
title: string;
|
||||
instructions: string;
|
||||
dueAt: string;
|
||||
selectedQuestionIds: number[];
|
||||
useMixedGeneration: boolean;
|
||||
primaryTopic: string;
|
||||
primaryDifficulty: DifficultyValue;
|
||||
totalQuestions: string;
|
||||
personalizedRatio: string;
|
||||
seed: string;
|
||||
personalizedDifficulty: DifficultyValue;
|
||||
subject: string;
|
||||
};
|
||||
|
||||
export type QuestionGenerationForm = {
|
||||
topic: string;
|
||||
difficulty: DifficultyValue;
|
||||
count: string;
|
||||
seed: string;
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
import { apiFetchJson } from "../../../lib/api";
|
||||
import type {
|
||||
ApiAssignment,
|
||||
ApiClassroom,
|
||||
ApiGenerateQuestionsResponse,
|
||||
ApiListResponse,
|
||||
ApiQuestion,
|
||||
ApiReviewSummary,
|
||||
ApiStudent,
|
||||
} from "../../../lib/api-types";
|
||||
import { teacherDashboardLabels } from "../../../content/dashboard-labels";
|
||||
import { getDashboardHomeHref } from "../../../lib/routes";
|
||||
import {
|
||||
FIXED_PASS_THRESHOLD,
|
||||
buildAssignmentItem,
|
||||
buildEmptyReviewSummary,
|
||||
formatShortDate,
|
||||
isIndividualRedoAssignment,
|
||||
mapSetupQuestion,
|
||||
sortDueSoonest,
|
||||
sortNeedsAttention,
|
||||
} from "./dashboard-teacher-assignments.helpers";
|
||||
import type {
|
||||
DashboardTeacherAssignmentsFocusData,
|
||||
GenerateTeacherQuestionsInput,
|
||||
GenerateTeacherQuestionsResult,
|
||||
MixedGenerationInput,
|
||||
TeacherAssignmentSetupData,
|
||||
} from "./dashboard-teacher-assignments.types";
|
||||
|
||||
export type {
|
||||
DashboardTeacherAssignmentsFocusData,
|
||||
GenerateTeacherQuestionsInput,
|
||||
GenerateTeacherQuestionsResult,
|
||||
MixedGenerationInput,
|
||||
TeacherAssignmentSetupData,
|
||||
TeacherAssignmentSetupQuestion,
|
||||
} from "./dashboard-teacher-assignments.types";
|
||||
|
||||
export { formatDetailedDate, formatDateTimeLocalInput, isIndividualRedoAssignment } from "./dashboard-teacher-assignments.helpers";
|
||||
|
||||
type CreateTeacherAssignmentInput = {
|
||||
teacherId: number;
|
||||
classroomId: number;
|
||||
title: string;
|
||||
instructions: string;
|
||||
dueAt: string;
|
||||
selectedQuestionIds: number[];
|
||||
assignedStudentIds?: number[];
|
||||
mixedGeneration?: MixedGenerationInput | null;
|
||||
};
|
||||
|
||||
export const getTeacherAssignmentSetupData = async (teacherId: number): Promise<TeacherAssignmentSetupData> => {
|
||||
const [classroomsResponse, questionsResponse] = await Promise.all([
|
||||
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${teacherId}/classrooms`),
|
||||
apiFetchJson<ApiListResponse<ApiQuestion>>(`/api/teachers/${teacherId}/questions`),
|
||||
]);
|
||||
|
||||
const classrooms = classroomsResponse.data;
|
||||
const questions = questionsResponse.data;
|
||||
const studentsByClassroomEntries = await Promise.all(
|
||||
classrooms.map(async (classroom) => [classroom.id, (await apiFetchJson<ApiListResponse<ApiStudent>>(`/api/classrooms/${classroom.id}/students`)).data] as const),
|
||||
);
|
||||
const studentsByClassroom = new Map(studentsByClassroomEntries);
|
||||
|
||||
return {
|
||||
classrooms: classrooms
|
||||
.slice()
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map((classroom) => {
|
||||
const studentCount = (studentsByClassroom.get(classroom.id) ?? []).length;
|
||||
return {
|
||||
id: classroom.id,
|
||||
name: classroom.name,
|
||||
description: classroom.description?.trim() || "No class note added yet.",
|
||||
studentCountLabel: `${studentCount} student${studentCount === 1 ? "" : "s"}`,
|
||||
};
|
||||
}),
|
||||
questions: questions
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const rightTime = new Date(right.updated_at ?? right.created_at ?? 0).getTime();
|
||||
const leftTime = new Date(left.updated_at ?? left.created_at ?? 0).getTime();
|
||||
return rightTime - leftTime;
|
||||
})
|
||||
.map(mapSetupQuestion),
|
||||
};
|
||||
};
|
||||
|
||||
export const generateTeacherQuestions = async (input: GenerateTeacherQuestionsInput): Promise<GenerateTeacherQuestionsResult> => {
|
||||
const response = await apiFetchJson<ApiGenerateQuestionsResponse>("/api/questions/generate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
topic: input.topic,
|
||||
difficulty: input.difficulty,
|
||||
count: input.count,
|
||||
seed: typeof input.seed === "number" ? input.seed : undefined,
|
||||
status: input.status,
|
||||
source: input.source,
|
||||
}),
|
||||
parseErrorMessage: true,
|
||||
});
|
||||
|
||||
return {
|
||||
seed: response.seed,
|
||||
count: response.count,
|
||||
questions: response.data.map((item) => mapSetupQuestion(item.question)),
|
||||
generatedQuestionIds: response.data.map((item) => item.question.id),
|
||||
};
|
||||
};
|
||||
|
||||
export const createTeacherAssignment = async (input: CreateTeacherAssignmentInput) => {
|
||||
const title = input.title.trim();
|
||||
const instructions = input.instructions.trim();
|
||||
const dueDate = input.dueAt ? new Date(input.dueAt) : null;
|
||||
const mixedGeneration = input.mixedGeneration ?? null;
|
||||
|
||||
if (!title) throw new Error("Add an assignment title before creating it.");
|
||||
if (!input.classroomId) throw new Error("Choose a classroom for this assignment.");
|
||||
if (dueDate && Number.isNaN(dueDate.getTime())) throw new Error("Pick a valid due date and time.");
|
||||
if (!mixedGeneration && input.selectedQuestionIds.length === 0) {
|
||||
throw new Error("Select at least one question before creating homework.");
|
||||
}
|
||||
|
||||
if (mixedGeneration) {
|
||||
if (!mixedGeneration.primaryTopic.trim()) {
|
||||
throw new Error("Choose a primary topic for personalized generation.");
|
||||
}
|
||||
|
||||
if (!mixedGeneration.primaryDifficulty) {
|
||||
throw new Error("Choose a primary difficulty for personalized generation.");
|
||||
}
|
||||
|
||||
if (!Number.isInteger(mixedGeneration.totalQuestions) || mixedGeneration.totalQuestions < 1) {
|
||||
throw new Error("Choose a total personalized question count greater than 0.");
|
||||
}
|
||||
}
|
||||
|
||||
const assignment = await apiFetchJson<ApiAssignment>("/api/assignments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
classroom_id: input.classroomId,
|
||||
teacher_id: input.teacherId,
|
||||
title,
|
||||
instructions: instructions || null,
|
||||
pass_threshold: FIXED_PASS_THRESHOLD,
|
||||
status: "assigned",
|
||||
due_at: dueDate ? dueDate.toISOString() : null,
|
||||
published_at: new Date().toISOString(),
|
||||
}),
|
||||
parseErrorMessage: true,
|
||||
});
|
||||
|
||||
if (!mixedGeneration && input.selectedQuestionIds.length > 0) {
|
||||
await Promise.all(
|
||||
input.selectedQuestionIds.map((questionId, index) =>
|
||||
apiFetchJson(`/api/assignments/${assignment.id}/questions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ question_id: questionId, position: index + 1 }),
|
||||
parseErrorMessage: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const studentIds =
|
||||
input.assignedStudentIds && input.assignedStudentIds.length > 0
|
||||
? Array.from(new Set(input.assignedStudentIds))
|
||||
: (await apiFetchJson<ApiListResponse<ApiStudent>>(`/api/classrooms/${input.classroomId}/students`)).data.map((student) => student.id);
|
||||
|
||||
await Promise.all(
|
||||
studentIds.map((studentId) =>
|
||||
apiFetchJson(`/api/assignments/${assignment.id}/students`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
mixedGeneration
|
||||
? {
|
||||
student_id: studentId,
|
||||
mixed_generation: {
|
||||
primary_topic: mixedGeneration.primaryTopic,
|
||||
primary_difficulty: mixedGeneration.primaryDifficulty,
|
||||
total_questions: mixedGeneration.totalQuestions,
|
||||
personalized_ratio: mixedGeneration.personalizedRatio,
|
||||
seed: mixedGeneration.seed,
|
||||
personalized_difficulty: mixedGeneration.personalizedDifficulty,
|
||||
subject: mixedGeneration.subject?.trim() || undefined,
|
||||
question_status: mixedGeneration.questionStatus,
|
||||
question_source: mixedGeneration.questionSource?.trim() || undefined,
|
||||
},
|
||||
}
|
||||
: { student_id: studentId },
|
||||
),
|
||||
parseErrorMessage: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return assignment;
|
||||
};
|
||||
|
||||
export const getDashboardTeacherAssignmentsFocusData = async (teacherId: number): Promise<DashboardTeacherAssignmentsFocusData> => {
|
||||
const [assignmentsResponse, classroomsResponse] = await Promise.all([
|
||||
apiFetchJson<ApiListResponse<ApiAssignment>>(`/api/teachers/${teacherId}/assignments`),
|
||||
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${teacherId}/classrooms`),
|
||||
]);
|
||||
|
||||
const assignments = assignmentsResponse.data;
|
||||
const classrooms = classroomsResponse.data;
|
||||
|
||||
const [studentsByClassroomEntries, summaryEntries] = await Promise.all([
|
||||
Promise.all(classrooms.map(async (classroom) => [classroom.id, (await apiFetchJson<ApiListResponse<ApiStudent>>(`/api/classrooms/${classroom.id}/students`)).data] as const)),
|
||||
Promise.all(assignments.map(async (assignment) => [assignment.id, await apiFetchJson<ApiReviewSummary>(`/api/assignments/${assignment.id}/review-summary`)] as const)),
|
||||
]);
|
||||
|
||||
const classroomById = new Map(classrooms.map((classroom) => [classroom.id, classroom]));
|
||||
const studentsByClassroom = new Map(studentsByClassroomEntries);
|
||||
const summaries = new Map(summaryEntries);
|
||||
|
||||
const liveAssignments = assignments.filter((assignment) => assignment.status === "assigned" || assignment.status === "draft");
|
||||
const individualRedoAssignments = liveAssignments.filter(isIndividualRedoAssignment);
|
||||
const liveClassroomAssignments = liveAssignments.filter((assignment) => !isIndividualRedoAssignment(assignment));
|
||||
const closedAssignments = assignments.filter(
|
||||
(assignment) => assignment.status === "closed" && !isIndividualRedoAssignment(assignment),
|
||||
);
|
||||
const needsReviewAssignments = liveAssignments.filter((assignment) => (summaries.get(assignment.id)?.submitted ?? 0) > 0);
|
||||
|
||||
const totalStudents = new Set(
|
||||
Array.from(studentsByClassroom.values())
|
||||
.flat()
|
||||
.map((student) => student.id),
|
||||
).size;
|
||||
const submittedCount = assignments.reduce((total, assignment) => total + (summaries.get(assignment.id)?.submitted ?? 0), 0);
|
||||
const totalAssigned = assignments.reduce((total, assignment) => total + (summaries.get(assignment.id)?.total_assigned ?? 0), 0);
|
||||
const totalReviewed = assignments.reduce((total, assignment) => total + (summaries.get(assignment.id)?.reviewed ?? 0), 0);
|
||||
const reviewCoverage = totalAssigned > 0 ? Math.round((totalReviewed / totalAssigned) * 100) : 0;
|
||||
const nextDue = liveClassroomAssignments.slice().sort(sortDueSoonest).find((assignment) => assignment.due_at);
|
||||
|
||||
const mapGroupItems = (entries: ApiAssignment[]) =>
|
||||
entries.map((assignment) =>
|
||||
buildAssignmentItem(
|
||||
assignment,
|
||||
classroomById.get(assignment.classroom_id),
|
||||
summaries.get(assignment.id) ?? buildEmptyReviewSummary(assignment.id),
|
||||
(studentsByClassroom.get(assignment.classroom_id) ?? []).length,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
stats: [
|
||||
{ label: teacherDashboardLabels.assignments.needsReview, value: `${submittedCount}`, note: "Fresh submissions waiting for feedback" },
|
||||
{
|
||||
label: teacherDashboardLabels.assignments.liveAssignments,
|
||||
value: `${liveClassroomAssignments.length}`,
|
||||
note:
|
||||
individualRedoAssignments.length > 0
|
||||
? `${individualRedoAssignments.length} student redo assignment${individualRedoAssignments.length === 1 ? "" : "s"} hidden from the main list`
|
||||
: `${closedAssignments.length} closed assignment${closedAssignments.length === 1 ? "" : "s"} archived separately`,
|
||||
},
|
||||
{ label: teacherDashboardLabels.assignments.studentsCovered, value: `${totalStudents}`, note: `${classrooms.length} classroom${classrooms.length === 1 ? "" : "s"} connected` },
|
||||
{ label: teacherDashboardLabels.assignments.reviewCoverage, value: `${reviewCoverage}%`, note: nextDue ? `${teacherDashboardLabels.common.nextDue} ${formatShortDate(nextDue.due_at)}` : "No live due date scheduled" },
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
title: "Needs attention now",
|
||||
description: "Homework with submitted work already waiting in the teacher review queue.",
|
||||
items: mapGroupItems(needsReviewAssignments.slice().sort((left, right) => sortNeedsAttention(left, right, summaries))),
|
||||
},
|
||||
{
|
||||
title: "Live homework",
|
||||
description: "Published classroom homework that students can already see or are actively working through.",
|
||||
items: mapGroupItems(liveClassroomAssignments.slice().sort((left, right) => sortNeedsAttention(left, right, summaries))),
|
||||
},
|
||||
{
|
||||
title: "Closed",
|
||||
description: "Past assignments that remain useful for reflection and reference.",
|
||||
items: mapGroupItems(closedAssignments.slice().sort(sortDueSoonest)),
|
||||
},
|
||||
].filter((group) => group.items.length > 0),
|
||||
coachNote: {
|
||||
title: submittedCount > 0 ? "Review queue is active" : "Assignment set looks steady",
|
||||
description:
|
||||
submittedCount > 0
|
||||
? `You have ${submittedCount} submission${submittedCount === 1 ? "" : "s"} waiting. Start with the review queue, then come back here to manage live homework.`
|
||||
: "Use this page to keep live homework and closed work easy to scan so setup and follow-up stay easier to manage.",
|
||||
ctaLabel: submittedCount > 0 ? "Open dashboard review queue" : "Back to teaching overview",
|
||||
ctaHref: getDashboardHomeHref("teacher"),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { getAssignmentReviewHref } from "../../../lib/routes";
|
||||
import type { ApiAssignment, ApiClassroom, ApiQuestion, ApiReviewSummary } from "../../../lib/api-types";
|
||||
import type {
|
||||
TeacherAssignmentSetupQuestion,
|
||||
TeacherAssignmentsFocusItem,
|
||||
TeacherAssignmentTone,
|
||||
} from "./dashboard-teacher-assignments.types";
|
||||
|
||||
export const FIXED_PASS_THRESHOLD = 6.0;
|
||||
|
||||
export const buildEmptyReviewSummary = (assignmentId: number): ApiReviewSummary => ({
|
||||
assignment_id: assignmentId,
|
||||
total_questions: 0,
|
||||
total_assigned: 0,
|
||||
not_started: 0,
|
||||
in_progress: 0,
|
||||
submitted: 0,
|
||||
reviewed: 0,
|
||||
});
|
||||
|
||||
const statusLabelForQuestion = (status: string) => {
|
||||
if (status === "draft") return "Draft question";
|
||||
if (status === "published") return "Published question";
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (match) => match.toUpperCase());
|
||||
};
|
||||
|
||||
const difficultyLabelForQuestion = (difficulty: string | null | undefined) => {
|
||||
if (!difficulty) return "Mixed difficulty";
|
||||
return difficulty.replace(/_/g, " ").replace(/\b\w/g, (match) => match.toUpperCase());
|
||||
};
|
||||
|
||||
const sourceLabelForQuestion = (source: string | null | undefined) => {
|
||||
const normalized = source?.trim();
|
||||
if (!normalized) return "Question bank";
|
||||
if (normalized === "rng_generated") return "RNG generated";
|
||||
return normalized.replace(/_/g, " ").replace(/\b\w/g, (match) => match.toUpperCase());
|
||||
};
|
||||
|
||||
const promptPreviewForQuestion = (prompt: string) => {
|
||||
const cleaned = prompt.replace(/\s+/g, " ").trim();
|
||||
if (cleaned.length <= 150) return cleaned;
|
||||
return `${cleaned.slice(0, 147).trimEnd()}…`;
|
||||
};
|
||||
|
||||
export const mapSetupQuestion = (question: ApiQuestion): TeacherAssignmentSetupQuestion => ({
|
||||
id: question.id,
|
||||
title: question.title,
|
||||
promptPreview: promptPreviewForQuestion(question.prompt),
|
||||
subjectLabel: question.subject?.trim() || "General",
|
||||
difficultyLabel: difficultyLabelForQuestion(question.difficulty),
|
||||
sourceLabel: sourceLabelForQuestion(question.source),
|
||||
statusLabel: statusLabelForQuestion(question.status),
|
||||
});
|
||||
|
||||
export const formatShortDate = (value: string | null | undefined) => {
|
||||
if (!value) return "No due date";
|
||||
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
export const formatDetailedDate = (value: string | null | undefined, fallback = "Not scheduled yet") => {
|
||||
if (!value) return fallback;
|
||||
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
export const formatDateTimeLocalInput = (value: string | null | undefined) => {
|
||||
if (!value) return "";
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
|
||||
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return localDate.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const statusLabelForAssignment = (assignment: ApiAssignment, summary: ApiReviewSummary) => {
|
||||
if (assignment.status === "closed") return "Closed";
|
||||
if (summary.submitted > 0) return "Needs review";
|
||||
if (summary.in_progress > 0) return "In progress";
|
||||
return "Live";
|
||||
};
|
||||
|
||||
const toneForAssignment = (assignment: ApiAssignment, summary: ApiReviewSummary): TeacherAssignmentTone => {
|
||||
if (assignment.status === "closed") return "closed";
|
||||
if (summary.submitted > 0) return "review";
|
||||
return "live";
|
||||
};
|
||||
|
||||
export const sortDueSoonest = (left: ApiAssignment, right: ApiAssignment) => {
|
||||
const leftTime = left.due_at ? new Date(left.due_at).getTime() : Number.POSITIVE_INFINITY;
|
||||
const rightTime = right.due_at ? new Date(right.due_at).getTime() : Number.POSITIVE_INFINITY;
|
||||
return leftTime - rightTime;
|
||||
};
|
||||
|
||||
export const sortNeedsAttention = (
|
||||
left: ApiAssignment,
|
||||
right: ApiAssignment,
|
||||
summaries: Map<number, ApiReviewSummary>,
|
||||
) => {
|
||||
const leftSummary = summaries.get(left.id) ?? buildEmptyReviewSummary(left.id);
|
||||
const rightSummary = summaries.get(right.id) ?? buildEmptyReviewSummary(right.id);
|
||||
const leftPriority = leftSummary.submitted > 0 ? 0 : left.status === "assigned" ? 1 : 2;
|
||||
const rightPriority = rightSummary.submitted > 0 ? 0 : right.status === "assigned" ? 1 : 2;
|
||||
return leftPriority - rightPriority || sortDueSoonest(left, right);
|
||||
};
|
||||
|
||||
export const isIndividualRedoAssignment = (assignment: ApiAssignment) => {
|
||||
const normalizedTitle = assignment.title.trim().toLowerCase();
|
||||
|
||||
// Current student-specific redo assignments are created from the redo-plan page
|
||||
// with the deterministic title shape: "{studentName} redo • {originalTitle}".
|
||||
// Once we add a first-class assignment type or source field, replace this
|
||||
// heuristic with that canonical backend signal.
|
||||
return normalizedTitle.includes(" redo • ");
|
||||
};
|
||||
|
||||
export const buildAssignmentItem = (
|
||||
assignment: ApiAssignment,
|
||||
classroom: ApiClassroom | undefined,
|
||||
summary: ApiReviewSummary,
|
||||
studentCount: number,
|
||||
): TeacherAssignmentsFocusItem => {
|
||||
const canOpenTeacherReview = assignment.status === "closed" || summary.submitted > 0 || summary.in_progress > 0;
|
||||
|
||||
const reviewedCoverage = summary.total_assigned > 0 ? Math.round((summary.reviewed / summary.total_assigned) * 100) : 0;
|
||||
const note =
|
||||
summary.submitted > 0
|
||||
? `${summary.submitted} submission${summary.submitted === 1 ? " is" : "s are"} waiting for feedback right now.`
|
||||
: summary.in_progress > 0
|
||||
? `${summary.in_progress} student${summary.in_progress === 1 ? " is" : "s are"} actively working through this assignment.`
|
||||
: "Students can start this assignment as soon as they are ready.";
|
||||
|
||||
return {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
classroomName: classroom?.name ?? "Classroom",
|
||||
statusLabel: statusLabelForAssignment(assignment, summary),
|
||||
tone: toneForAssignment(assignment, summary),
|
||||
dueLabel: `Due ${formatShortDate(assignment.due_at)}`,
|
||||
queueLabel: summary.submitted > 0 ? `${summary.submitted} waiting for review` : summary.in_progress > 0 ? `${summary.in_progress} in progress` : `${summary.not_started} not started`,
|
||||
coverageLabel: summary.total_assigned > 0 ? `${reviewedCoverage}% reviewed` : "No student work yet",
|
||||
workloadLabel: `${summary.total_questions} question${summary.total_questions === 1 ? "" : "s"} · ${studentCount} student${studentCount === 1 ? "" : "s"}`,
|
||||
note,
|
||||
primaryHref: canOpenTeacherReview ? getAssignmentReviewHref("teacher", assignment.id) : null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,674 @@
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 1.15rem;
|
||||
|
||||
@include respond(desktop-sm) {
|
||||
gap: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.setupGrid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@include respond(desktop-md) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.heroCard {
|
||||
display: grid;
|
||||
gap: 1.15rem;
|
||||
padding: 1.2rem;
|
||||
border-radius: var(--radius-3xl);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--surface-panel) 88%, var(--surface-info) 12%), var(--surface-panel-strong));
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
|
||||
@include respond(desktop-md) {
|
||||
grid-template-columns: minmax(0, 1.18fr) minmax(20rem, 0.98fr);
|
||||
align-items: start;
|
||||
padding: 1.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
.setupCard,
|
||||
.setupSummaryCard {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.05rem 1.15rem;
|
||||
border-radius: 1.25rem;
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.setupSummaryCard {
|
||||
align-content: start;
|
||||
gap: 1.1rem;
|
||||
padding: 1.15rem 1.2rem;
|
||||
background: var(--surface-panel);
|
||||
border-color: var(--border-soft);
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
@include respond(desktop-md) {
|
||||
padding: 1.25rem 1.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.panelEyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.setupHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.92rem;
|
||||
max-width: 60ch;
|
||||
}
|
||||
}
|
||||
|
||||
.setupBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2rem;
|
||||
padding: 0 0.8rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-info-emphasis);
|
||||
color: var(--text-info-strong);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
|
||||
@include respond(desktop-sm) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
|
||||
span {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.fieldLabelFull {
|
||||
@include respond(desktop-sm) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.textInput,
|
||||
.selectInput,
|
||||
.textArea {
|
||||
width: 100%;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-panel-strong);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-info-strong);
|
||||
box-shadow: var(--focus-ring-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.textArea {
|
||||
resize: vertical;
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 0.8rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--border-soft);
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.generatorPanel {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-xl);
|
||||
background: color-mix(in srgb, var(--surface-info) 10%, var(--surface-panel) 90%);
|
||||
border: 1px solid color-mix(in srgb, var(--border-soft) 72%, var(--info) 28%);
|
||||
}
|
||||
|
||||
.generatorActions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.generatorNote {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 46ch;
|
||||
}
|
||||
|
||||
.questionPicker {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.questionPickerHeader {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.questionList {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
max-height: 28rem;
|
||||
overflow: auto;
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-panel-strong);
|
||||
border: 1px solid var(--border-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.questionCheck {
|
||||
padding-top: 0.1rem;
|
||||
}
|
||||
|
||||
.questionCopy {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
|
||||
strong {
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.questionMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
}
|
||||
|
||||
.questionEmptyState {
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-soft);
|
||||
border: 1px dashed var(--border-soft);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.formActions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feedbackMessage {
|
||||
padding: 0.82rem 0.95rem;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.88rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.feedbackError {
|
||||
background: var(--surface-warning-tint);
|
||||
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.feedbackSuccess {
|
||||
background: var(--surface-info-soft-tint);
|
||||
border-color: color-mix(in srgb, var(--info) 28%, transparent);
|
||||
color: var(--text-info-strong);
|
||||
}
|
||||
|
||||
.summaryList {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.summaryRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
|
||||
span {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
strong {
|
||||
text-align: right;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.setupNote {
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
max-width: 15ch;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.96rem;
|
||||
max-width: 56ch;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.groupList {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.setupSummaryCard .formActions {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
|
||||
@include respond(desktop-sm) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.setupSummaryCard .primaryAction,
|
||||
.setupSummaryCard .secondaryAction {
|
||||
width: 100%;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.groupStack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 1.1rem;
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
color: var(--text-muted);
|
||||
box-shadow: var(--shadow-soft);
|
||||
|
||||
h2 {
|
||||
color: var(--text);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.groupCount {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
padding: 0 0.7rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-info-emphasis);
|
||||
color: var(--text-info-strong);
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.cardGrid {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
|
||||
@media (min-width: 940px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 1480px) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.assignmentCard {
|
||||
display: grid;
|
||||
gap: 0.95rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1.3rem;
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
box-shadow: var(--shadow-soft);
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0.3rem;
|
||||
background: var(--border-soft);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
}
|
||||
|
||||
.assignmentCardLink {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--border-info-strong);
|
||||
box-shadow: var(--focus-ring-soft), var(--shadow-elevated);
|
||||
}
|
||||
}
|
||||
|
||||
.assignmentCardReview {
|
||||
border-color: color-mix(in srgb, var(--warning) 24%, var(--border-soft));
|
||||
|
||||
&::before {
|
||||
background: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
.assignmentCardLive {
|
||||
border-color: color-mix(in srgb, var(--info) 22%, var(--border-soft));
|
||||
|
||||
&::before {
|
||||
background: var(--info);
|
||||
}
|
||||
}
|
||||
|
||||
.assignmentCardClosed {
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-panel) 82%, var(--surface-soft) 18%), var(--surface-panel));
|
||||
|
||||
&::before {
|
||||
background: var(--border-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.cardTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.statusChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.42rem 0.68rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.review {
|
||||
background: var(--surface-warning-tint);
|
||||
color: var(--warning-text);
|
||||
border-color: var(--border-info-strong);
|
||||
}
|
||||
|
||||
.live {
|
||||
background: var(--surface-info-soft-tint);
|
||||
color: var(--info);
|
||||
border-color: var(--border-info-strong);
|
||||
}
|
||||
|
||||
.closed {
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.progressText {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-subtle);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
|
||||
@include respond(desktop-sm) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
span {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
padding: 0.6rem 0.72rem;
|
||||
border-radius: 0.9rem;
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
|
||||
color: var(--action-primary-text);
|
||||
box-shadow: var(--action-primary-shadow);
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
background: var(--surface-soft);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-soft);
|
||||
justify-self: start;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import type { Component } from "solid-js";
|
||||
import { For, Show, createResource, createSignal, onMount } from "solid-js";
|
||||
import { dashboardUiCopy } from "~/content/ui-copy";
|
||||
import { useAuth } from "~/context/auth/context";
|
||||
import { getDashboardAssignmentCreateHref, getDashboardAssignmentsHref, getDashboardHomeHref, getDashboardTeacherClosedAssignmentsHref } from "../../../lib/routes";
|
||||
import styles from "./dashboard-teacher-assignments.module.scss";
|
||||
import { getDashboardTeacherAssignmentsFocusData } from "./dashboard-teacher-assignments.data";
|
||||
|
||||
type DashboardTeacherAssignmentsProps = {
|
||||
mode?: "all" | "closed";
|
||||
};
|
||||
|
||||
const DashboardTeacherAssignments: Component<DashboardTeacherAssignmentsProps> = (props) => {
|
||||
const auth = useAuth();
|
||||
const [teacherId, setTeacherId] = createSignal<number | null>(null);
|
||||
const [data] = createResource(teacherId, getDashboardTeacherAssignmentsFocusData);
|
||||
const mode = () => props.mode ?? "all";
|
||||
|
||||
onMount(() => {
|
||||
if (auth.user()?.role === "teacher") {
|
||||
setTeacherId(auth.user()!.id);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<section class={styles.section}>
|
||||
<article class={styles.heroCard}>
|
||||
<div class={styles.heroCopy}>
|
||||
<p class={styles.eyebrow}>{mode() === "closed" ? dashboardUiCopy.teacherAssignments.eyebrow.closed : dashboardUiCopy.teacherAssignments.eyebrow.all}</p>
|
||||
<h1>{mode() === "closed" ? dashboardUiCopy.teacherAssignments.title.closed : dashboardUiCopy.teacherAssignments.title.all}</h1>
|
||||
<p>
|
||||
{mode() === "closed"
|
||||
? "Open older closed homework here to review what students previously completed, revisit feedback, and inspect final outcomes."
|
||||
: "Keep live homework and review-ready work easy to scan here, then open the dedicated create page when you want to build something new."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.setupGrid}>
|
||||
<aside class={styles.setupSummaryCard}>
|
||||
<p class={styles.panelEyebrow}>{mode() === "closed" ? dashboardUiCopy.teacherAssignments.panelEyebrow.closed : dashboardUiCopy.teacherAssignments.panelEyebrow.all}</p>
|
||||
<h2>{mode() === "closed" ? dashboardUiCopy.teacherAssignments.panelTitle.closed : dashboardUiCopy.teacherAssignments.panelTitle.all}</h2>
|
||||
<p class={styles.setupNote}>
|
||||
{mode() === "closed"
|
||||
? "Closed homework stays available here for reflection. Open any card below to revisit the full teacher review view for that assignment."
|
||||
: "Open the dedicated assignment-create page to choose a classroom, attach questions, and create live homework."}
|
||||
</p>
|
||||
<div class={styles.formActions}>
|
||||
<Show
|
||||
when={mode() !== "closed"}
|
||||
fallback={
|
||||
<A href={getDashboardAssignmentsHref("teacher")} class={styles.primaryAction}>
|
||||
{dashboardUiCopy.teacherAssignments.actions.backToAssignments}
|
||||
</A>
|
||||
}
|
||||
>
|
||||
<A href={getDashboardAssignmentCreateHref("teacher")} class={styles.primaryAction}>
|
||||
{dashboardUiCopy.teacherAssignments.actions.createAssignment}
|
||||
</A>
|
||||
</Show>
|
||||
<Show when={mode() !== "closed"}>
|
||||
<A href={getDashboardTeacherClosedAssignmentsHref()} class={styles.secondaryAction}>
|
||||
{dashboardUiCopy.teacherAssignments.actions.viewClosedHomework}
|
||||
</A>
|
||||
</Show>
|
||||
<A href={getDashboardHomeHref("teacher")} class={styles.secondaryAction}>
|
||||
{dashboardUiCopy.teacherAssignments.actions.backToOverview}
|
||||
</A>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class={styles.groupList}>
|
||||
<Show when={!data.loading} fallback={<section class={styles.infoCard}>{dashboardUiCopy.teacherAssignments.loading}</section>}>
|
||||
<Show when={(data()?.groups.filter((group) => mode() === "closed" ? group.title === "Closed" : true).length ?? 0) > 0} fallback={<section class={styles.infoCard}>{mode() === "closed" ? dashboardUiCopy.teacherAssignments.empty.closed : dashboardUiCopy.teacherAssignments.empty.all}</section>}>
|
||||
<div class={styles.groupStack}>
|
||||
<For each={(data()?.groups ?? []).filter((group) => (mode() === "closed" ? group.title === "Closed" : true))}>
|
||||
{(group) => (
|
||||
<section class={styles.group}>
|
||||
<div class={styles.groupHeader}>
|
||||
<div>
|
||||
<h2>{group.title}</h2>
|
||||
<p>{group.description}</p>
|
||||
</div>
|
||||
<span class={styles.groupCount}>{group.items.length}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.cardGrid}>
|
||||
<For each={group.items}>
|
||||
{(item) => {
|
||||
const cardContent = (
|
||||
<>
|
||||
<div class={styles.cardTop}>
|
||||
<span
|
||||
classList={{
|
||||
[styles.statusChip]: true,
|
||||
[styles.review]: item.tone === "review",
|
||||
[styles.live]: item.tone === "live",
|
||||
[styles.closed]: item.tone === "closed",
|
||||
}}
|
||||
>
|
||||
{item.statusLabel}
|
||||
</span>
|
||||
<span class={styles.progressText}>{item.workloadLabel}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.cardBody}>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.classroomName}</p>
|
||||
<small>{item.note}</small>
|
||||
</div>
|
||||
|
||||
<div class={styles.cardMeta}>
|
||||
<span>{item.dueLabel}</span>
|
||||
<span>{item.queueLabel}</span>
|
||||
<span>{item.coverageLabel}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return item.primaryHref ? (
|
||||
<A
|
||||
href={item.primaryHref}
|
||||
classList={{
|
||||
[styles.assignmentCard]: true,
|
||||
[styles.assignmentCardLink]: true,
|
||||
[styles.assignmentCardReview]: item.tone === "review",
|
||||
[styles.assignmentCardLive]: item.tone === "live",
|
||||
[styles.assignmentCardClosed]: item.tone === "closed",
|
||||
}}
|
||||
id={`teacher-assignment-${item.id}`}
|
||||
>
|
||||
{cardContent}
|
||||
</A>
|
||||
) : (
|
||||
<article
|
||||
classList={{
|
||||
[styles.assignmentCard]: true,
|
||||
[styles.assignmentCardReview]: item.tone === "review",
|
||||
[styles.assignmentCardLive]: item.tone === "live",
|
||||
[styles.assignmentCardClosed]: item.tone === "closed",
|
||||
}}
|
||||
id={`teacher-assignment-${item.id}`}
|
||||
>
|
||||
{cardContent}
|
||||
</article>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTeacherAssignments;
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ApiAssignment } from "../../../lib/api-types";
|
||||
|
||||
export type TeacherAssignmentSetupQuestion = {
|
||||
id: number;
|
||||
title: string;
|
||||
promptPreview: string;
|
||||
subjectLabel: string;
|
||||
difficultyLabel: string;
|
||||
sourceLabel: string;
|
||||
statusLabel: string;
|
||||
};
|
||||
|
||||
export type TeacherAssignmentSetupData = {
|
||||
classrooms: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
studentCountLabel: string;
|
||||
}[];
|
||||
questions: TeacherAssignmentSetupQuestion[];
|
||||
};
|
||||
|
||||
export type MixedGenerationInput = {
|
||||
primaryTopic: string;
|
||||
primaryDifficulty: "easy" | "medium" | "hard";
|
||||
totalQuestions: number;
|
||||
personalizedRatio?: number;
|
||||
seed?: number;
|
||||
personalizedDifficulty?: "easy" | "medium" | "hard";
|
||||
subject?: string;
|
||||
questionStatus?: "draft" | "published" | "archived";
|
||||
questionSource?: string;
|
||||
};
|
||||
|
||||
export type GenerateTeacherQuestionsInput = {
|
||||
topic: string;
|
||||
difficulty: "easy" | "medium" | "hard";
|
||||
count: number;
|
||||
seed?: number;
|
||||
status?: "draft" | "published" | "archived";
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type GenerateTeacherQuestionsResult = {
|
||||
seed: number;
|
||||
count: number;
|
||||
questions: TeacherAssignmentSetupQuestion[];
|
||||
generatedQuestionIds: number[];
|
||||
};
|
||||
|
||||
export type TeacherAssignmentsFocusStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type TeacherAssignmentsFocusItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
classroomName: string;
|
||||
statusLabel: string;
|
||||
tone: "review" | "live" | "closed";
|
||||
dueLabel: string;
|
||||
queueLabel: string;
|
||||
coverageLabel: string;
|
||||
workloadLabel: string;
|
||||
note: string;
|
||||
primaryHref: string | null;
|
||||
};
|
||||
|
||||
export type TeacherAssignmentsFocusGroup = {
|
||||
title: string;
|
||||
description: string;
|
||||
items: TeacherAssignmentsFocusItem[];
|
||||
};
|
||||
|
||||
export type DashboardTeacherAssignmentsFocusData = {
|
||||
stats: TeacherAssignmentsFocusStat[];
|
||||
groups: TeacherAssignmentsFocusGroup[];
|
||||
coachNote: {
|
||||
title: string;
|
||||
description: string;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TeacherAssignmentTone = TeacherAssignmentsFocusItem["tone"];
|
||||
|
||||
export type IndividualRedoAssignment = ApiAssignment;
|
||||
@@ -0,0 +1,152 @@
|
||||
import { apiFetchJson } from "../../../lib/api";
|
||||
import type { ApiAssignment, ApiClassroom, ApiListResponse, ApiReviewQueueItem, ApiReviewSummary, ApiStudent } from "../../../lib/api-types";
|
||||
import {
|
||||
getAssignmentReviewHref,
|
||||
getDashboardTeacherClassroomHref,
|
||||
} from "../../../lib/routes";
|
||||
import { teacherDashboardLabels } from "../../../content/dashboard-labels";
|
||||
import { isIndividualRedoAssignment } from "./dashboard-teacher-assignments.data";
|
||||
import {
|
||||
buildTeacherShell,
|
||||
formatRelativeTime,
|
||||
formatShortDate,
|
||||
initialsFor,
|
||||
queueStatusLabel,
|
||||
queueStatusTone,
|
||||
studentNote,
|
||||
} from "./dashboard-teacher-classroom-detail.helpers";
|
||||
import type { TeacherClassroomDetailData, TeacherClassroomRedoAssignmentItem } from "./dashboard-teacher-classroom-detail.types";
|
||||
|
||||
export type { TeacherClassroomDetailData, TeacherClassroomDetailStudentItem, TeacherClassroomRedoAssignmentItem } from "./dashboard-teacher-classroom-detail.types";
|
||||
|
||||
export const getTeacherClassroomDetailData = async (
|
||||
teacherId: number,
|
||||
classroomId: number,
|
||||
selectedStudentId?: number | null,
|
||||
): Promise<TeacherClassroomDetailData | null> => {
|
||||
const [classroomsResponse, assignmentsResponse] = await Promise.all([
|
||||
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${teacherId}/classrooms`),
|
||||
apiFetchJson<ApiListResponse<ApiAssignment>>(`/api/teachers/${teacherId}/assignments`),
|
||||
]);
|
||||
|
||||
const classrooms = classroomsResponse.data;
|
||||
const classroom = classrooms.find((entry) => entry.id === classroomId);
|
||||
if (!classroom) return null;
|
||||
|
||||
const classroomAssignments = assignmentsResponse.data.filter((assignment) => assignment.classroom_id === classroomId);
|
||||
const redoAssignments = classroomAssignments.filter(isIndividualRedoAssignment);
|
||||
|
||||
const [studentsResponse, reviewSummaryEntries, reviewQueueEntries] = await Promise.all([
|
||||
apiFetchJson<ApiListResponse<ApiStudent>>(`/api/classrooms/${classroomId}/students`),
|
||||
Promise.all(classroomAssignments.map(async (assignment) => [assignment.id, await apiFetchJson<ApiReviewSummary>(`/api/assignments/${assignment.id}/review-summary`)] as const)),
|
||||
Promise.all(redoAssignments.map(async (assignment) => [assignment.id, (await apiFetchJson<ApiListResponse<ApiReviewQueueItem>>(`/api/assignments/${assignment.id}/review`)).data] as const)),
|
||||
]);
|
||||
|
||||
const students = studentsResponse.data.slice().sort((left, right) => left.full_name.localeCompare(right.full_name));
|
||||
const reviewSummaryByAssignment = new Map(reviewSummaryEntries);
|
||||
const reviewQueueByAssignment = new Map(reviewQueueEntries);
|
||||
|
||||
const selectedStudent =
|
||||
(selectedStudentId != null ? students.find((student) => student.id === selectedStudentId) : null) ?? students[0] ?? null;
|
||||
|
||||
const studentItems = students.map((student) => {
|
||||
const studentRedoRows = redoAssignments
|
||||
.map((assignment) => ({ assignment, row: (reviewQueueByAssignment.get(assignment.id) ?? []).find((item) => item.student_id === student.id) ?? null }))
|
||||
.filter((entry) => entry.row != null);
|
||||
|
||||
const liveRedoCount = studentRedoRows.filter((entry) => entry.assignment.status !== "closed").length;
|
||||
const closedRedoCount = studentRedoRows.filter((entry) => entry.assignment.status === "closed").length;
|
||||
const submittedCount = studentRedoRows.filter((entry) => (entry.row?.submitted_questions ?? 0) > 0).length;
|
||||
|
||||
return {
|
||||
id: student.id,
|
||||
name: student.full_name,
|
||||
email: student.email,
|
||||
initials: initialsFor(student.full_name),
|
||||
statusLabel: submittedCount > 0 ? "Needs review" : liveRedoCount > 0 ? "Redo active" : closedRedoCount > 0 ? "Redo history" : "No redo",
|
||||
redoCountLabel: `${studentRedoRows.length} redo assignment${studentRedoRows.length === 1 ? "" : "s"}`,
|
||||
note: studentNote(submittedCount, liveRedoCount, closedRedoCount),
|
||||
href: getDashboardTeacherClassroomHref(classroomId, student.id),
|
||||
selected: student.id === selectedStudent?.id,
|
||||
};
|
||||
});
|
||||
|
||||
const selectedStudentRedoAssignments = selectedStudent
|
||||
? redoAssignments
|
||||
.map((assignment) => {
|
||||
const queueRow = (reviewQueueByAssignment.get(assignment.id) ?? []).find((item) => item.student_id === selectedStudent.id);
|
||||
if (!queueRow) return null;
|
||||
|
||||
const latestActivity = queueRow.latest_submitted_at ?? queueRow.latest_reviewed_at;
|
||||
const summary = reviewSummaryByAssignment.get(assignment.id);
|
||||
|
||||
return {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
statusLabel: assignment.status === "closed" ? "Closed" : queueStatusLabel(queueRow),
|
||||
statusTone: assignment.status === "closed" ? "success" : queueStatusTone(queueRow),
|
||||
dueLabel: `Due ${formatShortDate(assignment.due_at)}`,
|
||||
progressLabel: `${queueRow.reviewed_questions}/${queueRow.total_questions} reviewed`,
|
||||
nextStepLabel: queueRow.next_step_outcome ? `Next step: ${queueRow.next_step_outcome.replace(/_/g, " ")}` : "Next step still pending",
|
||||
note:
|
||||
(queueRow.submitted_questions ?? 0) > 0
|
||||
? `${queueRow.submitted_questions} question${queueRow.submitted_questions === 1 ? " is" : "s are"} waiting for review.`
|
||||
: summary && summary.reviewed > 0
|
||||
? `Latest activity ${formatRelativeTime(latestActivity)}.`
|
||||
: assignment.status === "closed"
|
||||
? "Closed redo assignment kept here for this student’s history."
|
||||
: "Assigned redo work with no submission yet.",
|
||||
href: getAssignmentReviewHref("teacher", assignment.id),
|
||||
};
|
||||
})
|
||||
.filter((item): item is TeacherClassroomRedoAssignmentItem => item != null)
|
||||
.sort((left, right) => {
|
||||
const leftClosed = left.statusLabel === "Closed" ? 1 : 0;
|
||||
const rightClosed = right.statusLabel === "Closed" ? 1 : 0;
|
||||
return leftClosed - rightClosed || left.title.localeCompare(right.title);
|
||||
})
|
||||
: [];
|
||||
|
||||
const liveRedoCount = redoAssignments.filter((assignment) => assignment.status !== "closed").length;
|
||||
const classroomNeedsReviewCount = redoAssignments.reduce((total, assignment) => {
|
||||
const rows = reviewQueueByAssignment.get(assignment.id) ?? [];
|
||||
return total + rows.filter((row) => row.submitted_questions > 0).length;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
shell: buildTeacherShell(classroom.name, classroom.code, classrooms.length, classroomNeedsReviewCount),
|
||||
classroom: {
|
||||
id: classroom.id,
|
||||
name: classroom.name,
|
||||
description: classroom.description?.trim() || "Track each student’s follow-up work and individual redo assignments from here.",
|
||||
codeLabel: classroom.code ? `Invite code ${classroom.code}` : "Invite code not set",
|
||||
stats: [
|
||||
{ label: teacherDashboardLabels.classroomDetail.students, value: `${students.length}`, note: students.length === 0 ? "No students in this classroom yet" : "Roster ready for drilldown" },
|
||||
{ label: teacherDashboardLabels.classroomDetail.redoAssignments, value: `${redoAssignments.length}`, note: `${liveRedoCount} live · ${redoAssignments.length - liveRedoCount} closed` },
|
||||
{ label: teacherDashboardLabels.classroomDetail.waitingReview, value: `${classroomNeedsReviewCount}`, note: classroomNeedsReviewCount > 0 ? "Student-specific redo work needs attention" : "Nothing urgent in redo follow-up" },
|
||||
],
|
||||
},
|
||||
students: {
|
||||
title: teacherDashboardLabels.classroomDetail.students,
|
||||
description: "Pick a student to inspect their individual redo assignments in this classroom.",
|
||||
items: studentItems,
|
||||
},
|
||||
selectedStudent: {
|
||||
id: selectedStudent?.id ?? null,
|
||||
name: selectedStudent?.full_name ?? null,
|
||||
email: selectedStudent?.email ?? null,
|
||||
note: selectedStudent
|
||||
? selectedStudentRedoAssignments.length > 0
|
||||
? `${selectedStudentRedoAssignments.length} redo assignment${selectedStudentRedoAssignments.length === 1 ? "" : "s"} tied to this student in ${classroom.name}.`
|
||||
: "This student does not have any individual redo assignments in this classroom yet."
|
||||
: "Select a student to view their individual redo assignments.",
|
||||
},
|
||||
redoAssignments: {
|
||||
title: selectedStudent ? `${selectedStudent.full_name} redo assignments` : "Student redo assignments",
|
||||
description: selectedStudent
|
||||
? "These are the individual redo assignments created specifically for the selected student."
|
||||
: "Select a student from the roster to inspect their individual redo assignments.",
|
||||
items: selectedStudentRedoAssignments,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { ApiReviewQueueItem } from "../../../lib/api-types";
|
||||
import {
|
||||
getDashboardAssignmentsHref,
|
||||
getDashboardClassroomsHref,
|
||||
getDashboardHomeHref,
|
||||
getDashboardMessagesHref,
|
||||
getDashboardSettingsHref,
|
||||
} from "../../../lib/routes";
|
||||
import type { DashboardHomeShellData, TopbarMessageItem } from "../shared/dashboard-types";
|
||||
import type { TeacherClassroomRedoAssignmentItem } from "./dashboard-teacher-classroom-detail.types";
|
||||
|
||||
export const initialsFor = (name: string) =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
export const formatShortDate = (value: string | null | undefined, fallback = "No due date") => {
|
||||
if (!value) return fallback;
|
||||
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
export const formatRelativeTime = (value: string | null | undefined, fallback = "No recent update") => {
|
||||
if (!value) return fallback;
|
||||
|
||||
const deltaMs = Date.now() - new Date(value).getTime();
|
||||
const deltaMinutes = Math.max(0, Math.round(deltaMs / (1000 * 60)));
|
||||
if (deltaMinutes < 60) return `${deltaMinutes || 1}m ago`;
|
||||
const deltaHours = Math.round(deltaMinutes / 60);
|
||||
if (deltaHours < 24) return `${deltaHours}h ago`;
|
||||
const deltaDays = Math.round(deltaHours / 24);
|
||||
if (deltaDays < 7) return `${deltaDays}d ago`;
|
||||
return formatShortDate(value, fallback);
|
||||
};
|
||||
|
||||
export const buildTeacherShell = (
|
||||
classroomName: string,
|
||||
inviteCode: string | null | undefined,
|
||||
classroomCount: number,
|
||||
needsReviewCount: number,
|
||||
): DashboardHomeShellData => ({
|
||||
classroomSummary: {
|
||||
name: classroomName,
|
||||
inviteCode: inviteCode?.trim() || undefined,
|
||||
tutorName: "Teacher workspace",
|
||||
tutorRole: "Teaching dashboard",
|
||||
tutorInitials: "TA",
|
||||
},
|
||||
sidebarLinks: [
|
||||
{ label: "Home", detail: "Teaching overview", icon: "OV", href: getDashboardHomeHref("teacher") },
|
||||
{ label: "Assignments", detail: "Homework setup", icon: "AS", href: getDashboardAssignmentsHref("teacher") },
|
||||
{ label: "Classrooms", detail: "Roster & codes", icon: "CL", href: getDashboardClassroomsHref("teacher"), active: true },
|
||||
{ label: "Messages", detail: "Inbox & follow-up", icon: "MS", href: getDashboardMessagesHref("teacher") },
|
||||
{ label: "Settings", detail: "Profile & preferences", icon: "ST", href: getDashboardSettingsHref("teacher") },
|
||||
],
|
||||
sidebarSupport: {
|
||||
avatars: ["CL", "RQ", "TA"],
|
||||
title: "Teaching flow",
|
||||
description:
|
||||
needsReviewCount > 0
|
||||
? `${needsReviewCount} submission${needsReviewCount === 1 ? " is" : "s are"} still waiting in the review queue.`
|
||||
: `${classroomCount} classroom${classroomCount === 1 ? " is" : "s are"} connected right now.`,
|
||||
buttonLabel: needsReviewCount > 0 ? "Open assignments" : "Back to classrooms",
|
||||
buttonHref: needsReviewCount > 0 ? getDashboardAssignmentsHref("teacher") : getDashboardClassroomsHref("teacher"),
|
||||
},
|
||||
topbarSummary: {
|
||||
searchPlaceholder: "Search classes, assignments, or students",
|
||||
profileName: "Teacher",
|
||||
profileRole: "Teaching dashboard",
|
||||
profileBadge: "TA",
|
||||
notificationCount: 0,
|
||||
messageCount: 0,
|
||||
},
|
||||
topbarNotifications: [],
|
||||
topbarMessages: [] as TopbarMessageItem[],
|
||||
});
|
||||
|
||||
export const queueStatusLabel = (item: ApiReviewQueueItem) => {
|
||||
if (item.next_step_outcome === "redo") return "Redo set";
|
||||
if (item.next_step_outcome === "accept") return "Accepted";
|
||||
if (item.next_step_outcome === "support") return "Support next";
|
||||
if (item.review_status === "submitted") return "Needs review";
|
||||
if (item.review_status === "reviewed") return "Reviewed";
|
||||
if (item.review_status === "in_progress" || item.answered_questions > 0 || item.reviewed_questions > 0) return "In progress";
|
||||
return "Not started";
|
||||
};
|
||||
|
||||
export const queueStatusTone = (item: ApiReviewQueueItem): TeacherClassroomRedoAssignmentItem["statusTone"] => {
|
||||
if (item.next_step_outcome === "accept") return "success";
|
||||
if (item.next_step_outcome === "redo" || item.next_step_outcome === "support" || item.review_status === "submitted") return "review";
|
||||
if (item.review_status === "in_progress" || item.answered_questions > 0 || item.reviewed_questions > 0) return "progress";
|
||||
return "muted";
|
||||
};
|
||||
|
||||
export const studentNote = (submittedCount: number, activeRedoCount: number, closedRedoCount: number) => {
|
||||
if (submittedCount > 0) return `${submittedCount} redo assignment${submittedCount === 1 ? " is" : "s are"} waiting for review.`;
|
||||
if (activeRedoCount > 0) return `${activeRedoCount} live redo assignment${activeRedoCount === 1 ? "" : "s"} currently assigned.`;
|
||||
if (closedRedoCount > 0) return `${closedRedoCount} closed redo assignment${closedRedoCount === 1 ? "" : "s"} available for reference.`;
|
||||
return "No individual redo assignments for this student yet.";
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { For, Show, type Component } from "solid-js";
|
||||
import { getDashboardClassroomsHref } from "../../../lib/routes";
|
||||
import type { TeacherClassroomDetailData } from "./dashboard-teacher-classroom-detail.data";
|
||||
import styles from "./dashboard-teacher-classrooms.module.scss";
|
||||
|
||||
type Props = {
|
||||
data: TeacherClassroomDetailData;
|
||||
};
|
||||
|
||||
const DashboardTeacherClassroomDetail: Component<Props> = (props) => {
|
||||
return (
|
||||
<section class={styles.page}>
|
||||
<article class={styles.heroCard}>
|
||||
<div class={styles.heroCopy}>
|
||||
<p class={styles.eyebrow}>Classroom view</p>
|
||||
<h1>{props.data.classroom.name}</h1>
|
||||
<p>{props.data.classroom.description}</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.heroStats}>
|
||||
<For each={props.data.classroom.stats}>
|
||||
{(stat) => (
|
||||
<div class={styles.statCard}>
|
||||
<span>{stat.label}</span>
|
||||
<strong>{stat.value}</strong>
|
||||
<small>{stat.note}</small>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section class={styles.classroomDetailMetaRow}>
|
||||
<span class={styles.codePill}>{props.data.classroom.codeLabel}</span>
|
||||
<A href={getDashboardClassroomsHref("teacher")} class={styles.manageLink}>
|
||||
Back to classrooms
|
||||
</A>
|
||||
</section>
|
||||
|
||||
<section class={styles.classroomDetailLayout}>
|
||||
<article class={styles.detailPanel}>
|
||||
<div class={styles.detailPanelHeader}>
|
||||
<div>
|
||||
<p class={styles.eyebrow}>Roster</p>
|
||||
<h2>{props.data.students.title}</h2>
|
||||
</div>
|
||||
<p>{props.data.students.description}</p>
|
||||
</div>
|
||||
|
||||
<Show when={props.data.students.items.length} fallback={<section class={styles.emptyState}>No students are enrolled in this classroom yet.</section>}>
|
||||
<div class={styles.studentList}>
|
||||
<For each={props.data.students.items}>
|
||||
{(student) => (
|
||||
<A href={student.href} class={`${styles.studentCard} ${student.selected ? styles.studentCardSelected : ""}`.trim()}>
|
||||
<div class={styles.studentCardHeader}>
|
||||
<div class={styles.studentAvatar}>{student.initials}</div>
|
||||
<div>
|
||||
<h3>{student.name}</h3>
|
||||
<p>{student.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class={styles.metaRow}>
|
||||
<span>{student.statusLabel}</span>
|
||||
<span>{student.redoCountLabel}</span>
|
||||
</div>
|
||||
<p class={styles.studentNote}>{student.note}</p>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</article>
|
||||
|
||||
<article class={styles.detailPanel}>
|
||||
<div class={styles.detailPanelHeader}>
|
||||
<div>
|
||||
<p class={styles.eyebrow}>Student follow-up</p>
|
||||
<h2>{props.data.redoAssignments.title}</h2>
|
||||
</div>
|
||||
<p>{props.data.redoAssignments.description}</p>
|
||||
</div>
|
||||
|
||||
<article class={styles.selectedStudentCard}>
|
||||
<div>
|
||||
<strong>{props.data.selectedStudent.name ?? "No student selected"}</strong>
|
||||
<p>{props.data.selectedStudent.email ?? props.data.selectedStudent.note}</p>
|
||||
</div>
|
||||
<small>{props.data.selectedStudent.note}</small>
|
||||
</article>
|
||||
|
||||
<Show
|
||||
when={props.data.selectedStudent.id != null && props.data.redoAssignments.items.length > 0}
|
||||
fallback={<section class={styles.emptyState}>Select a student to inspect their individual redo assignments. If none appear, this student does not have student-specific redo work yet.</section>}
|
||||
>
|
||||
<div class={styles.redoAssignmentList}>
|
||||
<For each={props.data.redoAssignments.items}>
|
||||
{(assignment) => (
|
||||
<article class={styles.redoAssignmentCard}>
|
||||
<div class={styles.cardHeader}>
|
||||
<div>
|
||||
<h3>{assignment.title}</h3>
|
||||
<p>{assignment.note}</p>
|
||||
</div>
|
||||
<span class={`${styles.statusPill} ${styles[`statusPill${assignment.statusTone[0].toUpperCase()}${assignment.statusTone.slice(1)}`]}`}>{assignment.statusLabel}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.metaRow}>
|
||||
<span>{assignment.dueLabel}</span>
|
||||
<span>{assignment.progressLabel}</span>
|
||||
<span>{assignment.nextStepLabel}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.reviewRow}>
|
||||
<strong>{assignment.statusLabel}</strong>
|
||||
<A href={assignment.href} class={styles.manageLink}>
|
||||
Open review
|
||||
</A>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTeacherClassroomDetail;
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { DashboardHomeShellData } from "../shared/dashboard-types";
|
||||
|
||||
export type TeacherClassroomDetailStudentItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
initials: string;
|
||||
statusLabel: string;
|
||||
redoCountLabel: string;
|
||||
note: string;
|
||||
href: string;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type TeacherClassroomRedoAssignmentItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
statusLabel: string;
|
||||
statusTone: "review" | "progress" | "muted" | "success";
|
||||
dueLabel: string;
|
||||
progressLabel: string;
|
||||
nextStepLabel: string;
|
||||
note: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type TeacherClassroomDetailData = {
|
||||
shell: DashboardHomeShellData;
|
||||
classroom: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
codeLabel: string;
|
||||
stats: Array<{ label: string; value: string; note: string }>;
|
||||
};
|
||||
students: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: TeacherClassroomDetailStudentItem[];
|
||||
};
|
||||
selectedStudent: {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
note: string;
|
||||
};
|
||||
redoAssignments: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: TeacherClassroomRedoAssignmentItem[];
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.heroCard,
|
||||
.classroomCard,
|
||||
.emptyState {
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-3xl);
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 1.1rem;
|
||||
}
|
||||
|
||||
.heroCard {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--surface-panel) 86%, var(--surface-info) 14%), var(--surface-panel)),
|
||||
var(--surface-panel);
|
||||
|
||||
@include respond(desktop-md) {
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.8fr);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.45rem, 2vw, 2.1rem);
|
||||
line-height: 1.06;
|
||||
max-width: 18ch;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
color: var(--text-muted);
|
||||
max-width: 58ch;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.heroStats {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.statCard {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
padding: 0.95rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-panel-strong);
|
||||
border: 1px solid var(--border-soft);
|
||||
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 1.55rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
|
||||
@media (min-width: 1100px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.classroomCard {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
background: var(--surface-panel-strong);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 0.8rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.08rem;
|
||||
margin-bottom: 0.18rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.codePill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem 0.66rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.42rem 0.68rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--surface-info-soft-tint) 70%, white 30%);
|
||||
color: var(--info);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.reviewRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding-top: 0.2rem;
|
||||
|
||||
strong {
|
||||
color: var(--text-main);
|
||||
}
|
||||
}
|
||||
|
||||
.manageLink {
|
||||
padding: 0.66rem 0.9rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-info);
|
||||
color: var(--info);
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-info-soft);
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: var(--surface-info-tint);
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
color: var(--text-muted);
|
||||
border-style: dashed;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.classroomDetailMetaRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.classroomDetailLayout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@media (min-width: 1100px) {
|
||||
grid-template-columns: minmax(18rem, 0.8fr) minmax(0, 1.2fr);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.detailPanel,
|
||||
.studentCard,
|
||||
.selectedStudentCard,
|
||||
.redoAssignmentCard {
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-3xl);
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.detailPanel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detailPanelHeader {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.studentList,
|
||||
.redoAssignmentList {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.studentCard {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
background: var(--surface-panel-strong);
|
||||
border-color: var(--border-soft);
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
transform 140ms ease,
|
||||
box-shadow 140ms ease;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--border-info-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.studentCardSelected {
|
||||
border-color: var(--border-info-soft);
|
||||
background: color-mix(in srgb, var(--surface-info) 12%, var(--surface-panel-strong) 88%);
|
||||
}
|
||||
|
||||
.studentCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.studentAvatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-info);
|
||||
color: var(--info);
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.studentNote {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.selectedStudentCard {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
background: color-mix(in srgb, var(--surface-info) 12%, var(--surface-panel) 88%);
|
||||
border-color: var(--border-info-soft);
|
||||
|
||||
p,
|
||||
small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.statusPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.42rem 0.72rem;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.statusPillReview {
|
||||
background: color-mix(in srgb, var(--surface-warning) 24%, white 76%);
|
||||
color: var(--warning-strong);
|
||||
border-color: color-mix(in srgb, var(--warning) 26%, var(--border-soft) 74%);
|
||||
}
|
||||
|
||||
.statusPillProgress {
|
||||
background: color-mix(in srgb, var(--surface-info) 24%, white 76%);
|
||||
color: var(--info);
|
||||
border-color: color-mix(in srgb, var(--info) 26%, var(--border-soft) 74%);
|
||||
}
|
||||
|
||||
.statusPillMuted {
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.statusPillSuccess {
|
||||
background: color-mix(in srgb, var(--surface-success) 20%, white 80%);
|
||||
color: var(--success-strong);
|
||||
border-color: color-mix(in srgb, var(--success) 24%, var(--border-soft) 76%);
|
||||
}
|
||||
|
||||
.redoAssignmentCard {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
background: var(--surface-panel-strong);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { For, Show, type Component } from "solid-js";
|
||||
import { getDashboardTeacherClassroomHref } from "../../../lib/routes";
|
||||
import type { TeacherDashboardHomeData } from "./dashboard-teacher-home.data";
|
||||
import styles from "./dashboard-teacher-classrooms.module.scss";
|
||||
|
||||
type DashboardTeacherClassroomsProps = {
|
||||
data: TeacherDashboardHomeData;
|
||||
};
|
||||
|
||||
const DashboardTeacherClassrooms: Component<DashboardTeacherClassroomsProps> = (props) => {
|
||||
return (
|
||||
<section class={styles.page}>
|
||||
<article class={styles.heroCard}>
|
||||
<div class={styles.heroCopy}>
|
||||
<p class={styles.eyebrow}>Classrooms</p>
|
||||
<h1>Keep each class organised without crowding the home dashboard</h1>
|
||||
<p>{props.data.classrooms.description}</p>
|
||||
</div>
|
||||
|
||||
<div class={styles.heroStats}>
|
||||
<div class={styles.statCard}>
|
||||
<span>Classrooms</span>
|
||||
<strong>{props.data.classrooms.items.length}</strong>
|
||||
</div>
|
||||
<div class={styles.statCard}>
|
||||
<span>Students</span>
|
||||
<strong>{props.data.overview.stats.find((stat) => stat.label === "Students")?.value ?? "0"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<Show when={props.data.classrooms.items.length} fallback={<section class={styles.emptyState}>No classrooms are connected to this teacher yet.</section>}>
|
||||
<div class={styles.grid}>
|
||||
<For each={props.data.classrooms.items}>
|
||||
{(item) => (
|
||||
<article class={styles.classroomCard}>
|
||||
<div class={styles.cardHeader}>
|
||||
<div>
|
||||
<h2>{item.name}</h2>
|
||||
<p>{item.note}</p>
|
||||
</div>
|
||||
<span class={styles.codePill}>{item.codeLabel}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.metaRow}>
|
||||
<span>{item.studentCountLabel}</span>
|
||||
<span>{item.assignmentCountLabel}</span>
|
||||
<span>{item.liveLabel}</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.reviewRow}>
|
||||
<strong>{item.reviewLabel}</strong>
|
||||
<A href={getDashboardTeacherClassroomHref(item.id)} class={styles.manageLink}>
|
||||
Open classroom
|
||||
</A>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTeacherClassrooms;
|
||||
@@ -0,0 +1,341 @@
|
||||
// Path: Frontend/src/components/dashboard/dashboard-teacher-home.data.ts
|
||||
|
||||
import {
|
||||
getAssignmentReviewHref,
|
||||
getDashboardAssignmentsHref,
|
||||
getDashboardClassroomsHref,
|
||||
getDashboardHomeHref,
|
||||
getDashboardMessagesHref,
|
||||
getDashboardSettingsHref,
|
||||
} from "../../../lib/routes";
|
||||
import { apiFetchJson } from "../../../lib/api";
|
||||
import { teacherDashboardLabels } from "../../../content/dashboard-labels";
|
||||
import type {
|
||||
ApiAssignment,
|
||||
ApiClassroom,
|
||||
ApiListResponse,
|
||||
ApiReviewQueueItem,
|
||||
ApiReviewSummary,
|
||||
ApiStudent,
|
||||
} from "../../../lib/api-types";
|
||||
import type { DashboardHomeShellData, TopbarMessageItem } from "../shared/dashboard-types";
|
||||
|
||||
type TeacherOverviewStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
type TeacherOverviewAction = {
|
||||
label: string;
|
||||
description: string;
|
||||
href: string;
|
||||
tone: "primary" | "secondary";
|
||||
};
|
||||
|
||||
type TeacherReviewQueueItem = {
|
||||
studentName: string;
|
||||
assignmentTitle: string;
|
||||
classroomName: string;
|
||||
progressLabel: string;
|
||||
statusLabel: string;
|
||||
timestampLabel: string;
|
||||
href: string;
|
||||
initials: string;
|
||||
};
|
||||
|
||||
type TeacherAssignmentCard = {
|
||||
id: number;
|
||||
title: string;
|
||||
classroomName: string;
|
||||
statusLabel: string;
|
||||
dueLabel: string;
|
||||
queueLabel: string;
|
||||
completionLabel: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type TeacherClassroomCard = {
|
||||
id: number;
|
||||
name: string;
|
||||
codeLabel: string;
|
||||
studentCountLabel: string;
|
||||
assignmentCountLabel: string;
|
||||
reviewLabel: string;
|
||||
liveLabel: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type TeacherDashboardHomeData = {
|
||||
shell: DashboardHomeShellData;
|
||||
overview: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
stats: TeacherOverviewStat[];
|
||||
actions: TeacherOverviewAction[];
|
||||
};
|
||||
reviewQueue: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: TeacherReviewQueueItem[];
|
||||
};
|
||||
assignmentFocus: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: TeacherAssignmentCard[];
|
||||
};
|
||||
classrooms: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: TeacherClassroomCard[];
|
||||
};
|
||||
};
|
||||
|
||||
const initialsFor = (name: string) =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
const compact = (value: number) => new Intl.NumberFormat("en-GB", { notation: "compact", maximumFractionDigits: 1 }).format(value);
|
||||
|
||||
const formatShortDate = (value: string | null | undefined) => {
|
||||
if (!value) return "No due date";
|
||||
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
const formatRelativeTime = (value: string | null | undefined) => {
|
||||
if (!value) return "No recent updates";
|
||||
|
||||
const deltaMs = Date.now() - new Date(value).getTime();
|
||||
const deltaMinutes = Math.max(0, Math.round(deltaMs / (1000 * 60)));
|
||||
if (deltaMinutes < 60) return `${deltaMinutes || 1}m ago`;
|
||||
const deltaHours = Math.round(deltaMinutes / 60);
|
||||
if (deltaHours < 24) return `${deltaHours}h ago`;
|
||||
const deltaDays = Math.round(deltaHours / 24);
|
||||
if (deltaDays < 7) return `${deltaDays}d ago`;
|
||||
return formatShortDate(value);
|
||||
};
|
||||
|
||||
const statusLabelForAssignment = (assignment: ApiAssignment, summary?: ApiReviewSummary) => {
|
||||
if (assignment.status === "draft") return "Draft";
|
||||
if (assignment.status === "closed") return "Closed";
|
||||
if ((summary?.submitted ?? 0) > 0) return "Needs review";
|
||||
if ((summary?.in_progress ?? 0) > 0) return "In progress";
|
||||
return "Assigned";
|
||||
};
|
||||
|
||||
const statusPriorityForAssignment = (assignment: ApiAssignment, summary?: ApiReviewSummary) => {
|
||||
if ((summary?.submitted ?? 0) > 0) return 0;
|
||||
if ((summary?.in_progress ?? 0) > 0) return 1;
|
||||
if (assignment.status === "assigned") return 2;
|
||||
if (assignment.status === "draft") return 3;
|
||||
return 4;
|
||||
};
|
||||
|
||||
const sortByDueSoonest = (left: ApiAssignment, right: ApiAssignment) => {
|
||||
const leftTime = left.due_at ? new Date(left.due_at).getTime() : Number.POSITIVE_INFINITY;
|
||||
const rightTime = right.due_at ? new Date(right.due_at).getTime() : Number.POSITIVE_INFINITY;
|
||||
return leftTime - rightTime;
|
||||
};
|
||||
|
||||
export const getTeacherDashboardHomeData = async (teacherId: number): Promise<TeacherDashboardHomeData> => {
|
||||
const [assignmentResponse, classroomResponse] = await Promise.all([
|
||||
apiFetchJson<ApiListResponse<ApiAssignment>>(`/api/teachers/${teacherId}/assignments`),
|
||||
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${teacherId}/classrooms`),
|
||||
]);
|
||||
|
||||
const assignments = assignmentResponse.data;
|
||||
const classrooms = classroomResponse.data;
|
||||
|
||||
const [studentsByClassroomEntries, reviewSummaryEntries, reviewQueueEntries] = await Promise.all([
|
||||
Promise.all(classrooms.map(async (classroom) => [classroom.id, (await apiFetchJson<ApiListResponse<ApiStudent>>(`/api/classrooms/${classroom.id}/students`)).data] as const)),
|
||||
Promise.all(assignments.map(async (assignment) => [assignment.id, await apiFetchJson<ApiReviewSummary>(`/api/assignments/${assignment.id}/review-summary`)] as const)),
|
||||
Promise.all(assignments.map(async (assignment) => [assignment.id, (await apiFetchJson<ApiListResponse<ApiReviewQueueItem>>(`/api/assignments/${assignment.id}/review?status=submitted`)).data] as const)),
|
||||
]);
|
||||
|
||||
const studentsByClassroom = new Map(studentsByClassroomEntries);
|
||||
const reviewSummaryByAssignment = new Map(reviewSummaryEntries);
|
||||
const reviewQueueByAssignment = new Map(reviewQueueEntries);
|
||||
const classroomById = new Map(classrooms.map((classroom) => [classroom.id, classroom]));
|
||||
|
||||
const uniqueStudentIds = new Set<number>();
|
||||
for (const students of studentsByClassroom.values()) {
|
||||
for (const student of students) uniqueStudentIds.add(student.id);
|
||||
}
|
||||
|
||||
const liveAssignments = assignments.filter((assignment) => assignment.status === "assigned");
|
||||
const activeAssignments = assignments.filter((assignment) => assignment.status !== "closed");
|
||||
const draftAssignments = assignments.filter((assignment) => assignment.status === "draft");
|
||||
const needsReviewCount = assignments.reduce((total, assignment) => total + (reviewSummaryByAssignment.get(assignment.id)?.submitted ?? 0), 0);
|
||||
const reviewedCount = assignments.reduce((total, assignment) => total + (reviewSummaryByAssignment.get(assignment.id)?.reviewed ?? 0), 0);
|
||||
const assignedCount = assignments.reduce((total, assignment) => total + (reviewSummaryByAssignment.get(assignment.id)?.total_assigned ?? 0), 0);
|
||||
const reviewCoverage = assignedCount > 0 ? Math.round((reviewedCount / assignedCount) * 100) : 0;
|
||||
|
||||
const nextDueAssignment = liveAssignments
|
||||
.slice()
|
||||
.sort(sortByDueSoonest)
|
||||
.find((assignment) => assignment.due_at);
|
||||
|
||||
const reviewQueueItems = assignments
|
||||
.flatMap((assignment) => {
|
||||
const classroom = classroomById.get(assignment.classroom_id);
|
||||
return (reviewQueueByAssignment.get(assignment.id) ?? []).map((item) => ({
|
||||
studentName: item.student_name,
|
||||
assignmentTitle: assignment.title,
|
||||
classroomName: classroom?.name ?? "Classroom",
|
||||
progressLabel: `${item.reviewed_questions}/${item.total_questions} reviewed`,
|
||||
statusLabel: item.review_status.replaceAll("_", " "),
|
||||
timestampLabel: formatRelativeTime(item.latest_submitted_at ?? item.latest_reviewed_at),
|
||||
sortTimestamp: item.latest_submitted_at ?? item.latest_reviewed_at ?? "",
|
||||
href: getAssignmentReviewHref("teacher", assignment.id),
|
||||
initials: initialsFor(item.student_name),
|
||||
}));
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftWeight = left.sortTimestamp ? new Date(left.sortTimestamp).getTime() : 0;
|
||||
const rightWeight = right.sortTimestamp ? new Date(right.sortTimestamp).getTime() : 0;
|
||||
return rightWeight - leftWeight;
|
||||
})
|
||||
.map(({ sortTimestamp: _sortTimestamp, ...item }) => item)
|
||||
.slice(0, 6);
|
||||
|
||||
const assignmentHealthItems = (activeAssignments.length > 0 ? activeAssignments : assignments)
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftSummary = reviewSummaryByAssignment.get(left.id);
|
||||
const rightSummary = reviewSummaryByAssignment.get(right.id);
|
||||
return statusPriorityForAssignment(left, leftSummary) - statusPriorityForAssignment(right, rightSummary) || sortByDueSoonest(left, right);
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map((assignment) => {
|
||||
const summary = reviewSummaryByAssignment.get(assignment.id);
|
||||
const classroom = classroomById.get(assignment.classroom_id);
|
||||
const totalAssigned = summary?.total_assigned ?? 0;
|
||||
const reviewed = summary?.reviewed ?? 0;
|
||||
const submitted = summary?.submitted ?? 0;
|
||||
const inProgress = summary?.in_progress ?? 0;
|
||||
const completion = totalAssigned > 0 ? Math.round((reviewed / totalAssigned) * 100) : 0;
|
||||
|
||||
return {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
classroomName: classroom?.name ?? "Classroom",
|
||||
statusLabel: statusLabelForAssignment(assignment, summary),
|
||||
dueLabel: assignment.status === "draft" ? "Publish when ready" : formatShortDate(assignment.due_at),
|
||||
queueLabel:
|
||||
assignment.status === "draft"
|
||||
? "Not published yet"
|
||||
: submitted > 0
|
||||
? `${submitted} submission${submitted === 1 ? "" : "s"} waiting`
|
||||
: `${inProgress} in progress`,
|
||||
completionLabel: totalAssigned > 0 ? `${completion}% reviewed` : "No student attempts yet",
|
||||
href: submitted > 0 ? getAssignmentReviewHref("teacher", assignment.id) : getDashboardAssignmentsHref("teacher"),
|
||||
};
|
||||
});
|
||||
|
||||
const classroomCards = classrooms.map((classroom) => {
|
||||
const students = studentsByClassroom.get(classroom.id) ?? [];
|
||||
const classroomAssignments = assignments.filter((assignment) => assignment.classroom_id === classroom.id);
|
||||
const liveCount = classroomAssignments.filter((assignment) => assignment.status === "assigned").length;
|
||||
const classroomReviewCount = classroomAssignments.reduce((total, assignment) => total + (reviewSummaryByAssignment.get(assignment.id)?.submitted ?? 0), 0);
|
||||
const nextDue = classroomAssignments
|
||||
.filter((assignment) => assignment.status === "assigned")
|
||||
.slice()
|
||||
.sort(sortByDueSoonest)
|
||||
.find((assignment) => assignment.due_at);
|
||||
|
||||
return {
|
||||
id: classroom.id,
|
||||
name: classroom.name,
|
||||
codeLabel: classroom.code ? `Invite code ${classroom.code}` : "Invite code not set",
|
||||
studentCountLabel: `${students.length} student${students.length === 1 ? "" : "s"}`,
|
||||
assignmentCountLabel: `${classroomAssignments.length} assignment${classroomAssignments.length === 1 ? "" : "s"}`,
|
||||
reviewLabel: classroomReviewCount > 0 ? `${classroomReviewCount} waiting for review` : "Nothing waiting for review",
|
||||
liveLabel: `${liveCount} live assignment${liveCount === 1 ? "" : "s"}`,
|
||||
note: nextDue ? `${teacherDashboardLabels.common.nextDue} ${formatShortDate(nextDue.due_at)}` : "No due dates scheduled yet",
|
||||
};
|
||||
});
|
||||
|
||||
const shell: DashboardHomeShellData = {
|
||||
classroomSummary: {
|
||||
name: classrooms.length > 0 ? `${classrooms.length} classroom${classrooms.length === 1 ? "" : "s"}` : "Teacher workspace",
|
||||
tutorName: "Teacher workspace",
|
||||
tutorRole: "Teaching dashboard",
|
||||
tutorInitials: "TA",
|
||||
},
|
||||
sidebarLinks: [
|
||||
{ label: "Home", detail: "Teaching overview", icon: "OV", href: getDashboardHomeHref("teacher"), active: true },
|
||||
{ label: "Assignments", detail: "Homework setup", icon: "AS", href: getDashboardAssignmentsHref("teacher") },
|
||||
{ label: "Classrooms", detail: "Roster & codes", icon: "CL", href: getDashboardClassroomsHref("teacher") },
|
||||
{ label: "Messages", detail: "Inbox & follow-up", icon: "MS", href: getDashboardMessagesHref("teacher") },
|
||||
{ label: "Settings", detail: "Profile & preferences", icon: "ST", href: getDashboardSettingsHref("teacher") },
|
||||
],
|
||||
sidebarSupport: {
|
||||
avatars: ["RQ", "AS", "CL"],
|
||||
title: "Review queue",
|
||||
description: needsReviewCount > 0 ? `${needsReviewCount} submission${needsReviewCount === 1 ? " is" : "s are"} ready for review.` : "Everything is caught up right now.",
|
||||
buttonLabel: "Open assignments",
|
||||
buttonHref: getDashboardAssignmentsHref("teacher"),
|
||||
},
|
||||
topbarSummary: {
|
||||
searchPlaceholder: "Search classes, assignments, or students",
|
||||
profileName: "Teacher",
|
||||
profileRole: "Teaching dashboard",
|
||||
profileBadge: "TA",
|
||||
notificationCount: 0,
|
||||
messageCount: 0,
|
||||
},
|
||||
topbarNotifications: [],
|
||||
topbarMessages: [] as TopbarMessageItem[],
|
||||
};
|
||||
|
||||
return {
|
||||
shell,
|
||||
overview: {
|
||||
eyebrow: "Teacher dashboard",
|
||||
title: needsReviewCount > 0 ? "Start with student work that needs feedback" : "Your teaching dashboard is ready for the next move",
|
||||
description:
|
||||
needsReviewCount > 0
|
||||
? `You have ${needsReviewCount} submission${needsReviewCount === 1 ? "" : "s"} waiting. Clear the review queue first, then head into assignments to publish or adjust homework.`
|
||||
: nextDueAssignment != null
|
||||
? `${teacherDashboardLabels.common.nextDue}: ${nextDueAssignment.title} on ${formatShortDate(nextDueAssignment.due_at)}. Use this page to decide whether to review work, publish drafts, or check classrooms.`
|
||||
: draftAssignments.length > 0
|
||||
? `You have ${draftAssignments.length} draft assignment${draftAssignments.length === 1 ? "" : "s"} ready for the next publishing pass.`
|
||||
: "Use this page to jump into reviews, homework setup, and classroom organisation without scanning extra noise.",
|
||||
stats: [
|
||||
{ label: teacherDashboardLabels.home.needsReview, value: compact(needsReviewCount), note: needsReviewCount > 0 ? "Students waiting for feedback" : "No submissions waiting" },
|
||||
{ label: teacherDashboardLabels.home.draftsToPublish, value: compact(draftAssignments.length), note: liveAssignments.length > 0 ? `${compact(liveAssignments.length)} live right now` : "No live homework yet" },
|
||||
{ label: teacherDashboardLabels.common.students, value: compact(uniqueStudentIds.size), note: `${classrooms.length} classroom${classrooms.length === 1 ? "" : "s"} in rotation` },
|
||||
{ label: teacherDashboardLabels.home.reviewCoverage, value: `${reviewCoverage}%`, note: `${compact(reviewedCount)} of ${compact(assignedCount)} assigned reviews completed` },
|
||||
],
|
||||
actions: [
|
||||
{ label: needsReviewCount > 0 ? "Review submissions" : "Open assignments", description: needsReviewCount > 0 ? "Jump straight into student work waiting for feedback." : "Manage drafts and live homework from one place.", href: getDashboardAssignmentsHref("teacher"), tone: "primary" },
|
||||
{ label: "View classrooms", description: "Check rosters, invite codes, and classroom load.", href: getDashboardClassroomsHref("teacher"), tone: "secondary" },
|
||||
],
|
||||
},
|
||||
reviewQueue: {
|
||||
title: "Review queue",
|
||||
description: "These are the freshest student submissions waiting for your attention right now.",
|
||||
items: reviewQueueItems,
|
||||
},
|
||||
assignmentFocus: {
|
||||
title: "Assignments to watch next",
|
||||
description: "Keep homework setup and live teaching work visible without turning the home page into a full management board.",
|
||||
items: assignmentHealthItems,
|
||||
},
|
||||
classrooms: {
|
||||
title: "Classrooms",
|
||||
description: "See each class at a glance with roster size, invite code, and workload signals.",
|
||||
items: classroomCards,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,478 @@
|
||||
.teacherPage {
|
||||
display: grid;
|
||||
gap: 1.15rem;
|
||||
}
|
||||
|
||||
.heroCard,
|
||||
.sectionCard {
|
||||
background: var(--surface-panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-3xl);
|
||||
box-shadow: var(--shadow-soft);
|
||||
padding: 1.2rem;
|
||||
min-width: 0;
|
||||
|
||||
@include respond(desktop-sm) {
|
||||
padding: 1.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
.heroCard {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--surface-panel) 84%, var(--surface-info) 16%), var(--surface-panel)),
|
||||
var(--surface-panel);
|
||||
|
||||
@include respond(desktop-md) {
|
||||
grid-template-columns: minmax(0, 1.28fr) minmax(18rem, 0.92fr);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.sectionEyebrow {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.heroSummary {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
min-width: 0;
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.5rem, 2vw, 2.2rem);
|
||||
line-height: 1.05;
|
||||
max-width: 18ch;
|
||||
}
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
color: var(--text-muted);
|
||||
max-width: 58ch;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
|
||||
.heroActions {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
|
||||
@include respond(compact) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.heroAction {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-soft);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
transform 160ms ease;
|
||||
|
||||
strong,
|
||||
span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.heroActionPrimary {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--surface-info) 82%, white 18%), var(--surface-info));
|
||||
border-color: var(--border-info-emphasis);
|
||||
|
||||
strong {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
span {
|
||||
color: color-mix(in srgb, var(--info) 70%, var(--text-muted) 30%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-info-tint);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--border-info-strong);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.heroActionSecondary {
|
||||
background: var(--surface-panel-strong);
|
||||
border-color: var(--border-soft);
|
||||
|
||||
strong {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-soft);
|
||||
border-color: color-mix(in srgb, var(--border-soft) 72%, var(--info) 28%);
|
||||
}
|
||||
}
|
||||
|
||||
.statCardsGrid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.statCardsGrid {
|
||||
@include respond(desktop-md) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.reviewQueueList,
|
||||
.assignmentList {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.assignmentList {
|
||||
@media (min-width: 1280px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.assignmentList {
|
||||
@media (min-width: 1680px) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.statCard {
|
||||
padding: 1.05rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-panel-strong) 88%, white 12%), var(--surface-panel-strong));
|
||||
border: 1px solid var(--border-soft);
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
align-content: start;
|
||||
min-height: 8.8rem;
|
||||
|
||||
p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@include respond(desktop-md) {
|
||||
min-height: 9.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 1.7rem;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
|
||||
@include respond(desktop-md) {
|
||||
font-size: 1.76rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
font-size: 1.82rem;
|
||||
}
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1.1rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-muted);
|
||||
max-width: 62ch;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.listCard,
|
||||
.assignmentCard {
|
||||
display: grid;
|
||||
gap: 0.95rem;
|
||||
padding: 1.05rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-soft);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-panel-strong) 86%, white 14%), var(--surface-panel-strong));
|
||||
min-width: 0;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.listCard {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
|
||||
@media (min-width: 760px) {
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.8fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: var(--surface-info-tint);
|
||||
border-color: var(--border-info-emphasis);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.detailBlock {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detailKicker {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.listLead {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.listDetails {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
align-content: center;
|
||||
|
||||
small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-info);
|
||||
color: var(--info);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.itemCopy,
|
||||
.assignmentCopy {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
|
||||
strong,
|
||||
p,
|
||||
small {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
p,
|
||||
small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
|
||||
@media (min-width: 760px) {
|
||||
justify-items: end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--border-soft);
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.assignmentMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.1rem;
|
||||
}
|
||||
|
||||
.assignmentCard {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.assignmentCard {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.assignmentCard {
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--border-soft) 74%, var(--info) 26%);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.assignmentHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.statusPill,
|
||||
.metaPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.38rem 0.62rem;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statusPill {
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.metaPill {
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-soft);
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.statusToneReview {
|
||||
background: var(--surface-warning-tint);
|
||||
color: var(--warning-text);
|
||||
border-color: var(--border-info-strong);
|
||||
}
|
||||
|
||||
.statusToneProgress {
|
||||
background: var(--surface-info-soft-tint);
|
||||
color: var(--info);
|
||||
border-color: var(--border-info-strong);
|
||||
}
|
||||
|
||||
.statusToneDraft {
|
||||
background: color-mix(in srgb, var(--surface-soft) 84%, white 16%);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.statusToneLive {
|
||||
background: var(--surface-success-tint);
|
||||
color: var(--success-text);
|
||||
border-color: var(--border-success-soft);
|
||||
}
|
||||
|
||||
.statusToneNeutral {
|
||||
background: var(--surface-soft);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.assignmentLink {
|
||||
justify-self: start;
|
||||
padding: 0.68rem 0.92rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-info);
|
||||
color: var(--info);
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-info-soft);
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: var(--surface-info-tint);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.heroCard,
|
||||
.sectionCard {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-2xl);
|
||||
}
|
||||
|
||||
.statCard,
|
||||
.listCard,
|
||||
.assignmentCard {
|
||||
padding: 0.95rem;
|
||||
}
|
||||
|
||||
.assignmentHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { For, Show, type Component } from "solid-js";
|
||||
import type { TeacherDashboardHomeData } from "./dashboard-teacher-home.data";
|
||||
import styles from "./dashboard-teacher-home.module.scss";
|
||||
|
||||
type DashboardTeacherHomeProps = {
|
||||
data: TeacherDashboardHomeData;
|
||||
};
|
||||
|
||||
const statusToneClass = (value: string) => {
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized.includes("review")) return styles.statusToneReview;
|
||||
if (normalized.includes("progress")) return styles.statusToneProgress;
|
||||
if (normalized.includes("draft")) return styles.statusToneDraft;
|
||||
if (normalized.includes("closed")) return styles.statusToneNeutral;
|
||||
return styles.statusToneLive;
|
||||
};
|
||||
|
||||
const DashboardTeacherHome: Component<DashboardTeacherHomeProps> = (props) => {
|
||||
return (
|
||||
<div class={styles.teacherPage}>
|
||||
<section class={styles.heroCard}>
|
||||
<div class={styles.heroSummary}>
|
||||
<p class={styles.eyebrow}>{props.data.overview.eyebrow}</p>
|
||||
<h1>{props.data.overview.title}</h1>
|
||||
<p class={styles.heroDescription}>{props.data.overview.description}</p>
|
||||
|
||||
<div class={styles.heroActions}>
|
||||
<For each={props.data.overview.actions}>
|
||||
{(action) => (
|
||||
<A href={action.href} classList={{ [styles.heroAction]: true, [styles.heroActionPrimary]: action.tone === "primary", [styles.heroActionSecondary]: action.tone === "secondary" }}>
|
||||
<strong>{action.label}</strong>
|
||||
<span>{action.description}</span>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.statCardsGrid}>
|
||||
<For each={props.data.overview.stats}>
|
||||
{(stat, index) => (
|
||||
<article class={styles.statCard}>
|
||||
<span class={styles.statLabel}>{stat.label}</span>
|
||||
<strong class={styles.statValue}>{stat.value}</strong>
|
||||
<Show when={index() === 0}>
|
||||
<span class={`${styles.statusPill} ${styles.statusToneReview}`}>Priority now</span>
|
||||
</Show>
|
||||
<p>{stat.note}</p>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class={styles.sectionCard}>
|
||||
<header class={styles.sectionHeader}>
|
||||
<p class={styles.sectionEyebrow}>Priority</p>
|
||||
<h2>{props.data.reviewQueue.title}</h2>
|
||||
<p>{props.data.reviewQueue.description}</p>
|
||||
</header>
|
||||
|
||||
<Show when={props.data.reviewQueue.items.length} fallback={<div class={styles.emptyState}>No student submissions are waiting right now.</div>}>
|
||||
<div class={styles.reviewQueueList}>
|
||||
<For each={props.data.reviewQueue.items}>
|
||||
{(item) => (
|
||||
<A href={item.href} class={styles.listCard}>
|
||||
<div class={styles.listLead}>
|
||||
<span class={styles.avatar}>{item.initials}</span>
|
||||
<div class={styles.itemCopy}>
|
||||
<strong>{item.studentName}</strong>
|
||||
<p>{item.assignmentTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class={styles.listDetails}>
|
||||
<div class={styles.detailBlock}>
|
||||
<span class={styles.detailKicker}>Classroom</span>
|
||||
<small class={styles.detailValue}>{item.classroomName}</small>
|
||||
</div>
|
||||
<div class={styles.detailBlock}>
|
||||
<span class={styles.detailKicker}>Progress</span>
|
||||
<small class={styles.detailValue}>{item.progressLabel}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class={styles.itemMeta}>
|
||||
<span class={`${styles.statusPill} ${statusToneClass(item.statusLabel)}`}>{item.statusLabel}</span>
|
||||
<small>{item.timestampLabel}</small>
|
||||
</div>
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<section class={styles.sectionCard}>
|
||||
<header class={styles.sectionHeader}>
|
||||
<p class={styles.sectionEyebrow}>Snapshot</p>
|
||||
<h2>{props.data.assignmentFocus.title}</h2>
|
||||
<p>{props.data.assignmentFocus.description}</p>
|
||||
</header>
|
||||
|
||||
<Show when={props.data.assignmentFocus.items.length} fallback={<div class={styles.emptyState}>Assignments will appear here once this teacher has active work live in the backend.</div>}>
|
||||
<div class={styles.assignmentList}>
|
||||
<For each={props.data.assignmentFocus.items}>
|
||||
{(item) => (
|
||||
<article class={styles.assignmentCard}>
|
||||
<div class={styles.assignmentHeader}>
|
||||
<div class={styles.assignmentCopy}>
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.classroomName}</p>
|
||||
</div>
|
||||
<span class={`${styles.statusPill} ${statusToneClass(item.statusLabel)}`}>{item.statusLabel}</span>
|
||||
</div>
|
||||
<div class={styles.assignmentMeta}>
|
||||
<span class={styles.metaPill}>{item.dueLabel}</span>
|
||||
<span class={styles.metaPill}>{item.queueLabel}</span>
|
||||
<span class={styles.metaPill}>{item.completionLabel}</span>
|
||||
</div>
|
||||
<A href={item.href} class={styles.assignmentLink}>
|
||||
Open now
|
||||
</A>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTeacherHome;
|
||||
64
Frontend/src/content/dashboard-labels.ts
Normal file
64
Frontend/src/content/dashboard-labels.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export const studentDashboardLabels = {
|
||||
spotlight: {
|
||||
assignmentsDone: "Assignments done",
|
||||
currentFocus: "Current focus",
|
||||
bestTopic: "Best topic",
|
||||
},
|
||||
quickStats: {
|
||||
nextDue: "Next due",
|
||||
latestUpdate: "Latest update",
|
||||
noDeadline: "No deadline",
|
||||
dueToday: "Today",
|
||||
overdue: "Overdue",
|
||||
},
|
||||
activity: {
|
||||
completed: "Completed",
|
||||
answerCoverage: "Answer coverage",
|
||||
},
|
||||
insights: {
|
||||
reviewed: "Reviewed",
|
||||
answered: "Answered",
|
||||
remaining: "Remaining",
|
||||
},
|
||||
progress: {
|
||||
reviewed: "Reviewed",
|
||||
coverage: "Coverage",
|
||||
strongest: "Strongest",
|
||||
latestUpdate: "Latest update",
|
||||
},
|
||||
practice: {
|
||||
focusTopic: "Focus topic",
|
||||
topicCoverage: "Topic coverage",
|
||||
bestTopic: "Best topic",
|
||||
latestUpdate: "Latest update",
|
||||
},
|
||||
assignments: {
|
||||
liveNow: "Live now",
|
||||
completed: "Completed",
|
||||
averageReviewed: "Average reviewed",
|
||||
nextDue: "Next due",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const teacherDashboardLabels = {
|
||||
common: {
|
||||
nextDue: "Next due",
|
||||
students: "Students",
|
||||
},
|
||||
home: {
|
||||
needsReview: "Needs review",
|
||||
draftsToPublish: "Drafts to publish",
|
||||
reviewCoverage: "Review coverage",
|
||||
},
|
||||
assignments: {
|
||||
needsReview: "Needs review",
|
||||
liveAssignments: "Live assignments",
|
||||
studentsCovered: "Students covered",
|
||||
reviewCoverage: "Review coverage",
|
||||
},
|
||||
classroomDetail: {
|
||||
students: "Students",
|
||||
redoAssignments: "Redo assignments",
|
||||
waitingReview: "Waiting review",
|
||||
},
|
||||
} as const;
|
||||
232
Frontend/src/content/ui-copy.ts
Normal file
232
Frontend/src/content/ui-copy.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
export const dashboardUiCopy = {
|
||||
studentAssignmentsFocus: {
|
||||
eyebrow: "Assignments",
|
||||
title: "Your assignment hub",
|
||||
description: "Stay in dashboard mode to see what is live, what needs finishing, and what is ready for review.",
|
||||
loading: "Loading assignments from the backend…",
|
||||
empty: "No assignments are available for this student yet.",
|
||||
},
|
||||
studentProgress: {
|
||||
eyebrow: "Progress",
|
||||
title: "Your live learning progress",
|
||||
},
|
||||
studentPractice: {
|
||||
eyebrow: "Practice",
|
||||
title: "Your live practice space",
|
||||
buttons: {
|
||||
viewProgress: "View progress",
|
||||
seeAssignments: "See assignments",
|
||||
openDashboard: "Open dashboard",
|
||||
},
|
||||
cards: {
|
||||
firstPassTitle: "Take one clean first pass",
|
||||
},
|
||||
},
|
||||
teacherAssignments: {
|
||||
eyebrow: {
|
||||
all: "Assignments",
|
||||
closed: "Closed homework",
|
||||
},
|
||||
title: {
|
||||
all: "Homework setup and assignment management",
|
||||
closed: "Past homework assignments",
|
||||
},
|
||||
panelEyebrow: {
|
||||
all: "Create workspace",
|
||||
closed: "Archive access",
|
||||
},
|
||||
panelTitle: {
|
||||
all: "Create in a separate workspace",
|
||||
closed: "Review closed homework",
|
||||
},
|
||||
actions: {
|
||||
backToAssignments: "Back to assignments",
|
||||
createAssignment: "Create assignment",
|
||||
viewClosedHomework: "View closed homework",
|
||||
backToOverview: "Back to overview",
|
||||
},
|
||||
loading: "Loading teacher assignments from the backend…",
|
||||
empty: {
|
||||
all: "No homework has been created for this teacher yet.",
|
||||
closed: "No closed homework assignments are available yet.",
|
||||
},
|
||||
},
|
||||
teacherAssignmentCreate: {
|
||||
hero: {
|
||||
eyebrow: "Assignment setup",
|
||||
title: "Create homework in a dedicated workspace",
|
||||
backToAssignments: "Back to assignments",
|
||||
teachingOverview: "Teaching overview",
|
||||
},
|
||||
details: {
|
||||
eyebrow: "Create assignment",
|
||||
title: "Create homework for a classroom",
|
||||
selectedSuffix: "selected",
|
||||
classroom: "Classroom",
|
||||
chooseClassroom: "Choose a classroom",
|
||||
passThreshold: "Pass threshold",
|
||||
dueDate: "Due date",
|
||||
assignmentTitle: "Assignment title",
|
||||
assignmentTitlePlaceholder: "Fractions practice set",
|
||||
teacherNotes: "Teacher notes or instructions",
|
||||
teacherNotesPlaceholder: "Add short instructions, what students should focus on, or how you want them to show their work.",
|
||||
},
|
||||
personalized: {
|
||||
title: "Personalized homework generation",
|
||||
enabled: "Mixed generation enabled",
|
||||
disabled: "Classic shared assignment",
|
||||
toggleTitle: "Generate personalized question sets per student",
|
||||
primaryTopic: "Primary topic",
|
||||
primaryDifficulty: "Primary difficulty",
|
||||
totalQuestions: "Total questions per student",
|
||||
personalizedShare: "Personalized share (%)",
|
||||
personalizedDifficulty: "Personalized difficulty",
|
||||
subjectLabel: "Subject label",
|
||||
subjectPlaceholder: "Maths",
|
||||
seed: "Seed (optional)",
|
||||
seedPlaceholder: "Leave blank for a fresh cohort run",
|
||||
},
|
||||
sharedGeneration: {
|
||||
title: "Generate question set",
|
||||
autoSelected: "New questions are auto-selected",
|
||||
topic: "Topic",
|
||||
difficulty: "Difficulty",
|
||||
count: "How many questions",
|
||||
seed: "Seed (optional)",
|
||||
seedPlaceholder: "Leave blank for a fresh set",
|
||||
generating: "Generating…",
|
||||
generateQuestions: "Generate questions",
|
||||
},
|
||||
questionBank: {
|
||||
title: "Question bank",
|
||||
availableSuffix: "available",
|
||||
loading: "Loading your question bank…",
|
||||
empty: "No teacher-authored questions are available yet. Once questions exist, you can attach them here while creating homework.",
|
||||
},
|
||||
submission: {
|
||||
creating: "Creating…",
|
||||
createAssignment: "Create assignment",
|
||||
resetForm: "Reset form",
|
||||
},
|
||||
summary: {
|
||||
title: "Setup summary",
|
||||
classroom: "Classroom",
|
||||
roster: "Roster",
|
||||
questions: "Questions",
|
||||
mode: "Mode",
|
||||
passThreshold: "Pass threshold",
|
||||
delivery: "Delivery",
|
||||
notChosenYet: "Not chosen yet",
|
||||
chooseClassroom: "Choose a classroom",
|
||||
mixedPersonalized: "Mixed personalized",
|
||||
sharedQuestionSet: "Shared question set",
|
||||
liveAssignment: "Live assignment",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const assignmentUiCopy = {
|
||||
studentWork: {
|
||||
solveModes: {
|
||||
justAnswer: "Just answer",
|
||||
stepByStep: "Step by step",
|
||||
solveTogether: "Solve together",
|
||||
handwritten: "Handwritten",
|
||||
},
|
||||
banner: {
|
||||
eyebrow: "Practice submitted",
|
||||
link: "Open review page",
|
||||
},
|
||||
header: {
|
||||
backToReview: "Back to review",
|
||||
titlePrefix: "Work through",
|
||||
submitting: "Submitting and generating AI draft…",
|
||||
submitAssignment: "Submit assignment",
|
||||
submitHint: "This can take a few seconds while we save your work and prepare the draft review.",
|
||||
},
|
||||
navigator: {
|
||||
title: "Question list",
|
||||
answeredSuffix: "answered",
|
||||
remainingSuffix: "left",
|
||||
draftReady: "Draft ready",
|
||||
},
|
||||
workspace: {
|
||||
questionPrefix: "Question",
|
||||
solveMode: "Solve mode",
|
||||
solveModeHint: "Pick the mode that best matches how you want to think through this question.",
|
||||
yourAnswer: "Your answer",
|
||||
answerPlaceholder: "Type your final answer",
|
||||
workingSteps: "Working / steps",
|
||||
stepsPlaceholder: "Write your method here. For example: find a common denominator, simplify, then check your final answer.",
|
||||
helperEyebrow: "Helpful prompt",
|
||||
answerKey: "Answer key",
|
||||
previous: "Previous",
|
||||
saveDraft: "Save draft",
|
||||
saveThisQuestion: "Save this question",
|
||||
saveAndContinue: "Save and continue",
|
||||
lastSubmittedPrefix: "Last submitted",
|
||||
},
|
||||
},
|
||||
teacherReview: {
|
||||
feedback: {
|
||||
title: "Shared assignment feedback",
|
||||
aiFeedback: "AI feedback",
|
||||
noAiFeedback: "No AI feedback captured yet.",
|
||||
teacherFeedback: "Teacher feedback",
|
||||
teacherFeedbackPlaceholder: "Leave one clear summary for the student across the whole assignment",
|
||||
cleanDraft: "Shared feedback matches the last saved version.",
|
||||
dirtyDraftPrefix: "Shared feedback draft is stored locally until you click",
|
||||
},
|
||||
question: {
|
||||
questionPrefix: "Question",
|
||||
studentAnswer: "Student answer",
|
||||
studentSteps: "Student steps and explanation",
|
||||
noSteps: "No written steps or explanation were submitted.",
|
||||
correctAnswer: "Correct answer",
|
||||
noCorrectAnswer: "No correct answer saved yet.",
|
||||
structuredReviewLocked: "Structured review fields will unlock after the student submits an answer.",
|
||||
structuredReview: "Structured review",
|
||||
needsAttention: "Needs attention",
|
||||
weightingNote: "Correctness and question weighting are fixed at full credit. Only understanding varies per response.",
|
||||
issueReason: "Issue reason",
|
||||
issueReasonPlaceholder: "Explain what still needs attention for this response",
|
||||
understandingScore: "Understanding score",
|
||||
confidence: "Confidence",
|
||||
questionTags: "Question tags",
|
||||
noSavedTags: "This question has no saved tags yet. Tags should be added on the draft question, not during review.",
|
||||
inheritedTags: "Inherited from the question setup.",
|
||||
resetDraft: "Reset draft",
|
||||
needsAttentionInDraft: "Needs attention in draft",
|
||||
},
|
||||
saveProgress: {
|
||||
title: "Save all and move to next step",
|
||||
closedFallback: "This assignment is closed, so no further review actions are available.",
|
||||
lockedFallback: "This student has not submitted work yet, so next step is locked for now.",
|
||||
saving: "Saving and continuing...",
|
||||
saveAndNextStep: "Save all and next step",
|
||||
},
|
||||
sidebar: {
|
||||
statusEyebrow: "Assignment status",
|
||||
closeAssignment: "Close assignment",
|
||||
closed: "Closed",
|
||||
readyToClose: "Ready to close",
|
||||
blocked: "Blocked",
|
||||
closing: "Closing assignment...",
|
||||
assignmentClosed: "Assignment closed",
|
||||
queueEyebrow: "Queue",
|
||||
studentsToReview: "Students to review",
|
||||
backToDashboard: "Back to dashboard",
|
||||
queueEmpty: "No student attempts are in the review queue yet.",
|
||||
overviewEyebrow: "Overview",
|
||||
reviewSummary: "Review summary",
|
||||
},
|
||||
status: {
|
||||
needsAttention: "Needs attention",
|
||||
reviewed: "Reviewed",
|
||||
submitted: "Submitted",
|
||||
inProgress: "In progress",
|
||||
noAnswerYet: "No answer yet",
|
||||
needsAttentionDraft: "Needs attention draft",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
191
Frontend/src/context/auth/context.tsx
Normal file
191
Frontend/src/context/auth/context.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
// Path: Frontend/src/context/auth/context.tsx
|
||||
|
||||
import { createContext, createSignal, onMount, useContext, type ParentComponent } from "solid-js";
|
||||
|
||||
export type AuthProfile = {
|
||||
preferred_name: string | null;
|
||||
profile_icon_url: string | null;
|
||||
headline: string | null;
|
||||
bio: string | null;
|
||||
timezone: string | null;
|
||||
locale: string | null;
|
||||
grade_level: string | null;
|
||||
learning_goal: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
type AuthUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
role: "student" | "teacher";
|
||||
full_name: string;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
profile: AuthProfile;
|
||||
};
|
||||
|
||||
type AuthResponse = {
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
type LoginInput = {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe: boolean;
|
||||
};
|
||||
|
||||
type SignupInput = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type UpdateProfileInput = {
|
||||
fullName: string;
|
||||
preferredName: string;
|
||||
profileIconUrl: string;
|
||||
headline: string;
|
||||
bio: string;
|
||||
timezone: string;
|
||||
locale: string;
|
||||
gradeLevel: string;
|
||||
learningGoal: string;
|
||||
};
|
||||
|
||||
type AuthContextType = {
|
||||
user: () => AuthUser | null;
|
||||
isReady: () => boolean;
|
||||
login: (input: LoginInput) => Promise<AuthUser>;
|
||||
signup: (input: SignupInput) => Promise<AuthUser>;
|
||||
updateProfile: (input: UpdateProfileInput) => Promise<AuthUser>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<AuthUser | null>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType>();
|
||||
|
||||
const authRequest = async <T,>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const response = await fetch(path, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
...(init?.body ? { "content-type": "application/json" } : {}),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Request failed: ${response.status}`;
|
||||
try {
|
||||
const payload = (await response.json()) as { message?: string };
|
||||
if (payload.message) message = payload.message;
|
||||
} catch {
|
||||
// ignore malformed error bodies
|
||||
}
|
||||
|
||||
const error = new Error(message);
|
||||
(error as Error & { status?: number }).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
export const AuthProvider: ParentComponent = (props) => {
|
||||
const [user, setUser] = createSignal<AuthUser | null>(null);
|
||||
const [isReady, setIsReady] = createSignal(false);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const response = await authRequest<AuthResponse>("/api/auth/me");
|
||||
setUser(response.user);
|
||||
return response.user;
|
||||
} catch (error) {
|
||||
if ((error as Error & { status?: number }).status === 401) {
|
||||
setUser(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
setIsReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (input: LoginInput) => {
|
||||
const response = await authRequest<AuthResponse>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
remember_me: input.rememberMe,
|
||||
}),
|
||||
});
|
||||
|
||||
setUser(response.user);
|
||||
setIsReady(true);
|
||||
return response.user;
|
||||
};
|
||||
|
||||
const signup = async (input: SignupInput) => {
|
||||
const response = await authRequest<AuthResponse>("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
}),
|
||||
});
|
||||
|
||||
setUser(response.user);
|
||||
setIsReady(true);
|
||||
return response.user;
|
||||
};
|
||||
|
||||
const updateProfile = async (input: UpdateProfileInput) => {
|
||||
const response = await authRequest<AuthResponse>("/api/auth/me", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
full_name: input.fullName,
|
||||
preferred_name: input.preferredName,
|
||||
profile_icon_url: input.profileIconUrl,
|
||||
headline: input.headline,
|
||||
bio: input.bio,
|
||||
timezone: input.timezone,
|
||||
locale: input.locale,
|
||||
grade_level: input.gradeLevel,
|
||||
learning_goal: input.learningGoal,
|
||||
}),
|
||||
});
|
||||
|
||||
setUser(response.user);
|
||||
setIsReady(true);
|
||||
return response.user;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await authRequest<{ status: string }>("/api/auth/logout", { method: "POST" });
|
||||
setUser(null);
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
void refresh();
|
||||
});
|
||||
|
||||
return <AuthContext.Provider value={{ user, isReady, login, signup, updateProfile, logout, refresh }}>{props.children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -5,13 +5,14 @@ import { createHandler, StartServer } from "@solidjs/start/server";
|
||||
|
||||
export default createHandler(() => (
|
||||
<StartServer
|
||||
document={({ assets, children, scripts }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
{assets}
|
||||
document={({ assets, children, scripts }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
{assets}
|
||||
|
||||
<link rel="preload" href="/fonts/Poppins/Poppins-Light.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
<link rel="preload" href="/fonts/Poppins/Poppins-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
|
||||
185
Frontend/src/lib/api-types.ts
Normal file
185
Frontend/src/lib/api-types.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// Path: Frontend/src/lib/api-types.ts
|
||||
|
||||
export type ApiListResponse<T> = {
|
||||
data: T[];
|
||||
};
|
||||
|
||||
export type ApiAssignment = {
|
||||
id: number;
|
||||
classroom_id: number;
|
||||
teacher_id: number;
|
||||
title: string;
|
||||
instructions: string | null;
|
||||
pass_threshold?: number;
|
||||
status: "draft" | "assigned" | "closed";
|
||||
due_at: string | null;
|
||||
published_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ApiQuestion = {
|
||||
id: number;
|
||||
author_teacher_id: number;
|
||||
title: string;
|
||||
prompt: string;
|
||||
topic?: string | null;
|
||||
subject: string | null;
|
||||
difficulty?: string | null;
|
||||
source: string | null;
|
||||
correct_answer?: string | null;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type ApiGeneratedQuestion = {
|
||||
question: ApiQuestion;
|
||||
tags: string[];
|
||||
worked_solution: string[];
|
||||
};
|
||||
|
||||
export type ApiGenerateQuestionsResponse = {
|
||||
seed: number;
|
||||
data: ApiGeneratedQuestion[];
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type ApiAssignmentQuestion = {
|
||||
assignment_id: number;
|
||||
question_id: number;
|
||||
position: number;
|
||||
author_teacher_id: number;
|
||||
title: string;
|
||||
prompt: string;
|
||||
subject: string | null;
|
||||
source: string | null;
|
||||
question_status: string;
|
||||
question_created_at?: string | null;
|
||||
question_updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type ApiAssignmentStudentQuestionDetail = {
|
||||
assignment_id: number;
|
||||
student_id: number;
|
||||
question_id: number;
|
||||
position: number;
|
||||
title: string;
|
||||
prompt: string;
|
||||
subject: string;
|
||||
source: string | null;
|
||||
question_tags?: string[];
|
||||
question_status: string;
|
||||
correct_answer?: string;
|
||||
answer_id?: number;
|
||||
answer_text?: string;
|
||||
solve_mode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
|
||||
working_steps?: string;
|
||||
is_correct?: boolean;
|
||||
assignment_ai_feedback?: string;
|
||||
assignment_teacher_feedback?: string;
|
||||
overall_score?: number;
|
||||
pass_threshold?: number;
|
||||
next_step_outcome?: "redo" | "accept" | "support";
|
||||
pass_status_override?: "pending" | "pass" | "no_pass";
|
||||
pass_status?: "pending" | "pass" | "no_pass";
|
||||
ai_feedback?: string;
|
||||
teacher_feedback?: string;
|
||||
answer_status?: string;
|
||||
review_needs_attention?: boolean;
|
||||
review_issue_reason?: string;
|
||||
review_correctness_score?: number;
|
||||
review_understanding_score?: number;
|
||||
review_question_score?: number;
|
||||
review_confidence?: number;
|
||||
review_tags?: string[];
|
||||
submitted_at?: string;
|
||||
reviewed_at?: string;
|
||||
answer_created_at?: string;
|
||||
answer_updated_at?: string;
|
||||
};
|
||||
|
||||
export type ApiUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
role: "student" | "teacher";
|
||||
full_name: string;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type ApiClassroom = {
|
||||
id: number;
|
||||
teacher_id: number;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
description?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ApiStudent = {
|
||||
id: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type ApiReviewSummary = {
|
||||
assignment_id: number;
|
||||
total_questions: number;
|
||||
total_assigned: number;
|
||||
not_started: number;
|
||||
in_progress: number;
|
||||
submitted: number;
|
||||
reviewed: number;
|
||||
};
|
||||
|
||||
export type ApiReviewQueueItem = {
|
||||
assignment_id: number;
|
||||
student_id: number;
|
||||
student_name: string;
|
||||
student_email: string;
|
||||
next_step_outcome?: "redo" | "accept" | "support";
|
||||
total_questions: number;
|
||||
answered_questions: number;
|
||||
reviewed_questions: number;
|
||||
submitted_questions: number;
|
||||
in_progress_questions: number;
|
||||
review_status: string;
|
||||
latest_submitted_at?: string;
|
||||
latest_reviewed_at?: string;
|
||||
};
|
||||
|
||||
export type ApiStudentWeaknessSummary = {
|
||||
student_id: number;
|
||||
topic_scores: Record<string, number>;
|
||||
weak_tags: string[];
|
||||
recent_issues: string[];
|
||||
};
|
||||
|
||||
export type ApiRedoPlanQuestion = {
|
||||
topic: string;
|
||||
difficulty: string;
|
||||
tags: string[];
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ApiRedoPlan = {
|
||||
rationale: string;
|
||||
questionSet: ApiRedoPlanQuestion[];
|
||||
};
|
||||
|
||||
export type ApiAssignmentRedoPlanResponse = {
|
||||
assignment_id: number;
|
||||
student_id: number;
|
||||
redo_plan_generated_at?: string | null;
|
||||
teacher_feedback?: string | null;
|
||||
weakness_summary: ApiStudentWeaknessSummary;
|
||||
plan?: ApiRedoPlan | null;
|
||||
error?: string | null;
|
||||
};
|
||||
58
Frontend/src/lib/api.ts
Normal file
58
Frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// Path: Frontend/src/lib/api.ts
|
||||
|
||||
type ApiRequestOptions = RequestInit & {
|
||||
notFoundAsNull?: boolean;
|
||||
allowNoContent?: boolean;
|
||||
parseErrorMessage?: boolean;
|
||||
};
|
||||
|
||||
export const apiFetchJson = async <T>(path: string, options: ApiRequestOptions = {}): Promise<T> => {
|
||||
const { notFoundAsNull = false, allowNoContent = false, parseErrorMessage = false, ...init } = options;
|
||||
const headers = new Headers(init.headers ?? undefined);
|
||||
|
||||
if (!headers.has("accept")) {
|
||||
headers.set("accept", "application/json");
|
||||
}
|
||||
|
||||
if (init.body && !headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
if (notFoundAsNull) {
|
||||
return null as T;
|
||||
}
|
||||
|
||||
const error = new Error("not_found");
|
||||
(error as Error & { status?: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let message = `Request failed: ${response.status}`;
|
||||
if (parseErrorMessage) {
|
||||
try {
|
||||
const payload = (await response.json()) as { message?: string };
|
||||
if (payload.message) message = payload.message;
|
||||
} catch {
|
||||
// ignore malformed payloads
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error(message);
|
||||
(error as Error & { status?: number }).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.status === 204 && allowNoContent) {
|
||||
return null as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user