diff --git a/src/app/(dashboard)/student/elective/page.tsx b/src/app/(dashboard)/student/elective/page.tsx index 7699b9b..763cc8a 100644 --- a/src/app/(dashboard)/student/elective/page.tsx +++ b/src/app/(dashboard)/student/elective/page.tsx @@ -2,28 +2,51 @@ import { getAuthContext } from "@/shared/lib/auth-guard" import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections" import { StudentSelectionView } from "@/modules/elective/components/student-selection-view" +import { ElectiveFilters } from "@/modules/elective/components/elective-filters" export const dynamic = "force-dynamic" -export default async function StudentElectivePage() { +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function StudentElectivePage({ + searchParams, +}: { + searchParams: Promise +}) { const ctx = await getAuthContext() const studentId = ctx.userId - const [availableCourses, mySelections] = await Promise.all([ + const [sp, availableCourses, mySelections] = await Promise.all([ + searchParams, getAvailableCoursesForStudent(studentId), getStudentSelections(studentId), ]) + const q = (getParam(sp, "q") || "").toLowerCase().trim() + const modeFilter = getParam(sp, "mode") || "all" + + const filteredCourses = availableCourses.filter((c) => { + if (q && !c.name.toLowerCase().includes(q) && !(c.teacherName?.toLowerCase().includes(q) ?? false)) return false + if (modeFilter !== "all" && c.selectionMode !== modeFilter) return false + return true + }) + return ( -
+

Elective Courses

Browse available electives and manage your selections.

+ {availableCourses.length > 0 && }
diff --git a/src/app/(dashboard)/student/grades/page.tsx b/src/app/(dashboard)/student/grades/page.tsx index fc7f58c..1e0f02b 100644 --- a/src/app/(dashboard)/student/grades/page.tsx +++ b/src/app/(dashboard)/student/grades/page.tsx @@ -1,19 +1,34 @@ import { getAuthContext } from "@/shared/lib/auth-guard" import { getStudentGradeSummary } from "@/modules/grades/data-access" import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary" +import { GradeFilters } from "@/modules/grades/components/grade-filters" +import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card" import { EmptyState } from "@/shared/components/ui/empty-state" import { UserX } from "lucide-react" export const dynamic = "force-dynamic" -export default async function StudentGradesPage() { - const ctx = await getAuthContext() +type SearchParams = { [key: string]: string | string[] | undefined } - const summary = await getStudentGradeSummary(ctx.userId) +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function StudentGradesPage({ + searchParams, +}: { + searchParams: Promise +}) { + const ctx = await getAuthContext() + const [sp, summary] = await Promise.all([ + searchParams, + getStudentGradeSummary(ctx.userId), + ]) if (!summary) { return ( -
+

My Grades

View your grade records.

@@ -28,13 +43,34 @@ export default async function StudentGradesPage() { ) } + // 应用筛选 + const q = (getParam(sp, "q") || "").toLowerCase().trim() + const subjectFilter = getParam(sp, "subject") || "all" + const typeFilter = getParam(sp, "type") || "all" + const semesterFilter = getParam(sp, "semester") || "all" + + const filteredRecords = summary.records.filter((r) => { + if (q && !r.title.toLowerCase().includes(q)) return false + if (subjectFilter !== "all" && r.subjectName !== subjectFilter) return false + if (typeFilter !== "all" && r.type !== typeFilter) return false + if (semesterFilter !== "all" && r.semester !== semesterFilter) return false + return true + }) + + const filteredSummary = { + ...summary, + records: filteredRecords, + } + return ( -
+

My Grades

View your grade records.

- + + {filteredSummary.records.length > 0 && } +
) } diff --git a/src/app/(dashboard)/student/learning/courses/[classId]/loading.tsx b/src/app/(dashboard)/student/learning/courses/[classId]/loading.tsx new file mode 100644 index 0000000..a3e20e4 --- /dev/null +++ b/src/app/(dashboard)/student/learning/courses/[classId]/loading.tsx @@ -0,0 +1,39 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + + +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + + + + + + + + + ))} +
+ + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/student/learning/courses/[classId]/page.tsx b/src/app/(dashboard)/student/learning/courses/[classId]/page.tsx new file mode 100644 index 0000000..32aa03f --- /dev/null +++ b/src/app/(dashboard)/student/learning/courses/[classId]/page.tsx @@ -0,0 +1,234 @@ +import Link from "next/link" +import { notFound } from "next/navigation" +import { + BookOpen, + Building2, + CalendarDays, + ChevronLeft, + Mail, + PenTool, + School, + User, +} from "lucide-react" + +import { getStudentClassById, getStudentSchedule } from "@/modules/classes/data-access" +import { getCurrentStudentUser } from "@/modules/users/data-access" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export const dynamic = "force-dynamic" + +const WEEKDAYS: Record = { + 1: "Mon", + 2: "Tue", + 3: "Wed", + 4: "Thu", + 5: "Fri", + 6: "Sat", + 7: "Sun", +} + +export default async function StudentClassDetailPage({ + params, +}: { + params: Promise<{ classId: string }> +}) { + const { classId } = await params + const student = await getCurrentStudentUser() + if (!student) return notFound() + + const [classInfo, schedule] = await Promise.all([ + getStudentClassById(student.id, classId), + getStudentSchedule(student.id), + ]) + + if (!classInfo) return notFound() + + // Filter schedule items for this class + const classSchedule = schedule + .filter((s) => s.classId === classId) + .sort((a, b) => a.weekday - b.weekday || a.startTime.localeCompare(b.startTime)) + + return ( +
+
+
+ +

{classInfo.name}

+
+ + + Grade {classInfo.grade} + + {classInfo.homeroom && ( + <> + + {classInfo.homeroom} + + )} + {classInfo.room && ( + <> + + + + Room {classInfo.room} + + + )} + Active +
+
+
+ + +
+
+ +
+ {/* Teacher Info */} + + + + + Teacher + + + + {classInfo.teacherName ? ( +
+ + {classInfo.teacherName} +
+ ) : ( +

No teacher assigned.

+ )} + {classInfo.teacherEmail && ( + + )} +
+
+ + {/* School Info */} + + + + + School + + + + {classInfo.schoolName ? ( +
+ + {classInfo.schoolName} +
+ ) : ( +

School info not available.

+ )} + {classInfo.grade && ( +
+ + Grade {classInfo.grade} +
+ )} +
+
+ + {/* Class Info */} + + + + + Classroom + + + + {classInfo.room ? ( +
+ + Room {classInfo.room} +
+ ) : ( +

Room not assigned.

+ )} + {classInfo.homeroom && ( +
+ + Homeroom: {classInfo.homeroom} +
+ )} +
+
+
+ + {/* Schedule */} + + + + + Class Schedule + + + + {classSchedule.length === 0 ? ( + + ) : ( +
+ {classSchedule.map((s) => ( +
+
+ + {WEEKDAYS[s.weekday]} + +
+

{s.course}

+ {s.location && ( +

{s.location}

+ )} +
+
+
+ {s.startTime} - {s.endTime} +
+
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/student/learning/courses/page.tsx b/src/app/(dashboard)/student/learning/courses/page.tsx index b2144f8..daefa38 100644 --- a/src/app/(dashboard)/student/learning/courses/page.tsx +++ b/src/app/(dashboard)/student/learning/courses/page.tsx @@ -3,15 +3,27 @@ import { UserX } from "lucide-react" import { getStudentClasses } from "@/modules/classes/data-access" import { getCurrentStudentUser } from "@/modules/users/data-access" import { StudentCoursesView } from "@/modules/student/components/student-courses-view" +import { CourseFilters } from "@/modules/student/components/course-filters" import { EmptyState } from "@/shared/components/ui/empty-state" export const dynamic = "force-dynamic" -export default async function StudentCoursesPage() { +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function StudentCoursesPage({ + searchParams, +}: { + searchParams: Promise +}) { const student = await getCurrentStudentUser() if (!student) { return ( -
+

Courses

Your enrolled classes.

@@ -25,16 +37,31 @@ export default async function StudentCoursesPage() { ) } - const classes = await getStudentClasses(student.id) + const [sp, classes] = await Promise.all([ + searchParams, + getStudentClasses(student.id), + ]) + + const q = (getParam(sp, "q") || "").toLowerCase().trim() + const filteredClasses = q + ? classes.filter((c) => { + return ( + c.name.toLowerCase().includes(q) || + (c.teacherName?.toLowerCase().includes(q) ?? false) || + (c.schoolName?.toLowerCase().includes(q) ?? false) || + (c.homeroom?.toLowerCase().includes(q) ?? false) + ) + }) + : classes return ( -
+

Courses

Your enrolled classes.

- + {classes.length > 0 && } +
) } - diff --git a/src/app/(dashboard)/student/learning/textbooks/[id]/loading.tsx b/src/app/(dashboard)/student/learning/textbooks/[id]/loading.tsx index dc8d36e..e14e122 100644 --- a/src/app/(dashboard)/student/learning/textbooks/[id]/loading.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/[id]/loading.tsx @@ -2,14 +2,14 @@ import { Skeleton } from "@/shared/components/ui/skeleton" export default function Loading() { return ( -
+
-
+
diff --git a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx index a0bca01..72d9f5e 100644 --- a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx @@ -29,7 +29,7 @@ export default async function StudentTextbookDetailPage({ if (!textbook) notFound() return ( -
+

{textbook.title}

@@ -43,7 +43,7 @@ export default async function StudentTextbookDetailPage({
-
+
{chapters.length === 0 ? (
=> { + const sid = studentId.trim() + const cid = classId.trim() + if (!sid || !cid) return null + + const rows = await db + .select({ + id: classes.id, + schoolName: classes.schoolName, + name: classes.name, + grade: classes.grade, + homeroom: classes.homeroom, + room: classes.room, + teacherName: users.name, + teacherEmail: users.email, + }) + .from(classEnrollments) + .innerJoin(classes, eq(classes.id, classEnrollments.classId)) + .leftJoin(users, eq(users.id, classes.teacherId)) + .where(and(eq(classEnrollments.studentId, sid), eq(classEnrollments.classId, cid), eq(classEnrollments.status, "active"))) + .limit(1) + + if (rows.length === 0) return null + const r = rows[0] + return { + id: r.id, + schoolName: r.schoolName, + name: r.name, + grade: r.grade, + homeroom: r.homeroom, + room: r.room, + teacherName: r.teacherName, + teacherEmail: r.teacherEmail, + } + } +) + export const getClassStudents = cache( async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise => { const teacherId = params?.teacherId ?? (await getSessionTeacherId()) diff --git a/src/modules/diagnostic/components/student-diagnostic-view.tsx b/src/modules/diagnostic/components/student-diagnostic-view.tsx index 67a750a..b89bba0 100644 --- a/src/modules/diagnostic/components/student-diagnostic-view.tsx +++ b/src/modules/diagnostic/components/student-diagnostic-view.tsx @@ -1,20 +1,14 @@ "use client" -import { useState } from "react" -import { useRouter } from "next/navigation" -import { toast } from "sonner" -import { Award, AlertTriangle, Lightbulb, FileText, TrendingUp } from "lucide-react" +import Link from "next/link" +import { Award, AlertTriangle, Lightbulb, FileText, History, ArrowRight } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" -import { Input } from "@/shared/components/ui/input" -import { Label } from "@/shared/components/ui/label" import { EmptyState } from "@/shared/components/ui/empty-state" -import { usePermission } from "@/shared/hooks" -import { Permissions } from "@/shared/types/permissions" +import { formatDate } from "@/shared/lib/utils" import { MasteryRadarChart } from "./mastery-radar-chart" -import { generateStudentReportAction } from "../actions" import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types" interface StudentDiagnosticViewProps { @@ -24,28 +18,6 @@ interface StudentDiagnosticViewProps { } export function StudentDiagnosticView({ summary, reports, classAverageMastery }: StudentDiagnosticViewProps) { - const router = useRouter() - const { hasPermission } = usePermission() - const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE) - const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7)) - const [isGenerating, setIsGenerating] = useState(false) - - const handleGenerate = async () => { - if (!summary) return - setIsGenerating(true) - const formData = new FormData() - formData.set("studentId", summary.studentId) - formData.set("period", period) - const result = await generateStudentReportAction(null, formData) - setIsGenerating(false) - if (result.success) { - toast.success(result.message) - router.refresh() - } else { - toast.error(result.message || "Failed to generate report") - } - } - if (!summary) { return ( {summary.weaknesses.map((m) => ( -
  • - {m.knowledgePointName} - {m.masteryLevel.toFixed(1)}% +
  • +
    + {m.knowledgePointName} + {m.masteryLevel.toFixed(1)}% +
    +
  • ))} @@ -160,38 +140,6 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
    - {/* 生成报告 */} - {canManage ? ( - - - - - Generate Diagnostic Report - - - Generate an AI-analyzed diagnostic report for this student. - - - -
    -
    - - setPeriod(e.target.value)} - className="w-[180px]" - /> -
    - -
    -
    -
    - ) : null} - {/* 最新报告 / 建议 */} {latestReport ? ( @@ -224,6 +172,44 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }: ) : null} + + {/* 历史报告列表 */} + {publishedReports.length > 1 ? ( + + + + + Report History + + Past diagnostic reports (newest first). + + +
    + {publishedReports.map((r) => ( +
    +
    +
    + + {r.period ?? "Untitled period"} + + + {r.reportType} + +
    +

    + {formatDate(r.createdAt)} + {r.overallScore !== null ? ` · Overall: ${r.overallScore.toFixed(1)}%` : ""} +

    +
    +
    + ))} +
    +
    +
    + ) : null}
    ) } diff --git a/src/modules/elective/components/elective-filters.tsx b/src/modules/elective/components/elective-filters.tsx new file mode 100644 index 0000000..debbea8 --- /dev/null +++ b/src/modules/elective/components/elective-filters.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useQueryState, parseAsString } from "nuqs" + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" +import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" + +export function ElectiveFilters() { + const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) + const [mode, setMode] = useQueryState("mode", parseAsString.withDefault("all")) + + const hasFilters = Boolean(search || mode !== "all") + + return ( + { + setSearch(null) + setMode(null) + }} + > + setSearch(v || null)} + placeholder="Search by course name, teacher..." + /> + +
    + +
    +
    + ) +} diff --git a/src/modules/grades/components/grade-trend-card.tsx b/src/modules/grades/components/grade-trend-card.tsx new file mode 100644 index 0000000..91d28c4 --- /dev/null +++ b/src/modules/grades/components/grade-trend-card.tsx @@ -0,0 +1,57 @@ +"use client" + +import { useMemo } from "react" +import { BarChart3 } from "lucide-react" + +import { ChartCardShell } from "@/shared/components/charts/chart-card-shell" +import { TrendLineChart } from "@/shared/components/charts/trend-line-chart" +import { formatDate } from "@/shared/lib/utils" +import type { StudentGradeSummary } from "../types" + +export function GradeTrendCard({ summary }: { summary: StudentGradeSummary }) { + const chartData = useMemo(() => { + return [...summary.records] + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map((r) => ({ + title: r.title, + score: Math.round((r.score / r.fullScore) * 100), + fullTitle: r.title, + submittedAt: formatDate(r.createdAt), + rawScore: r.score, + maxScore: r.fullScore, + })) + }, [summary.records]) + + const hasData = chartData.length > 0 + + return ( + +
    + +
    +
    + ) +} diff --git a/src/modules/homework/components/homework-take-view.tsx b/src/modules/homework/components/homework-take-view.tsx index a632b8b..dedfa19 100644 --- a/src/modules/homework/components/homework-take-view.tsx +++ b/src/modules/homework/components/homework-take-view.tsx @@ -1,7 +1,8 @@ "use client" -import { useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" +import Link from "next/link" import { toast } from "sonner" import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group" @@ -12,7 +13,18 @@ import { Checkbox } from "@/shared/components/ui/checkbox" import { Label } from "@/shared/components/ui/label" import { Textarea } from "@/shared/components/ui/textarea" import { ScrollArea } from "@/shared/components/ui/scroll-area" -import { Clock, CheckCircle2, Save, FileText } from "lucide-react" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog" +import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert } from "lucide-react" +import { formatDate, cn } from "@/shared/lib/utils" import type { StudentHomeworkTakeData } from "../types" import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions" @@ -64,6 +76,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView const [submissionId, setSubmissionId] = useState(initialData.submission?.id ?? null) const [submissionStatus, setSubmissionStatus] = useState(initialData.submission?.status ?? "not_started") const [isBusy, setIsBusy] = useState(false) + const [showSubmitConfirm, setShowSubmitConfirm] = useState(false) const initialAnswersByQuestionId = useMemo(() => { const map = new Map() @@ -83,6 +96,30 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView const canEdit = isStarted && Boolean(submissionId) const showQuestions = submissionStatus !== "not_started" + // 离开警告:作答中未提交时关闭/刷新页面会丢失答案 + useEffect(() => { + if (!canEdit) return + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault() + e.returnValue = "" + } + window.addEventListener("beforeunload", handler) + return () => window.removeEventListener("beforeunload", handler) + }, [canEdit]) + + // 截止时间与紧急度 + const dueAt = initialData.assignment.dueAt + const now = new Date() + const dueDate = dueAt ? new Date(dueAt) : null + const isOverdue = dueDate ? dueDate < now : false + const hoursUntilDue = dueDate ? Math.floor((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60)) : null + const isUrgent = hoursUntilDue !== null && hoursUntilDue >= 0 && hoursUntilDue < 24 + + // 尝试次数 + const maxAttempts = initialData.assignment.maxAttempts + const attemptsUsed = initialData.submission?.attemptNo ?? 0 + const attemptsRemaining = Math.max(0, maxAttempts - attemptsUsed) + const handleStart = async () => { setIsBusy(true) const fd = new FormData() @@ -144,11 +181,26 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView setIsBusy(false) } + // 统计未作答题目数 + const unansweredCount = initialData.questions.filter((q) => { + const v = answersByQuestionId[q.questionId]?.answer + if (v === undefined || v === null) return true + if (typeof v === "string" && v.trim() === "") return true + if (Array.isArray(v) && v.length === 0) return true + return false + }).length + return (
    +
    @@ -169,15 +221,10 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView {isBusy ? "Starting..." : "Start Assignment"} ) : ( -
    - - Auto-saving enabled - - -
    + )}
    @@ -190,7 +237,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView

    Ready to start?

    - Click the "Start Assignment" button above to begin. The timer will start once you confirm. + Click the "Start Assignment" button above to begin. Your answers will be saved when you click "Save Answer".

    ) })}
    @@ -406,7 +493,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView {canEdit && (
    -

    @@ -415,6 +502,33 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView

    )}
    + + {/* 提交二次确认对话框 */} + + + + Confirm Submission + + {unansweredCount > 0 + ? `You have ${unansweredCount} unanswered question${unansweredCount === 1 ? "" : "s"}. Submitted answers cannot be changed. Are you sure you want to submit?` + : "All questions have been answered. Submitted answers cannot be changed. Are you sure you want to submit?"} + + + + Cancel + { + e.preventDefault() + setShowSubmitConfirm(false) + void handleSubmit() + }} + > + {isBusy ? "Submitting..." : "Confirm Submit"} + + + +
    ) } diff --git a/src/modules/homework/components/student-homework-review-view.tsx b/src/modules/homework/components/student-homework-review-view.tsx index 0be97ba..a9533c5 100644 --- a/src/modules/homework/components/student-homework-review-view.tsx +++ b/src/modules/homework/components/student-homework-review-view.tsx @@ -15,7 +15,7 @@ import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group" const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null -type Option = { id: string; text: string } +type Option = { id: string; text: string; isCorrect?: boolean } const getQuestionText = (content: unknown): string => { if (!isRecord(content)) return "" @@ -32,11 +32,29 @@ const getOptions = (content: unknown): Option[] => { const id = typeof item.id === "string" ? item.id : "" const text = typeof item.text === "string" ? item.text : "" if (!id || !text) continue - out.push({ id, text }) + const isCorrect = item.isCorrect === true + out.push({ id, text, isCorrect }) } return out } +const getChoiceCorrectIds = (content: unknown): string[] => { + return getOptions(content).filter((o) => o.isCorrect).map((o) => o.id) +} + +const getJudgmentCorrectAnswer = (content: unknown): boolean | null => { + if (!isRecord(content)) return null + return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null +} + +const getTextCorrectAnswers = (content: unknown): string[] => { + if (!isRecord(content)) return [] + const raw = content.correctAnswer + if (typeof raw === "string") return [raw] + if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string") + return [] +} + const toAnswerShape = (questionType: string, v: unknown) => { if (questionType === "text") return { answer: typeof v === "string" ? v : "" } if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false } @@ -144,6 +162,16 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
    {typeof value === "string" ? value : No answer provided}
    + {isGraded && (() => { + const correctTexts = getTextCorrectAnswers(q.questionContent) + if (correctTexts.length === 0) return null + return ( +
    +
    Correct Answer
    +
    {correctTexts.join(" / ")}
    +
    + ) + })()}
    ) : q.questionType === "judgment" ? (
    @@ -161,6 +189,16 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
    + {isGraded && (() => { + const correct = getJudgmentCorrectAnswer(q.questionContent) + if (correct === null) return null + return ( +
    + Correct Answer: + {correct ? "True" : "False"} +
    + ) + })()}
    ) : q.questionType === "single_choice" ? (
    @@ -169,14 +207,26 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) { disabled className="flex flex-col gap-2" > - {options.map((o) => ( -
    - - -
    - ))} + {options.map((o) => { + const correctIds = isGraded ? getChoiceCorrectIds(q.questionContent) : [] + const isCorrectOption = isGraded && correctIds.includes(o.id) + return ( +
    + + +
    + ) + })}
    ) : q.questionType === "multiple_choice" ? ( @@ -184,8 +234,15 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
    {options.map((o) => { const selected = Array.isArray(value) ? value.includes(o.id) : false + const correctIds = isGraded ? getChoiceCorrectIds(q.questionContent) : [] + const isCorrectOption = isGraded && correctIds.includes(o.id) return ( -
    +
    ) diff --git a/src/modules/layout/components/site-header.tsx b/src/modules/layout/components/site-header.tsx index e73c903..0baa7cc 100644 --- a/src/modules/layout/components/site-header.tsx +++ b/src/modules/layout/components/site-header.tsx @@ -61,13 +61,20 @@ export function SiteHeader() { // Generate breadcrumbs const segments = pathname.split("/").filter(Boolean) + const roleSegments = new Set(["admin", "teacher", "student", "parent"]) const breadcrumbs = segments .map((segment, index) => { const href = `/${segments.slice(0, index + 1).join("/")}` const title = BREADCRUMB_MAP.get(href) || segment.charAt(0).toUpperCase() + segment.slice(1) - return { href, title, isLast: index === segments.length - 1 } + return { href, title, isLast: index === segments.length - 1, isRole: roleSegments.has(segment.toLowerCase()) } + }) + .filter((b, idx) => { + // Keep the first role segment (root context), filter out subsequent role segments + if (b.isRole) { + return idx === 0 + } + return true }) - .filter((b) => !["admin", "teacher", "student", "parent"].includes(b.title.toLowerCase())) return (
    diff --git a/src/modules/student/components/course-filters.tsx b/src/modules/student/components/course-filters.tsx new file mode 100644 index 0000000..cc9d3a2 --- /dev/null +++ b/src/modules/student/components/course-filters.tsx @@ -0,0 +1,25 @@ +"use client" + +import { useQueryState, parseAsString } from "nuqs" + +import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" + +export function CourseFilters() { + const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) + + const hasFilters = Boolean(search) + + return ( + setSearch(null)} + > + setSearch(v || null)} + placeholder="Search by class name, teacher, school..." + /> + + ) +} diff --git a/src/modules/student/components/student-courses-view.tsx b/src/modules/student/components/student-courses-view.tsx index 779d421..aab902f 100644 --- a/src/modules/student/components/student-courses-view.tsx +++ b/src/modules/student/components/student-courses-view.tsx @@ -11,7 +11,7 @@ import { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" -import { BookOpen, Building2, Inbox, CalendarDays, User, PlusCircle, PenTool } from "lucide-react" +import { BookOpen, Building2, Inbox, CalendarDays, User, PlusCircle, PenTool, Mail, School } from "lucide-react" import type { StudentEnrolledClass } from "@/modules/classes/types" import { joinClassByInvitationCodeAction } from "@/modules/classes/actions" @@ -22,7 +22,11 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) {
    - {c.name} + + + {c.name} + + @@ -44,12 +48,26 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) {
    + {c.schoolName && ( +
    + + {c.schoolName} +
    + )} {c.teacherName && (
    {c.teacherName}
    )} + {c.teacherEmail && ( + + )} {c.room && (
    @@ -60,6 +78,12 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) { +