完整性更新

现在已经实现了大部分基础功能
This commit is contained in:
SpecialX
2026-01-08 11:14:03 +08:00
parent 0da2eac0b4
commit 57807def37
155 changed files with 26421 additions and 1036 deletions

View File

@@ -1,20 +1,9 @@
"use client"
import { useEffect } from "react"
import { Button } from "@/shared/components/ui/button"
import { AlertCircle } from "lucide-react"
export default function AuthError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
export default function AuthError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">

View File

@@ -1,5 +1,9 @@
import { AdminDashboard } from "@/modules/dashboard/components/admin-view"
import { AdminDashboardView } from "@/modules/dashboard/components/admin-dashboard/admin-dashboard"
import { getAdminDashboardData } from "@/modules/dashboard/data-access"
export default function AdminDashboardPage() {
return <AdminDashboard />
export const dynamic = "force-dynamic"
export default async function AdminDashboardPage() {
const data = await getAdminDashboardData()
return <AdminDashboardView data={data} />
}

View File

@@ -0,0 +1,18 @@
import { AcademicYearClient } from "@/modules/school/components/academic-year-view"
import { getAcademicYears } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminAcademicYearPage() {
const years = await getAcademicYears()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Academic Year</h2>
<p className="text-muted-foreground">Manage academic year ranges and the active year.</p>
</div>
<AcademicYearClient years={years} />
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { getAdminClasses, getTeacherOptions } from "@/modules/classes/data-access"
import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view"
export const dynamic = "force-dynamic"
export default async function AdminSchoolClassesPage() {
const [classes, teachers] = await Promise.all([getAdminClasses(), getTeacherOptions()])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Classes</h2>
<p className="text-muted-foreground">Manage classes and assign teachers.</p>
</div>
<AdminClassesClient classes={classes} teachers={teachers} />
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { DepartmentsClient } from "@/modules/school/components/departments-view"
import { getDepartments } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminDepartmentsPage() {
const departments = await getDepartments()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Departments</h2>
<p className="text-muted-foreground">Manage school departments.</p>
</div>
<DepartmentsClient departments={departments} />
</div>
)
}

View File

@@ -0,0 +1,231 @@
import Link from "next/link"
import { getGrades } from "@/modules/school/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { BarChart3 } from "lucide-react"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
if (typeof v === "string") return v
if (Array.isArray(v)) return v[0]
return undefined
}
const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
export default async function AdminGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const gradeId = getParam(params, "gradeId")
const grades = await getGrades()
const selected = gradeId && gradeId !== "all" ? gradeId : ""
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
<p className="text-muted-foreground">Homework statistics aggregated across all classes in a grade.</p>
</div>
<Button asChild variant="outline">
<Link href="/admin/school/grades">Manage grades</Link>
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Filters</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{grades.length}
</Badge>
</CardHeader>
<CardContent>
<form action="/admin/school/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
<label className="text-sm font-medium">Grade</label>
<select
name="gradeId"
defaultValue={selected || "all"}
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
>
<option value="all">Select a grade</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.school.name} / {g.name}
</option>
))}
</select>
<Button type="submit" className="md:ml-2">
Apply
</Button>
</form>
</CardContent>
</Card>
{!selected ? (
<EmptyState
icon={BarChart3}
title="Select a grade to view insights"
description="Pick a grade to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
) : !insights ? (
<EmptyState
icon={BarChart3}
title="Grade not found"
description="This grade may not exist or has no accessible data."
className="h-[360px] bg-card"
/>
) : insights.assignments.length === 0 ? (
<EmptyState
icon={BarChart3}
title="No homework data for this grade"
description="No homework assignments were targeted to students in this grade yet."
className="h-[360px] bg-card"
/>
) : (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Classes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.classCount}</div>
<div className="text-xs text-muted-foreground">
{insights.grade.school.name} / {insights.grade.name}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.overallScores.avg)}</div>
<div className="text-xs text-muted-foreground">Across graded homework</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Latest Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.latest?.scoreStats.avg ?? null)}</div>
<div className="text-xs text-muted-foreground">{insights.latest ? insights.latest.title : "-"}</div>
</CardContent>
</Card>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Latest homework</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.assignments.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.avg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.median)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Class ranking</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.classes.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Class</TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead className="text-right">Latest Avg</TableHead>
<TableHead className="text-right">Prev Avg</TableHead>
<TableHead className="text-right">Δ</TableHead>
<TableHead className="text-right">Overall Avg</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.classes.map((c) => (
<TableRow key={c.class.id}>
<TableCell className="font-medium">
{c.class.name}
{c.class.homeroom ? <span className="text-muted-foreground"> {c.class.homeroom}</span> : null}
</TableCell>
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.latestAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.prevAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.deltaAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.overallScores.avg)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { GradesClient } from "@/modules/school/components/grades-view"
import { getGrades, getSchools, getStaffOptions } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminGradesPage() {
const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grades</h2>
<p className="text-muted-foreground">Manage grades and assign grade heads.</p>
</div>
<GradesClient grades={grades} schools={schools} staff={staff} />
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function AdminSchoolPage() {
redirect("/admin/school/classes")
}

View File

@@ -0,0 +1,18 @@
import { SchoolsClient } from "@/modules/school/components/schools-view"
import { getSchools } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminSchoolsPage() {
const schools = await getSchools()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Schools</h2>
<p className="text-muted-foreground">Manage schools for multi-school setups.</p>
</div>
<SchoolsClient schools={schools} />
</div>
)
}

View File

@@ -1,72 +1,16 @@
"use client"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/shared/components/ui/button"
import Link from "next/link"
import { Shield, GraduationCap, Users, User } from "lucide-react"
export const dynamic = "force-dynamic"
// In a real app, this would be a server component that redirects based on session
// But for this demo/dev environment, we keep the manual selection or add auto-redirect logic if we had auth state.
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect("/login")
export default function DashboardPage() {
// Mock Auth Logic (Optional: Uncomment to test auto-redirect)
/*
const router = useRouter();
useEffect(() => {
// const role = "teacher"; // Fetch from auth hook
// if (role) router.push(`/${role}/dashboard`);
}, []);
*/
const role = String(session.user.role ?? "teacher")
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] space-y-8">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold">Welcome to Next_Edu</h1>
<p className="text-muted-foreground">Select your role to view the corresponding dashboard.</p>
<p className="text-xs text-muted-foreground bg-muted p-2 rounded inline-block">
[DEV MODE] In production, you would be redirected automatically based on your login session.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Link href="/admin/dashboard">
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-primary hover:bg-primary/5 transition-all">
<Shield className="h-10 w-10 text-primary" />
<div className="space-y-1">
<span className="font-semibold text-lg block">Admin</span>
<span className="text-xs text-muted-foreground font-normal">System Management</span>
</div>
</Button>
</Link>
<Link href="/teacher/dashboard">
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-indigo-500 hover:bg-indigo-50 transition-all">
<GraduationCap className="h-10 w-10 text-indigo-600" />
<div className="space-y-1">
<span className="font-semibold text-lg block">Teacher</span>
<span className="text-xs text-muted-foreground font-normal">Class & Exams</span>
</div>
</Button>
</Link>
<Link href="/student/dashboard">
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-emerald-500 hover:bg-emerald-50 transition-all">
<Users className="h-10 w-10 text-emerald-600" />
<div className="space-y-1">
<span className="font-semibold text-lg block">Student</span>
<span className="text-xs text-muted-foreground font-normal">My Learning</span>
</div>
</Button>
</Link>
<Link href="/parent/dashboard">
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-amber-500 hover:bg-amber-50 transition-all">
<User className="h-10 w-10 text-amber-600" />
<div className="space-y-1">
<span className="font-semibold text-lg block">Parent</span>
<span className="text-xs text-muted-foreground font-normal">Family Overview</span>
</div>
</Button>
</Link>
</div>
</div>
)
if (role === "admin") redirect("/admin/dashboard")
if (role === "student") redirect("/student/dashboard")
if (role === "parent") redirect("/parent/dashboard")
redirect("/teacher/dashboard")
}

View File

@@ -1,23 +1,10 @@
"use client"
import { useEffect } from "react"
import { AlertCircle } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
export default function Error({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<EmptyState

View File

@@ -0,0 +1,151 @@
import Link from "next/link"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
import { StudentRankingCard } from "@/modules/dashboard/components/student-dashboard/student-ranking-card"
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
export const dynamic = "force-dynamic"
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
const day = d.getDay()
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
}
export default async function ProfilePage() {
const session = await auth()
if (!session?.user) redirect("/login")
const name = session.user.name ?? "User"
const email = session.user.email ?? "-"
const role = String(session.user.role ?? "teacher")
const userId = String(session.user.id ?? "").trim()
const studentData =
role === "student" && userId
? await (async () => {
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
getStudentClasses(userId),
getStudentSchedule(userId),
getStudentHomeworkAssignments(userId),
getStudentDashboardGrades(userId),
])
const now = new Date()
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
const dueSoonCount = assignmentsAll.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due >= now && due <= in7Days && a.progressStatus !== "graded"
}).length
const overdueCount = assignmentsAll.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due < now && a.progressStatus !== "graded"
}).length
const gradedCount = assignmentsAll.filter((a) => a.progressStatus === "graded").length
const upcomingAssignments = [...assignmentsAll]
.sort((a, b) => {
const aTime = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
const bTime = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
.slice(0, 8)
const todayWeekday = toWeekday(now)
const todayScheduleItems = schedule
.filter((s) => s.weekday === todayWeekday)
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
return {
enrolledClassCount: classes.length,
dueSoonCount,
overdueCount,
gradedCount,
todayScheduleItems,
upcomingAssignments,
grades,
}
})()
: null
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Profile</h1>
<div className="text-sm text-muted-foreground">Your account information.</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/settings">Open settings</Link>
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Signed-in user details from session.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{name}</div>
<Badge variant="secondary" className="capitalize">
{role}
</Badge>
</div>
<div className="text-sm text-muted-foreground">{email}</div>
</CardContent>
</Card>
{studentData ? (
<div className="space-y-6">
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Student</h2>
<div className="text-sm text-muted-foreground">Your learning overview.</div>
</div>
<StudentStatsGrid
enrolledClassCount={studentData.enrolledClassCount}
dueSoonCount={studentData.dueSoonCount}
overdueCount={studentData.overdueCount}
gradedCount={studentData.gradedCount}
/>
<div className="grid gap-4 md:grid-cols-2">
<StudentGradesCard grades={studentData.grades} />
<StudentRankingCard ranking={studentData.grades.ranking} />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
</div>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
export const dynamic = "force-dynamic"
export default async function SettingsPage() {
const session = await auth()
if (!session?.user) redirect("/login")
const role = String(session.user.role ?? "teacher")
if (role === "admin") return <AdminSettingsView />
if (role === "student") return <StudentSettingsView user={session.user} />
if (role === "teacher") return <TeacherSettingsView user={session.user} />
redirect("/dashboard")
}

View File

@@ -0,0 +1,61 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-2">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-4 w-56" />
</div>
<Skeleton className="h-10 w-40" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4 rounded-full" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-28" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle className="text-sm">
<Skeleton className="h-4 w-40" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
<Card className="lg:col-span-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-sm">
<Skeleton className="h-4 w-44" />
</CardTitle>
<Skeleton className="h-9 w-24" />
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,5 +1,88 @@
import { StudentDashboard } from "@/modules/dashboard/components/student-view"
import { StudentDashboard } from "@/modules/dashboard/components/student-dashboard/student-dashboard-view"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getDemoStudentUser, getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Inbox } from "lucide-react"
export default function StudentDashboardPage() {
return <StudentDashboard />
export const dynamic = "force-dynamic"
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
const day = d.getDay()
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
}
export default async function StudentDashboardPage() {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="flex h-full flex-col items-center justify-center">
<EmptyState
title="No user found"
description="Create a student user to see dashboard."
icon={Inbox}
className="border-none shadow-none h-auto"
/>
</div>
)
}
const [classes, schedule, assignments, grades] = await Promise.all([
getStudentClasses(student.id),
getStudentSchedule(student.id),
getStudentHomeworkAssignments(student.id),
getStudentDashboardGrades(student.id),
])
const now = new Date()
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
const dueSoonCount = assignments.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due >= now && due <= in7Days && a.progressStatus !== "graded"
}).length
const overdueCount = assignments.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due < now && a.progressStatus !== "graded"
}).length
const gradedCount = assignments.filter((a) => a.progressStatus === "graded").length
const todayWeekday = toWeekday(now)
const todayScheduleItems = schedule
.filter((s) => s.weekday === todayWeekday)
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
.sort((a, b) => a.startTime.localeCompare(b.startTime))
const upcomingAssignments = [...assignments]
.sort((a, b) => {
const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
return aDue - bDue
})
.slice(0, 6)
return (
<StudentDashboard
studentName={student.name}
enrolledClassCount={classes.length}
dueSoonCount={dueSoonCount}
overdueCount={overdueCount}
gradedCount={gradedCount}
todayScheduleItems={todayScheduleItems}
upcomingAssignments={upcomingAssignments}
grades={grades}
/>
)
}

View File

@@ -0,0 +1,29 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-52" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-44" />
<Skeleton className="mt-2 h-4 w-56" />
</CardHeader>
<CardContent className="pt-2 flex items-center justify-between">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-9 w-24" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { Inbox } from "lucide-react"
import { getStudentClasses } from "@/modules/classes/data-access"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { StudentCoursesView } from "@/modules/student/components/student-courses-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic"
export default async function StudentCoursesPage() {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p>
</div>
<EmptyState
title="No user found"
description="Create a student user to see courses."
icon={Inbox}
/>
</div>
)
}
const classes = await getStudentClasses(student.id)
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p>
</div>
<StudentCoursesView classes={classes} />
</div>
)
}

View File

@@ -0,0 +1,78 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { ArrowLeft, BookOpen, Inbox } from "lucide-react"
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getDemoStudentUser } from "@/modules/homework/data-access"
export const dynamic = "force-dynamic"
export default async function StudentTextbookDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbook</h2>
<p className="text-muted-foreground">Read chapters and review content.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to read textbooks." icon={Inbox} />
</div>
)
}
const { id } = await params
const [textbook, chapters] = await Promise.all([getTextbookById(id), getChaptersByTextbookId(id)])
if (!textbook) notFound()
return (
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden">
<div className="flex items-center gap-4 border-b bg-background py-4 shrink-0 z-10">
<Button variant="ghost" size="icon" asChild>
<Link href="/student/learning/textbooks">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline">{textbook.subject}</Badge>
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
{textbook.grade ?? "-"}
</span>
</div>
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
</div>
</div>
<div className="flex-1 overflow-hidden pt-6">
{chapters.length === 0 ? (
<div className="px-8">
<EmptyState
icon={BookOpen}
title="No chapters"
description="This textbook has no chapters yet."
className="bg-card"
/>
</div>
) : (
<div className="h-[calc(100vh-140px)] px-8 min-h-0">
<TextbookReader chapters={chapters} />
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { BookOpen, Inbox } from "lucide-react"
import { getTextbooks } from "@/modules/textbooks/data-access"
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Button } from "@/shared/components/ui/button"
import Link from "next/link"
export const dynamic = "force-dynamic"
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 StudentTextbooksPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const [student, sp] = await Promise.all([getDemoStudentUser(), searchParams])
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={Inbox} />
</div>
)
}
const q = getParam(sp, "q") || undefined
const subject = getParam(sp, "subject") || undefined
const grade = getParam(sp, "grade") || undefined
const textbooks = await getTextbooks(q, subject, grade)
const hasFilters = Boolean(q || (subject && subject !== "all") || (grade && grade !== "all"))
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
</div>
<Button asChild variant="outline">
<Link href="/student/dashboard">Back</Link>
</Button>
</div>
<TextbookFilters />
{textbooks.length === 0 ? (
<EmptyState
icon={BookOpen}
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "No textbooks are available right now."}
action={hasFilters ? { label: "Clear filters", href: "/student/learning/textbooks" } : undefined}
className="bg-card"
/>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{textbooks.map((textbook) => (
<TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-52" />
</div>
<Skeleton className="h-10 w-60" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-3">
<Skeleton className="h-4 w-16" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 3 }).map((__, j) => (
<Skeleton key={j} className="h-16 w-full" />
))}
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { Inbox } from "lucide-react"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters"
import { StudentScheduleView } from "@/modules/student/components/student-schedule-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
export default async function StudentSchedulePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">Your weekly timetable.</p>
</div>
<EmptyState title="No user found" description="Create a student user to see schedule." icon={Inbox} />
</div>
)
}
const [sp, classes, schedule] = await Promise.all([
searchParams,
getStudentClasses(student.id),
getStudentSchedule(student.id),
])
const classIdParam = sp.classId
const classId = typeof classIdParam === "string" ? classIdParam : Array.isArray(classIdParam) ? classIdParam[0] : "all"
const filteredItems =
classId && classId !== "all" ? schedule.filter((s) => s.classId === classId) : schedule
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">Your weekly timetable.</p>
</div>
<StudentScheduleFilters classes={classes} />
</div>
<StudentScheduleView items={filteredItems} />
</div>
)
}

View File

@@ -0,0 +1,259 @@
import Link from "next/link"
import { Suspense } from "react"
import { BarChart3 } from "lucide-react"
import { getClassHomeworkInsights, getTeacherClasses } from "@/modules/classes/data-access"
import { InsightsFilters } from "@/modules/classes/components/insights-filters"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const formatNumber = (v: number | null, digits = 1) => {
if (typeof v !== "number" || Number.isNaN(v)) return "-"
return v.toFixed(digits)
}
function InsightsResultsFallback() {
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card">
<div className="p-6">
<Skeleton className="h-5 w-28" />
<Skeleton className="mt-3 h-8 w-20" />
</div>
</div>
))}
</div>
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 8 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
)
}
async function InsightsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const classId = getParam(params, "classId")
if (!classId || classId === "all") {
return (
<EmptyState
icon={BarChart3}
title="Select a class to view insights"
description="Pick a class to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
)
}
const insights = await getClassHomeworkInsights({ classId, limit: 50 })
if (!insights) {
return (
<EmptyState
icon={BarChart3}
title="Class not found"
description="This class may not exist or is not accessible."
className="h-[360px] bg-card"
/>
)
}
const hasAssignments = insights.assignments.length > 0
if (!hasAssignments) {
return (
<EmptyState
icon={BarChart3}
title="No homework data for this class"
description="No homework assignments were targeted to students in this class yet."
className="h-[360px] bg-card"
/>
)
}
const latest = insights.latest
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.assignments.length}</div>
<div className="text-xs text-muted-foreground">Latest: {latest ? formatDate(latest.createdAt) : "-"}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall scores</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
<div className="text-xs text-muted-foreground">
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count}
</div>
</CardContent>
</Card>
</div>
{latest && (
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-base">Latest assignment</CardTitle>
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{latest.title}</span>
<Badge variant="outline" className="capitalize">
{latest.status}
</Badge>
<span>·</span>
<span>{formatDate(latest.createdAt)}</span>
{latest.dueAt ? (
<>
<span>·</span>
<span>Due {formatDate(latest.dueAt)}</span>
</>
) : null}
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-5">
<div>
<div className="text-sm text-muted-foreground">Targeted</div>
<div className="text-lg font-semibold">{latest.targetCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Submitted</div>
<div className="text-lg font-semibold">{latest.submittedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Graded</div>
<div className="text-lg font-semibold">{latest.gradedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Average</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Median</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
</div>
</CardContent>
</Card>
)}
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
<TableHead className="text-right">Min</TableHead>
<TableHead className="text-right">Max</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.assignmentId}`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right">{a.targetCount}</TableCell>
<TableCell className="text-right">{a.submittedCount}</TableCell>
<TableCell className="text-right">{a.gradedCount}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.avg, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.median, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.min, 0)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.max, 0)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export default async function ClassInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Class Insights</h2>
<p className="text-muted-foreground">Latest homework and historical score statistics for a class.</p>
</div>
</div>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<InsightsFilters classes={classes} />
</Suspense>
<Suspense fallback={<InsightsResultsFallback />}>
<InsightsResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,315 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { getClassHomeworkInsights, getClassSchedule, getClassStudents } from "@/modules/classes/data-access"
import { ScheduleView } from "@/modules/classes/components/schedule-view"
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const formatNumber = (v: number | null, digits = 1) => {
if (typeof v !== "number" || Number.isNaN(v)) return "-"
return v.toFixed(digits)
}
export default async function ClassDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<SearchParams>
}) {
const { id } = await params
const sp = await searchParams
const hw = getParam(sp, "hw")
const hwFilter = hw === "active" || hw === "overdue" ? hw : "all"
const [insights, students, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: id, limit: 50 }),
getClassStudents({ classId: id }),
getClassSchedule({ classId: id }),
])
if (!insights) return notFound()
const latest = insights.latest
const filteredAssignments = insights.assignments.filter((a) => {
if (hwFilter === "all") return true
if (hwFilter === "overdue") return a.isOverdue
if (hwFilter === "active") return a.isActive
return true
})
const hasAssignments = filteredAssignments.length > 0
const scheduleBuilderClasses = [
{
id: insights.class.id,
name: insights.class.name,
grade: insights.class.grade,
homeroom: insights.class.homeroom ?? null,
room: insights.class.room ?? null,
studentCount: insights.studentCounts.total,
},
]
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/teacher/classes/my">Back</Link>
</Button>
<Badge variant="secondary">{insights.class.grade}</Badge>
<Badge variant="outline">{insights.studentCounts.total} students</Badge>
</div>
<h2 className="text-2xl font-bold tracking-tight">{insights.class.name}</h2>
<div className="text-sm text-muted-foreground">
{insights.class.room ? `Room: ${insights.class.room}` : "Room: Not set"}
{insights.class.homeroom ? ` · Homeroom: ${insights.class.homeroom}` : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>Students</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>Schedule</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(insights.class.id)}`}>Insights</Link>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} · Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Schedule items</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{schedule.length}</div>
<div className="text-xs text-muted-foreground">Weekly timetable entries</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{insights.assignments.length}</div>
<div className="text-xs text-muted-foreground">{latest ? `Latest ${formatDate(latest.createdAt)}` : "No homework yet"}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(insights.overallScores.avg, 1)}</div>
<div className="text-xs text-muted-foreground">
Median {formatNumber(insights.overallScores.median, 1)} · Count {insights.overallScores.count}
</div>
</CardContent>
</Card>
</div>
{latest ? (
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-base">Latest homework</CardTitle>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{latest.title}</span>
<Badge variant="outline" className="capitalize">
{latest.status}
</Badge>
<span>·</span>
<span>{formatDate(latest.createdAt)}</span>
{latest.dueAt ? (
<>
<span>·</span>
<span>Due {formatDate(latest.dueAt)}</span>
</>
) : null}
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}`}>Open</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments/${latest.assignmentId}/submissions`}>Submissions</Link>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-5">
<div>
<div className="text-sm text-muted-foreground">Targeted</div>
<div className="text-lg font-semibold">{latest.targetCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Submitted</div>
<div className="text-lg font-semibold">{latest.submittedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Graded</div>
<div className="text-lg font-semibold">{latest.gradedCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Average</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.avg, 1)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Median</div>
<div className="text-lg font-semibold">{formatNumber(latest.scoreStats.median, 1)}</div>
</div>
</CardContent>
</Card>
) : null}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Students (preview)</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
{students.length === 0 ? (
<div className="text-sm text-muted-foreground">No students enrolled.</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.slice(0, 8).map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-muted-foreground">{s.email}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Schedule</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(insights.class.id)}`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
<ScheduleView schedule={schedule} classes={scheduleBuilderClasses} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<CardTitle className="text-base">Homework history</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Button asChild size="sm" variant={hwFilter === "all" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}`}>All</Link>
</Button>
<Button asChild size="sm" variant={hwFilter === "active" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=active`}>Active</Link>
</Button>
<Button asChild size="sm" variant={hwFilter === "overdue" ? "secondary" : "outline"}>
<Link href={`/teacher/classes/my/${encodeURIComponent(insights.class.id)}?hw=overdue`}>Overdue</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(insights.class.id)}`}>Open list</Link>
</Button>
<Button asChild size="sm">
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(insights.class.id)}`}>New homework</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{!hasAssignments ? (
<div className="text-sm text-muted-foreground">No homework assignments yet.</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAssignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/assignments/${a.assignmentId}`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">Created {formatDate(a.createdAt)}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell>{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right">{a.targetCount}</TableCell>
<TableCell className="text-right">{a.submittedCount}</TableCell>
<TableCell className="text-right">{a.gradedCount}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.avg, 1)}</TableCell>
<TableCell className="text-right">{formatNumber(a.scoreStats.median, 1)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-start justify-between gap-3">
<Skeleton className="h-5 w-[60%]" />
<Skeleton className="h-5 w-20" />
</div>
<Skeleton className="mt-3 h-4 w-32" />
<div className="mt-6 flex items-center justify-between">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-16" />
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,10 +1,18 @@
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Users } from "lucide-react"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
export const dynamic = "force-dynamic"
export default function MyClassesPage() {
return <MyClassesPageImpl />
}
async function MyClassesPageImpl() {
const classes = await getTeacherClasses()
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">My Classes</h2>
<p className="text-muted-foreground">
@@ -12,11 +20,8 @@ export default function MyClassesPage() {
</p>
</div>
</div>
<EmptyState
title="No classes found"
description="You are not assigned to any classes yet."
icon={Users}
/>
<MyClassesGrid classes={classes} />
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-10 w-full" />
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
<div className="mt-6 space-y-3">
<Skeleton className="h-4 w-[70%]" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[60%]" />
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,10 +1,73 @@
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Suspense } from "react"
import { Calendar } from "lucide-react"
export default function SchedulePage() {
import { getClassSchedule, getTeacherClasses } from "@/modules/classes/data-access"
import { ScheduleFilters } from "@/modules/classes/components/schedule-filters"
import { ScheduleView } from "@/modules/classes/components/schedule-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
async function ScheduleResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const classId = getParam(params, "classId")
const classes = await getTeacherClasses()
const schedule = await getClassSchedule({
classId: classId && classId !== "all" ? classId : undefined,
})
const hasFilters = Boolean(classId && classId !== "all")
if (schedule.length === 0) {
return (
<EmptyState
icon={Calendar}
title={hasFilters ? "No schedule for this class" : "No schedule available"}
description={hasFilters ? "Try selecting another class." : "Your class schedule has not been set up yet."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/classes/schedule" } : undefined}
className="h-[360px] bg-card"
/>
)
}
return <ScheduleView schedule={schedule} classes={classes} />
}
function ScheduleResultsFallback() {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
<div className="mt-6 space-y-3">
<Skeleton className="h-4 w-[70%]" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[60%]" />
</div>
</div>
))}
</div>
)
}
export default async function SchedulePage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">
@@ -12,11 +75,16 @@ export default function SchedulePage() {
</p>
</div>
</div>
<EmptyState
title="No schedule available"
description="Your class schedule has not been set up yet."
icon={Calendar}
/>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ScheduleFilters classes={classes} />
</Suspense>
<Suspense fallback={<ScheduleResultsFallback />}>
<ScheduleResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-10 w-full" />
<div className="rounded-md border bg-card">
<div className="space-y-2 p-4">
{Array.from({ length: 10 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
)
}

View File

@@ -1,10 +1,74 @@
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Suspense } from "react"
import { User } from "lucide-react"
export default function StudentsPage() {
import { getClassStudents, getTeacherClasses } from "@/modules/classes/data-access"
import { StudentsFilters } from "@/modules/classes/components/students-filters"
import { StudentsTable } from "@/modules/classes/components/students-table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
async function StudentsResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const q = getParam(params, "q") || undefined
const classId = getParam(params, "classId")
const filteredStudents = await getClassStudents({
q,
classId: classId && classId !== "all" ? classId : undefined,
})
const hasFilters = Boolean(q || (classId && classId !== "all"))
if (filteredStudents.length === 0) {
return (
<EmptyState
icon={User}
title={hasFilters ? "No students match your filters" : "No students found"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "There are no students in your classes yet."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/classes/students" } : undefined}
className="h-[360px] bg-card"
/>
)
}
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div className="rounded-md border bg-card">
<StudentsTable students={filteredStudents} />
</div>
)
}
function StudentsResultsFallback() {
return (
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 8 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
)
}
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Students</h2>
<p className="text-muted-foreground">
@@ -12,11 +76,16 @@ export default function StudentsPage() {
</p>
</div>
</div>
<EmptyState
title="No students found"
description="There are no students in your classes yet."
icon={User}
/>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<StudentsFilters classes={classes} />
</Suspense>
<Suspense fallback={<StudentsResultsFallback />}>
<StudentsResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -1,28 +1,27 @@
import { TeacherStats } from "@/modules/dashboard/components/teacher-stats";
import { TeacherSchedule } from "@/modules/dashboard/components/teacher-schedule";
import { RecentSubmissions } from "@/modules/dashboard/components/recent-submissions";
import { TeacherQuickActions } from "@/modules/dashboard/components/teacher-quick-actions";
import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view"
import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access";
import { getHomeworkAssignments, getHomeworkSubmissions } from "@/modules/homework/data-access";
export const dynamic = "force-dynamic";
export default async function TeacherDashboardPage() {
const teacherId = await getTeacherIdForMutations();
const [classes, schedule, assignments, submissions] = await Promise.all([
getTeacherClasses({ teacherId }),
getClassSchedule({ teacherId }),
getHomeworkAssignments({ creatorId: teacherId }),
getHomeworkSubmissions({ creatorId: teacherId }),
]);
export default function TeacherDashboardPage() {
return (
<div className="flex-1 space-y-4">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Teacher Dashboard</h2>
<div className="flex items-center space-x-2">
<TeacherQuickActions />
</div>
</div>
{/* Overview Stats */}
<TeacherStats />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
{/* Left Column: Schedule (3/7 width) */}
<TeacherSchedule />
{/* Right Column: Recent Activity (4/7 width) */}
<RecentSubmissions />
</div>
</div>
);
<TeacherDashboardView
data={{
classes,
schedule,
assignments,
submissions,
}}
/>
)
}

View File

@@ -0,0 +1,244 @@
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { getGradesForStaff } from "@/modules/school/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { BarChart3 } from "lucide-react"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
if (typeof v === "string") return v
if (Array.isArray(v)) return v[0]
return undefined
}
const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
export default async function TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const gradeId = getParam(params, "gradeId")
const teacherId = await getTeacherIdForMutations()
const grades = await getGradesForStaff(teacherId)
const allowedIds = new Set(grades.map((g) => g.id))
const selected = gradeId && gradeId !== "all" && allowedIds.has(gradeId) ? gradeId : ""
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
if (grades.length === 0) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
<p className="text-muted-foreground">View grade-level homework statistics for grades you lead.</p>
</div>
<EmptyState
icon={BarChart3}
title="No grades assigned"
description="You are not assigned as a grade head or teaching head for any grade."
className="h-[360px] bg-card"
/>
</div>
)
}
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
<p className="text-muted-foreground">Homework statistics aggregated across all classes in a grade.</p>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Filters</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{grades.length}
</Badge>
</CardHeader>
<CardContent>
<form action="/teacher/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
<label className="text-sm font-medium">Grade</label>
<select
name="gradeId"
defaultValue={selected || "all"}
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
>
<option value="all">Select a grade</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.school.name} / {g.name}
</option>
))}
</select>
<Button type="submit" className="md:ml-2">
Apply
</Button>
</form>
</CardContent>
</Card>
{!selected ? (
<EmptyState
icon={BarChart3}
title="Select a grade to view insights"
description="Pick a grade to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
) : !insights ? (
<EmptyState
icon={BarChart3}
title="Grade not found"
description="This grade may not exist or has no accessible data."
className="h-[360px] bg-card"
/>
) : insights.assignments.length === 0 ? (
<EmptyState
icon={BarChart3}
title="No homework data for this grade"
description="No homework assignments were targeted to students in this grade yet."
className="h-[360px] bg-card"
/>
) : (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Classes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.classCount}</div>
<div className="text-xs text-muted-foreground">
{insights.grade.school.name} / {insights.grade.name}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.overallScores.avg)}</div>
<div className="text-xs text-muted-foreground">Across graded homework</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Latest Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.latest?.scoreStats.avg ?? null)}</div>
<div className="text-xs text-muted-foreground">{insights.latest ? insights.latest.title : "-"}</div>
</CardContent>
</Card>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Homework timeline</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.assignments.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.avg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.median)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Class ranking</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.classes.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Class</TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead className="text-right">Latest Avg</TableHead>
<TableHead className="text-right">Prev Avg</TableHead>
<TableHead className="text-right">Δ</TableHead>
<TableHead className="text-right">Overall Avg</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.classes.map((c) => (
<TableRow key={c.class.id}>
<TableCell className="font-medium">
{c.class.name}
{c.class.homeroom ? <span className="text-muted-foreground"> {c.class.homeroom}</span> : null}
</TableCell>
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.latestAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.prevAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.deltaAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.overallScores.avg)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,9 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { getHomeworkAssignmentById } from "@/modules/homework/data-access"
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card"
import { HomeworkAssignmentQuestionErrorDetailsCard } from "@/modules/homework/components/homework-assignment-question-error-details-card"
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -10,9 +13,11 @@ export const dynamic = "force-dynamic"
export default async function HomeworkAssignmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const assignment = await getHomeworkAssignmentById(id)
const analytics = await getHomeworkAssignmentAnalytics(id)
if (!assignment) return notFound()
if (!analytics) return notFound()
const { assignment, questions, gradedSampleCount } = analytics
return (
<div className="flex h-full flex-col space-y-8 p-8">
@@ -69,12 +74,28 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
<div className="text-sm">
<div className="font-medium">{assignment.dueAt ? formatDate(assignment.dueAt) : "—"}</div>
<div className="text-muted-foreground">
Late: {assignment.allowLate ? (assignment.lateDueAt ? formatDate(assignment.lateDueAt) : "Allowed") : "Not allowed"}
Late:{" "}
{assignment.allowLate
? assignment.lateDueAt
? formatDate(assignment.lateDueAt)
: "Allowed"
: "Not allowed"}
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-2">
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} />
<HomeworkAssignmentQuestionErrorDetailsCard questions={questions} gradedSampleCount={gradedSampleCount} />
</div>
<HomeworkAssignmentExamContentCard
structure={assignment.structure}
questions={questions}
gradedSampleCount={gradedSampleCount}
/>
</div>
)
}

View File

@@ -28,10 +28,22 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa
<div>
<h2 className="text-2xl font-bold tracking-tight">Submissions</h2>
<p className="text-muted-foreground">{assignment.title}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>Exam: {assignment.sourceExamTitle}</span>
<span></span>
<span>Targets: {assignment.targetCount}</span>
<span></span>
<span>Submitted: {assignment.submittedCount}</span>
<span></span>
<span>Graded: {assignment.gradedCount}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={`/teacher/homework/assignments/${id}`}>Back</Link>
<Link href="/teacher/homework/submissions">Back</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/homework/assignments/${id}`}>Open Assignment</Link>
</Button>
</div>
</div>

View File

@@ -1,12 +1,13 @@
import { HomeworkAssignmentForm } from "@/modules/homework/components/homework-assignment-form"
import { getExams } from "@/modules/exams/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { FileQuestion } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function CreateHomeworkAssignmentPage() {
const exams = await getExams({})
const [exams, classes] = await Promise.all([getExams({}), getTeacherClasses()])
const options = exams.map((e) => ({ id: e.id, title: e.title }))
return (
@@ -25,8 +26,15 @@ export default async function CreateHomeworkAssignmentPage() {
icon={FileQuestion}
action={{ label: "Create Exam", href: "/teacher/exams/create" }}
/>
) : classes.length === 0 ? (
<EmptyState
title="No classes available"
description="Create a class first, then publish homework to that class."
icon={FileQuestion}
action={{ label: "Go to Classes", href: "/teacher/classes/my" }}
/>
) : (
<HomeworkAssignmentForm exams={options} />
<HomeworkAssignmentForm exams={options} classes={classes} />
)}
</div>
)

View File

@@ -12,13 +12,28 @@ import {
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignments } from "@/modules/homework/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { PenTool, PlusCircle } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function AssignmentsPage() {
const assignments = await getHomeworkAssignments()
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 AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const classId = getParam(sp, "classId") || undefined
const [assignments, classes] = await Promise.all([
getHomeworkAssignments({ classId: classId && classId !== "all" ? classId : undefined }),
classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
])
const hasAssignments = assignments.length > 0
const className = classId && classId !== "all" ? classes.find((c) => c.id === classId)?.name : undefined
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
@@ -26,25 +41,41 @@ export default async function AssignmentsPage() {
<div>
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
<p className="text-muted-foreground">
Manage homework assignments.
{classId && classId !== "all" ? `Filtered by class: ${className ?? classId}` : "Manage homework assignments."}
</p>
</div>
<Button asChild>
<Link href="/teacher/homework/assignments/create">
<PlusCircle className="mr-2 h-4 w-4" />
Create Assignment
</Link>
</Button>
<div className="flex items-center gap-2">
{classId && classId !== "all" ? (
<Button asChild variant="outline">
<Link href="/teacher/homework/assignments">Clear filter</Link>
</Button>
) : null}
<Button asChild>
<Link
href={
classId && classId !== "all"
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`
: "/teacher/homework/assignments/create"
}
>
<PlusCircle className="mr-2 h-4 w-4" />
Create Assignment
</Link>
</Button>
</div>
</div>
{!hasAssignments ? (
<EmptyState
title="No assignments"
description="You haven't created any assignments yet."
description={classId && classId !== "all" ? "No assignments for this class yet." : "You haven't created any assignments yet."}
icon={PenTool}
action={{
label: "Create Assignment",
href: "/teacher/homework/assignments/create",
href:
classId && classId !== "all"
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`
: "/teacher/homework/assignments/create",
}}
/>
) : (

View File

@@ -10,14 +10,16 @@ import {
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkSubmissions } from "@/modules/homework/data-access"
import { getHomeworkAssignmentReviewList } from "@/modules/homework/data-access"
import { Inbox } from "lucide-react"
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
export default async function SubmissionsPage() {
const submissions = await getHomeworkSubmissions()
const hasSubmissions = submissions.length > 0
const creatorId = await getTeacherIdForMutations()
const assignments = await getHomeworkAssignmentReviewList({ creatorId })
const hasAssignments = assignments.length > 0
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
@@ -25,15 +27,15 @@ export default async function SubmissionsPage() {
<div>
<h2 className="text-2xl font-bold tracking-tight">Submissions</h2>
<p className="text-muted-foreground">
Review student homework submissions.
Review homework by assignment.
</p>
</div>
</div>
{!hasSubmissions ? (
{!hasAssignments ? (
<EmptyState
title="No submissions"
description="There are no homework submissions to review."
title="No assignments"
description="There are no homework assignments to review yet."
icon={Inbox}
/>
) : (
@@ -42,29 +44,31 @@ export default async function SubmissionsPage() {
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Student</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Score</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targets</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submissions.map((s) => (
<TableRow key={s.id}>
{assignments.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/submissions/${s.id}`} className="hover:underline">
{s.assignmentTitle}
<Link href={`/teacher/homework/assignments/${a.id}/submissions`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">{a.sourceExamTitle}</div>
</TableCell>
<TableCell>{s.studentName}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
{a.status}
</Badge>
{s.isLate ? <span className="ml-2 text-xs text-destructive">Late</span> : null}
</TableCell>
<TableCell className="text-muted-foreground">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
<TableCell>{typeof s.score === "number" ? s.score : "-"}</TableCell>
<TableCell className="text-muted-foreground">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right">{a.targetCount}</TableCell>
<TableCell className="text-right">{a.submittedCount}</TableCell>
<TableCell className="text-right">{a.gradedCount}</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -1,12 +1,14 @@
import { notFound } from "next/navigation";
import { ArrowLeft, Edit } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByChapterId } from "@/modules/textbooks/data-access";
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
import { TextbookContentLayout } from "@/modules/textbooks/components/textbook-content-layout";
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog";
export const dynamic = "force-dynamic"
export default async function TextbookDetailPage({
params,
}: {
@@ -14,36 +16,16 @@ export default async function TextbookDetailPage({
}) {
const { id } = await params;
const [textbook, chapters] = await Promise.all([
const [textbook, chapters, knowledgePoints] = await Promise.all([
getTextbookById(id),
getChaptersByTextbookId(id),
getKnowledgePointsByTextbookId(id),
]);
if (!textbook) {
notFound();
}
// Fetch all KPs for these chapters. In a real app, this might be optimized to fetch only needed or use a different query strategy.
// For now, we simulate fetching KPs for all chapters to pass down, or we could fetch on demand.
// Given the layout loads everything client-side for interactivity, let's fetch all KPs associated with any chapter in this textbook.
// We'll need to extend the data access for this specific query pattern or loop.
// For simplicity in this mock, let's assume getKnowledgePointsByChapterId can handle fetching all KPs for a textbook if we had such a function,
// or we iterate. Let's create a helper to get all KPs for the textbook's chapters.
// Actually, let's update data-access to support getting KPs by Textbook ID directly or just fetch all for mock.
// Since we don't have getKnowledgePointsByTextbookId, we will map over chapters.
const allKnowledgePoints = (await Promise.all(
chapters.map(c => getKnowledgePointsByChapterId(c.id))
)).flat();
// Also need to get KPs for children chapters if any
const childrenKPs = (await Promise.all(
chapters.flatMap(c => c.children || []).map(child => getKnowledgePointsByChapterId(child.id))
)).flat();
const knowledgePoints = [...allKnowledgePoints, ...childrenKPs];
return (
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
{/* Header / Nav (Fixed height) */}

View File

@@ -1,20 +1,53 @@
import { Search, Filter } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { Suspense } from "react"
import { BookOpen } from "lucide-react"
import { TextbookCard } from "@/modules/textbooks/components/textbook-card";
import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog";
import { getTextbooks } from "@/modules/textbooks/data-access";
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default async function TextbooksPage() {
// In a real app, we would parse searchParams here
const textbooks = await getTextbooks();
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
async function TextbooksResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const q = getParam(params, "q") || undefined
const subject = getParam(params, "subject")
const grade = getParam(params, "grade")
const textbooks = await getTextbooks(q, subject || undefined, grade || undefined)
const hasFilters = Boolean(q || (subject && subject !== "all") || (grade && grade !== "all"))
if (textbooks.length === 0) {
return (
<EmptyState
icon={BookOpen}
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "Create your first textbook to start organizing chapters."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/textbooks" } : undefined}
className="bg-card"
/>
)
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{textbooks.map((textbook) => (
<TextbookCard key={textbook.id} textbook={textbook} />
))}
</div>
)
}
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
return (
<div className="space-y-6">
@@ -29,50 +62,13 @@ export default async function TextbooksPage() {
<TextbookFormDialog />
</div>
{/* Toolbar */}
<div className="flex flex-col gap-4 md:flex-row md:items-center justify-between bg-card p-4 rounded-lg border shadow-sm">
<div className="relative w-full md:w-96">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search textbooks..."
className="pl-9 bg-background"
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Select>
<SelectTrigger className="w-[140px] bg-background">
<Filter className="mr-2 h-4 w-4 text-muted-foreground" />
<SelectValue placeholder="Subject" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Subjects</SelectItem>
<SelectItem value="math">Mathematics</SelectItem>
<SelectItem value="physics">Physics</SelectItem>
<SelectItem value="history">History</SelectItem>
<SelectItem value="english">English</SelectItem>
</SelectContent>
</Select>
<Select>
<SelectTrigger className="w-[140px] bg-background">
<SelectValue placeholder="Grade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Grades</SelectItem>
<SelectItem value="10">Grade 10</SelectItem>
<SelectItem value="11">Grade 11</SelectItem>
<SelectItem value="12">Grade 12</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Suspense fallback={<div className="h-14 w-full animate-pulse rounded-lg bg-muted" />}>
<TextbookFilters />
</Suspense>
{/* Grid Content */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{textbooks.map((textbook) => (
<TextbookCard key={textbook.id} textbook={textbook} />
))}
</div>
<Suspense fallback={<div className="h-[360px] w-full animate-pulse rounded-md bg-muted" />}>
<TextbooksResults searchParams={searchParams} />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,4 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

View File

@@ -1,6 +1,7 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { ThemeProvider } from "@/shared/components/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner";
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { AuthSessionProvider } from "@/shared/components/auth-session-provider"
import "./globals.css";
export const metadata: Metadata = {
@@ -25,9 +26,11 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<NuqsAdapter>
{children}
</NuqsAdapter>
<AuthSessionProvider>
<NuqsAdapter>
{children}
</NuqsAdapter>
</AuthSessionProvider>
<Toaster />
</ThemeProvider>
</body>

61
src/auth.ts Normal file
View File

@@ -0,0 +1,61 @@
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
export const { handlers, auth, signIn, signOut } = NextAuth({
session: { strategy: "jwt" },
pages: { signIn: "/login" },
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
const email = String(credentials?.email ?? "").trim().toLowerCase()
const password = String(credentials?.password ?? "")
if (!email || !password) return null
const [{ eq }, { db }, { users }] = await Promise.all([
import("drizzle-orm"),
import("@/shared/db"),
import("@/shared/db/schema"),
])
const user = await db.query.users.findFirst({
where: eq(users.email, email),
})
if (!user) return null
const storedPassword = user.password ?? null
if (storedPassword) {
if (storedPassword !== password) return null
} else if (process.env.NODE_ENV === "production") {
return null
}
return {
id: user.id,
name: user.name ?? undefined,
email: user.email,
role: (user.role ?? "student") as string,
}
},
}),
],
callbacks: {
jwt: async ({ token, user }) => {
if (user) {
token.id = (user as { id: string }).id
token.role = (user as { role?: string }).role ?? "student"
}
return token
},
session: async ({ session, token }) => {
if (session.user) {
session.user.id = String(token.id ?? "")
session.user.role = String(token.role ?? "student")
}
return session
},
},
})

44
src/middleware.ts Normal file
View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server"
import type { NextAuthRequest } from "next-auth"
import { auth } from "./auth"
function roleHome(role: string) {
if (role === "admin") return "/admin/dashboard"
if (role === "student") return "/student/dashboard"
if (role === "parent") return "/parent/dashboard"
return "/teacher/dashboard"
}
export default auth((req: NextAuthRequest) => {
const { pathname } = req.nextUrl
const session = req.auth
if (!session?.user) {
const url = req.nextUrl.clone()
url.pathname = "/login"
url.searchParams.set("callbackUrl", pathname)
return NextResponse.redirect(url)
}
const role = String(session.user.role ?? "teacher")
if (pathname.startsWith("/admin/") && role !== "admin") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
}
if (pathname.startsWith("/teacher/") && role !== "teacher") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
}
if (pathname.startsWith("/student/") && role !== "student") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
}
if (pathname.startsWith("/parent/") && role !== "parent") {
return NextResponse.redirect(new URL(roleHome(role), req.url))
}
return NextResponse.next()
})
export const config = {
matcher: ["/dashboard", "/admin/:path*", "/teacher/:path*", "/student/:path*", "/parent/:path*", "/settings/:path*", "/profile"],
}

View File

@@ -1,4 +1,3 @@
import Link from "next/link"
import { GraduationCap } from "lucide-react"
interface AuthLayoutProps {

View File

@@ -2,6 +2,8 @@
import * as React from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { signIn } from "next-auth/react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
@@ -12,14 +14,32 @@ type LoginFormProps = React.HTMLAttributes<HTMLDivElement>
export function LoginForm({ className, ...props }: LoginFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const router = useRouter()
const searchParams = useSearchParams()
async function onSubmit(event: React.SyntheticEvent) {
event.preventDefault()
setIsLoading(true)
setTimeout(() => {
setIsLoading(false)
}, 3000)
const form = event.currentTarget as HTMLFormElement
const formData = new FormData(form)
const email = String(formData.get("email") ?? "")
const password = String(formData.get("password") ?? "")
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"
const result = await signIn("credentials", {
redirect: false,
email,
password,
callbackUrl,
})
setIsLoading(false)
if (!result?.error) {
router.push(result?.url ?? callbackUrl)
router.refresh()
}
}
return (
@@ -38,6 +58,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
@@ -58,6 +79,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
</div>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
disabled={isLoading}

View File

@@ -0,0 +1,434 @@
"use server";
import { revalidatePath } from "next/cache"
import { auth } from "@/auth"
import type { ActionState } from "@/shared/types/action-state"
import {
createAdminClass,
createClassScheduleItem,
createTeacherClass,
deleteAdminClass,
deleteClassScheduleItem,
deleteTeacherClass,
enrollStudentByEmail,
enrollStudentByInvitationCode,
ensureClassInvitationCode,
regenerateClassInvitationCode,
setClassSubjectTeachers,
setStudentEnrollmentStatus,
updateAdminClass,
updateClassScheduleItem,
updateTeacherClass,
} from "./data-access"
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "./types"
const isClassSubject = (v: string): v is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(v as ClassSubject)
export async function createTeacherClassAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
if (typeof name !== "string" || name.trim().length === 0) {
return { success: false, message: "Class name is required" }
}
if (typeof grade !== "string" || grade.trim().length === 0) {
return { success: false, message: "Grade is required" }
}
try {
const id = await createTeacherClass({
schoolName: typeof schoolName === "string" ? schoolName : null,
schoolId: typeof schoolId === "string" ? schoolId : null,
name,
grade,
gradeId: typeof gradeId === "string" ? gradeId : null,
homeroom: typeof homeroom === "string" ? homeroom : null,
room: typeof room === "string" ? room : null,
})
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
}
}
export async function updateTeacherClassAction(
classId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
await updateTeacherClass(classId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined,
name: typeof name === "string" ? name : undefined,
grade: typeof grade === "string" ? grade : undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined,
room: typeof room === "string" ? room : undefined,
})
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
}
}
export async function deleteTeacherClassAction(classId: string): Promise<ActionState> {
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
await deleteTeacherClass(classId)
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
}
}
export async function enrollStudentByEmailAction(
classId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const email = formData.get("email")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Please select a class" }
}
if (typeof email !== "string" || email.trim().length === 0) {
return { success: false, message: "Student email is required" }
}
try {
await enrollStudentByEmail(classId, email)
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/my")
return { success: true, message: "Student added successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to add student" }
}
}
export async function joinClassByInvitationCodeAction(
prevState: ActionState<{ classId: string }> | null,
formData: FormData
): Promise<ActionState<{ classId: string }>> {
const code = formData.get("code")
if (typeof code !== "string" || code.trim().length === 0) {
return { success: false, message: "Invitation code is required" }
}
const session = await auth()
if (!session?.user?.id || String(session.user.role ?? "") !== "student") {
return { success: false, message: "Unauthorized" }
}
try {
const classId = await enrollStudentByInvitationCode(session.user.id, code)
revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule")
revalidatePath("/profile")
return { success: true, message: "Joined class successfully", data: { classId } }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to join class" }
}
}
export async function ensureClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
const code = await ensureClassInvitationCode(classId)
revalidatePath("/teacher/classes/my")
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
return { success: true, message: "Invitation code ready", data: { code } }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to generate code" }
}
}
export async function regenerateClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
const code = await regenerateClassInvitationCode(classId)
revalidatePath("/teacher/classes/my")
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
return { success: true, message: "Invitation code updated", data: { code } }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to regenerate code" }
}
}
export async function setStudentEnrollmentStatusAction(
classId: string,
studentId: string,
status: "active" | "inactive"
): Promise<ActionState> {
if (!classId?.trim() || !studentId?.trim()) {
return { success: false, message: "Missing enrollment info" }
}
try {
await setStudentEnrollmentStatus(classId, studentId, status)
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/my")
return { success: true, message: "Student updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update student" }
}
}
export async function createClassScheduleItemAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const classId = formData.get("classId")
const weekday = formData.get("weekday")
const startTime = formData.get("startTime")
const endTime = formData.get("endTime")
const course = formData.get("course")
const location = formData.get("location")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Please select a class" }
}
if (typeof weekday !== "string" || weekday.trim().length === 0) {
return { success: false, message: "Weekday is required" }
}
const weekdayNum = Number(weekday)
if (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7) {
return { success: false, message: "Invalid weekday" }
}
if (typeof course !== "string" || course.trim().length === 0) {
return { success: false, message: "Course is required" }
}
if (typeof startTime !== "string" || typeof endTime !== "string") {
return { success: false, message: "Time is required" }
}
try {
const id = await createClassScheduleItem({
classId,
weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7,
startTime,
endTime,
course,
location: typeof location === "string" ? location : null,
})
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create schedule item" }
}
}
export async function updateClassScheduleItemAction(
scheduleId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const classId = formData.get("classId")
const weekday = formData.get("weekday")
const startTime = formData.get("startTime")
const endTime = formData.get("endTime")
const course = formData.get("course")
const location = formData.get("location")
if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) {
return { success: false, message: "Missing schedule id" }
}
const weekdayNum = typeof weekday === "string" && weekday.trim().length > 0 ? Number(weekday) : undefined
if (weekdayNum !== undefined && (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7)) {
return { success: false, message: "Invalid weekday" }
}
try {
await updateClassScheduleItem(scheduleId, {
classId: typeof classId === "string" ? classId : undefined,
weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7 | undefined,
startTime: typeof startTime === "string" ? startTime : undefined,
endTime: typeof endTime === "string" ? endTime : undefined,
course: typeof course === "string" ? course : undefined,
location: typeof location === "string" ? location : undefined,
})
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update schedule item" }
}
}
export async function deleteClassScheduleItemAction(scheduleId: string): Promise<ActionState> {
if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) {
return { success: false, message: "Missing schedule id" }
}
try {
await deleteClassScheduleItem(scheduleId)
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete schedule item" }
}
}
export async function createAdminClassAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
if (typeof name !== "string" || name.trim().length === 0) {
return { success: false, message: "Class name is required" }
}
if (typeof grade !== "string" || grade.trim().length === 0) {
return { success: false, message: "Grade is required" }
}
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
return { success: false, message: "Teacher is required" }
}
try {
const id = await createAdminClass({
schoolName: typeof schoolName === "string" ? schoolName : null,
schoolId: typeof schoolId === "string" ? schoolId : null,
name,
grade,
gradeId: typeof gradeId === "string" ? gradeId : null,
teacherId,
homeroom: typeof homeroom === "string" ? homeroom : null,
room: typeof room === "string" ? room : null,
})
revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
}
}
export async function updateAdminClassAction(
classId: string,
prevState: ActionState | undefined,
formData: FormData
): Promise<ActionState> {
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
const subjectTeachers = formData.get("subjectTeachers")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
await updateAdminClass(classId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined,
name: typeof name === "string" ? name : undefined,
grade: typeof grade === "string" ? grade : undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined,
teacherId: typeof teacherId === "string" ? teacherId : undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined,
room: typeof room === "string" ? room : undefined,
})
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
const parsed = JSON.parse(subjectTeachers) as unknown
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
await setClassSubjectTeachers({
classId,
assignments: parsed.flatMap((item) => {
if (!item || typeof item !== "object") return []
const subject = (item as { subject?: unknown }).subject
const teacherId = (item as { teacherId?: unknown }).teacherId
if (typeof subject !== "string" || !isClassSubject(subject)) return []
if (teacherId === null || typeof teacherId === "undefined") {
return [{ subject, teacherId: null }]
}
if (typeof teacherId !== "string") return []
const trimmed = teacherId.trim()
return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }]
}),
})
}
revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
}
}
export async function deleteAdminClassAction(classId: string): Promise<ActionState> {
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
await deleteAdminClass(classId)
revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
}
}

View File

@@ -0,0 +1,434 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import type { AdminClassListItem, ClassSubjectTeacherAssignment, TeacherOption } from "../types"
import { DEFAULT_CLASS_SUBJECTS } from "../types"
import { createAdminClassAction, deleteAdminClassAction, updateAdminClassAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { formatDate } from "@/shared/lib/utils"
export function AdminClassesClient({
classes,
teachers,
}: {
classes: AdminClassListItem[]
teachers: TeacherOption[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<AdminClassListItem | null>(null)
const [deleteItem, setDeleteItem] = useState<AdminClassListItem | null>(null)
const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers])
const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId)
const [editTeacherId, setEditTeacherId] = useState("")
const [editSubjectTeachers, setEditSubjectTeachers] = useState<Array<{ subject: string; teacherId: string | null }>>([])
useEffect(() => {
if (!createOpen) return
setCreateTeacherId(defaultTeacherId)
}, [createOpen, defaultTeacherId])
useEffect(() => {
if (!editItem) return
setEditTeacherId(editItem.teacher.id)
setEditSubjectTeachers(
DEFAULT_CLASS_SUBJECTS.map((s) => ({
subject: s,
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
}))
)
}, [editItem])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await createAdminClassAction(undefined, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create class")
}
} catch {
toast.error("Failed to create class")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
const res = await updateAdminClassAction(editItem.id, undefined, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
}
} catch {
toast.error("Failed to update class")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteAdminClassAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete class")
}
} catch {
toast.error("Failed to delete class")
} finally {
setIsWorking(false)
}
}
const setSubjectTeacher = (subject: string, teacherId: string | null) => {
setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p)))
}
const formatSubjectTeachers = (list: ClassSubjectTeacherAssignment[]) => {
const pairs = list
.filter((x) => x.teacher)
.map((x) => `${x.subject}:${x.teacher?.name ?? ""}`)
.filter((x) => x.length > 0)
return pairs.length > 0 ? pairs.join("") : "-"
}
return (
<>
<div className="flex justify-end">
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
<Plus className="mr-2 h-4 w-4" />
New class
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">All classes</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{classes.length}
</Badge>
</CardHeader>
<CardContent>
{classes.length === 0 ? (
<EmptyState
title="No classes"
description="Create classes to manage students and schedules."
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>School</TableHead>
<TableHead>Name</TableHead>
<TableHead>Grade</TableHead>
<TableHead>Homeroom</TableHead>
<TableHead>Room</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{classes.map((c) => (
<TableRow key={c.id}>
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditItem(c)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(c)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>New class</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-school-name" className="text-right">
School
</Label>
<Input id="create-school-name" name="schoolName" className="col-span-3" placeholder="e.g. First Primary School" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-name" className="text-right">
Name
</Label>
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Grade 10 · Class 3" autoFocus />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade" className="text-right">
Grade
</Label>
<Input id="create-grade" name="grade" className="col-span-3" placeholder="e.g. Grade 10" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-homeroom" className="text-right">
Homeroom
</Label>
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-room" className="text-right">
Room
</Label>
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Teacher</Label>
<div className="col-span-3">
<Select value={createTeacherId} onValueChange={setCreateTeacherId} disabled={teachers.length === 0}>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={createTeacherId} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking || teachers.length === 0 || !createTeacherId}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(editItem)}
onOpenChange={(open) => {
if (isWorking) return
if (!open) setEditItem(null)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Edit class</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-school-name" className="text-right">
School
</Label>
<Input
id="edit-school-name"
name="schoolName"
className="col-span-3"
defaultValue={editItem.schoolName ?? ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-name" className="text-right">
Name
</Label>
<Input id="edit-name" name="name" className="col-span-3" defaultValue={editItem.name} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-grade" className="text-right">
Grade
</Label>
<Input id="edit-grade" name="grade" className="col-span-3" defaultValue={editItem.grade} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-homeroom" className="text-right">
Homeroom
</Label>
<Input id="edit-homeroom" name="homeroom" className="col-span-3" defaultValue={editItem.homeroom ?? ""} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-room" className="text-right">
Room
</Label>
<Input id="edit-room" name="room" className="col-span-3" defaultValue={editItem.room ?? ""} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<div className="col-span-3">
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={editTeacherId} />
</div>
</div>
<div className="space-y-3 rounded-md border p-4">
<div className="text-sm font-medium"></div>
<div className="grid gap-3">
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
return (
<div key={subject} className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{subject}</Label>
<div className="col-span-3">
<Select
value={selected ?? ""}
onValueChange={(v) => setSubjectTeacher(subject, v ? v : null)}
disabled={teachers.length === 0}
>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
})}
</div>
<input type="hidden" name="subjectTeachers" value={JSON.stringify(editSubjectTeachers)} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking || !editTeacherId}>
Save
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteItem)}
onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete class</AlertDialogTitle>
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this class"}.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,40 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { X } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { TeacherClass } from "../types"
export function InsightsFilters({ classes }: { classes: TeacherClass[] }) {
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="Class" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Select a class</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{classId !== "all" && (
<Button variant="ghost" onClick={() => setClassId(null)} className="h-8 px-2 lg:px-3">
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,533 @@
"use client"
import Link from "next/link"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Calendar, Copy, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2, Users } from "lucide-react"
import { toast } from "sonner"
import { parseAsString, useQueryState } from "nuqs"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { cn } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { TeacherClass } from "../types"
import {
createTeacherClassAction,
deleteTeacherClassAction,
ensureClassInvitationCodeAction,
regenerateClassInvitationCodeAction,
updateTeacherClassAction,
} from "../actions"
export function MyClassesGrid({ classes }: { classes: TeacherClass[] }) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
const [grade, setGrade] = useQueryState("grade", parseAsString.withDefault("all"))
const gradeOptions = useMemo(() => {
const set = new Set<string>()
for (const c of classes) set.add(c.grade)
return Array.from(set).sort((a, b) => a.localeCompare(b))
}, [classes])
const filteredClasses = useMemo(() => {
const needle = q.trim().toLowerCase()
return classes.filter((c) => {
const gradeOk = grade === "all" ? true : c.grade === grade
const qOk = needle.length === 0 ? true : c.name.toLowerCase().includes(needle)
return gradeOk && qOk
})
}, [classes, grade, q])
const defaultGrade = useMemo(() => (grade !== "all" ? grade : classes[0]?.grade ?? ""), [classes, grade])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await createTeacherClassAction(null, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create class")
}
} catch {
toast.error("Failed to create class")
} finally {
setIsWorking(false)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 md:max-w-sm">
<Input
placeholder="Search classes..."
value={q}
onChange={(e) => setQ(e.target.value || null)}
/>
</div>
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Grade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All grades</SelectItem>
{gradeOptions.map((g) => (
<SelectItem key={g} value={g}>
{g}
</SelectItem>
))}
</SelectContent>
</Select>
{(q || grade !== "all") && (
<Button
variant="ghost"
className="h-9"
onClick={() => {
setQ(null)
setGrade(null)
}}
>
Reset
</Button>
)}
</div>
<Dialog
open={createOpen}
onOpenChange={(open) => {
if (isWorking) return
setCreateOpen(open)
}}
>
<DialogTrigger asChild>
<Button className="gap-2" disabled={isWorking}>
<Plus className="size-4" />
New class
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create class</DialogTitle>
<DialogDescription>Add a new class to start managing students.</DialogDescription>
</DialogHeader>
<form action={handleCreate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-school-name" className="text-right">
School
</Label>
<Input
id="create-school-name"
name="schoolName"
className="col-span-3"
placeholder="Optional"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-name" className="text-right">
Name
</Label>
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Class 1A" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade" className="text-right">
Grade
</Label>
<Input
id="create-grade"
name="grade"
className="col-span-3"
placeholder="e.g. Grade 7"
defaultValue={defaultGrade}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-homeroom" className="text-right">
Homeroom
</Label>
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-room" className="text-right">
Room
</Label>
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{classes.length === 0 ? (
<EmptyState
title="No classes yet"
description="Create your first class to start managing students and schedules."
icon={Users}
action={{ label: "Create class", onClick: () => setCreateOpen(true) }}
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3"
/>
) : filteredClasses.length === 0 ? (
<EmptyState
title="No classes match your filters"
description="Try clearing filters or adjusting keywords."
icon={Users}
action={{ label: "Clear filters", onClick: () => {
setQ(null)
setGrade(null)
}}}
className="h-[360px] bg-card sm:col-span-2 lg:col-span-3"
/>
) : (
filteredClasses.map((c) => (
<ClassCard
key={c.id}
c={c}
onWorkingChange={setIsWorking}
isWorking={isWorking}
/>
))
)}
</div>
</div>
)
}
function ClassCard({
c,
isWorking,
onWorkingChange,
}: {
c: TeacherClass
isWorking: boolean
onWorkingChange: (v: boolean) => void
}) {
const router = useRouter()
const [showEdit, setShowEdit] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const handleEnsureCode = async () => {
onWorkingChange(true)
try {
const res = await ensureClassInvitationCodeAction(c.id)
if (res.success) {
toast.success(res.message || "Invitation code ready")
router.refresh()
} else {
toast.error(res.message || "Failed to generate invitation code")
}
} catch {
toast.error("Failed to generate invitation code")
} finally {
onWorkingChange(false)
}
}
const handleRegenerateCode = async () => {
onWorkingChange(true)
try {
const res = await regenerateClassInvitationCodeAction(c.id)
if (res.success) {
toast.success(res.message || "Invitation code updated")
router.refresh()
} else {
toast.error(res.message || "Failed to regenerate invitation code")
}
} catch {
toast.error("Failed to regenerate invitation code")
} finally {
onWorkingChange(false)
}
}
const handleCopyCode = async () => {
const code = c.invitationCode ?? ""
if (!code) return
try {
await navigator.clipboard.writeText(code)
toast.success("Copied invitation code")
} catch {
toast.error("Failed to copy")
}
}
const handleEdit = async (formData: FormData) => {
onWorkingChange(true)
try {
const res = await updateTeacherClassAction(c.id, null, formData)
if (res.success) {
toast.success(res.message)
setShowEdit(false)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
}
} catch {
toast.error("Failed to update class")
} finally {
onWorkingChange(false)
}
}
const handleDelete = async () => {
onWorkingChange(true)
try {
const res = await deleteTeacherClassAction(c.id)
if (res.success) {
toast.success(res.message)
setShowDelete(false)
router.refresh()
} else {
toast.error(res.message || "Failed to delete class")
}
} catch {
toast.error("Failed to delete class")
} finally {
onWorkingChange(false)
}
}
return (
<Card className="shadow-none">
<CardHeader className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-base truncate">
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline">
{c.name}
</Link>
</CardTitle>
<div className="text-muted-foreground text-sm mt-1">
{c.room ? `Room: ${c.room}` : "Room: Not set"}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">{c.grade}</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setShowEdit(true)}>
<Pencil className="mr-2 size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDelete(true)}
>
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium tabular-nums">{c.studentCount} students</div>
{c.homeroom ? <Badge variant="outline">{c.homeroom}</Badge> : null}
</div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs uppercase text-muted-foreground">Invitation code</div>
<div className="font-mono tabular-nums text-sm">{c.invitationCode ?? "-"}</div>
</div>
<div className="flex items-center gap-2">
{c.invitationCode ? (
<>
<Button variant="outline" size="sm" className="gap-2" onClick={handleCopyCode} disabled={isWorking}>
<Copy className="size-4" />
Copy
</Button>
<Button variant="outline" size="sm" className="gap-2" onClick={handleRegenerateCode} disabled={isWorking}>
<RefreshCw className="size-4" />
Regenerate
</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={handleEnsureCode} disabled={isWorking}>
Generate
</Button>
)}
</div>
</div>
<div className={cn("grid gap-2", "grid-cols-2")}>
<Button asChild variant="outline" className="w-full justify-start gap-2">
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(c.id)}`}>
<Users className="size-4" />
Students
</Link>
</Button>
<Button asChild variant="outline" className="w-full justify-start gap-2">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(c.id)}`}>
<Calendar className="size-4" />
Schedule
</Link>
</Button>
</div>
</CardContent>
<Dialog
open={showEdit}
onOpenChange={(open) => {
if (isWorking) return
setShowEdit(open)
}}
>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Edit class</DialogTitle>
<DialogDescription>Update basic class information.</DialogDescription>
</DialogHeader>
<form action={handleEdit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-school-name-${c.id}`} className="text-right">
School
</Label>
<Input
id={`edit-school-name-${c.id}`}
name="schoolName"
className="col-span-3"
defaultValue={c.schoolName ?? ""}
placeholder="Optional"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-name-${c.id}`} className="text-right">
Name
</Label>
<Input
id={`edit-name-${c.id}`}
name="name"
className="col-span-3"
defaultValue={c.name}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-grade-${c.id}`} className="text-right">
Grade
</Label>
<Input
id={`edit-grade-${c.id}`}
name="grade"
className="col-span-3"
defaultValue={c.grade}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-homeroom-${c.id}`} className="text-right">
Homeroom
</Label>
<Input
id={`edit-homeroom-${c.id}`}
name="homeroom"
className="col-span-3"
defaultValue={c.homeroom ?? ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={`edit-room-${c.id}`} className="text-right">
Room
</Label>
<Input
id={`edit-room-${c.id}`}
name="room"
className="col-span-3"
defaultValue={c.room ?? ""}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={showDelete}
onOpenChange={(open) => {
if (isWorking) return
setShowDelete(open)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete class?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <span className="font-medium text-foreground">{c.name}</span> and remove all
enrollments.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={isWorking}
>
{isWorking ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
)
}

View File

@@ -0,0 +1,195 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Plus, X } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import type { TeacherClass } from "../types"
import { createClassScheduleItemAction } from "../actions"
export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
const router = useRouter()
const [open, setOpen] = useState(false)
const [isWorking, setIsWorking] = useState(false)
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
const [createClassId, setCreateClassId] = useState(defaultClassId)
const [weekday, setWeekday] = useState<string>("1")
useEffect(() => {
if (!open) return
setCreateClassId(defaultClassId)
setWeekday("1")
}, [open, defaultClassId])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("classId", createClassId)
const res = await createClassScheduleItemAction(null, formData)
if (res.success) {
toast.success(res.message)
setOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create schedule item")
}
} catch {
toast.error("Failed to create schedule item")
} finally {
setIsWorking(false)
}
}
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="Class" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Classes</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{classId !== "all" && (
<Button
variant="ghost"
onClick={() => setClassId(null)}
className="h-8 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<Dialog
open={open}
onOpenChange={(v) => {
if (isWorking) return
setOpen(v)
}}
>
<DialogTrigger asChild>
<Button className="gap-2" disabled={classes.length === 0}>
<Plus className="size-4" />
Add item
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Add schedule item</DialogTitle>
<DialogDescription>Create a class schedule entry.</DialogDescription>
</DialogHeader>
<form action={handleCreate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={createClassId} onValueChange={setCreateClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="weekday" className="text-right">
Weekday
</Label>
<div className="col-span-3">
<Select value={weekday} onValueChange={setWeekday}>
<SelectTrigger>
<SelectValue placeholder="Select weekday" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Mon</SelectItem>
<SelectItem value="2">Tue</SelectItem>
<SelectItem value="3">Wed</SelectItem>
<SelectItem value="4">Thu</SelectItem>
<SelectItem value="5">Fri</SelectItem>
<SelectItem value="6">Sat</SelectItem>
<SelectItem value="7">Sun</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="weekday" value={weekday} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="startTime" className="text-right">
Start
</Label>
<Input id="startTime" name="startTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="endTime" className="text-right">
End
</Label>
<Input id="endTime" name="endTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="course" className="text-right">
Course
</Label>
<Input id="course" name="course" className="col-span-3" placeholder="e.g. Math" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="location" className="text-right">
Location
</Label>
<Input id="location" name="location" className="col-span-3" placeholder="Optional" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !createClassId}>
{isWorking ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,465 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Clock, MapPin, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { ClassScheduleItem, TeacherClass } from "../types"
import {
createClassScheduleItemAction,
deleteClassScheduleItemAction,
updateClassScheduleItemAction,
} from "../actions"
const WEEKDAYS: Array<{ key: ClassScheduleItem["weekday"]; label: string }> = [
{ key: 1, label: "Mon" },
{ key: 2, label: "Tue" },
{ key: 3, label: "Wed" },
{ key: 4, label: "Thu" },
{ key: 5, label: "Fri" },
{ key: 6, label: "Sat" },
{ key: 7, label: "Sun" },
]
export function ScheduleView({
schedule,
classes,
}: {
schedule: ClassScheduleItem[]
classes: TeacherClass[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [editItem, setEditItem] = useState<ClassScheduleItem | null>(null)
const [deleteItem, setDeleteItem] = useState<ClassScheduleItem | null>(null)
const [createWeekday, setCreateWeekday] = useState<ClassScheduleItem["weekday"]>(1)
const [createOpen, setCreateOpen] = useState(false)
const [createClassId, setCreateClassId] = useState<string>("")
const [editClassId, setEditClassId] = useState<string>("")
const [editWeekday, setEditWeekday] = useState<string>("1")
const classNameById = useMemo(() => new Map(classes.map((c) => [c.id, c.name] as const)), [classes])
const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes])
useEffect(() => {
if (!editItem) return
setEditClassId(editItem.classId)
setEditWeekday(String(editItem.weekday))
}, [editItem])
useEffect(() => {
if (!createOpen) return
setCreateClassId(defaultClassId)
}, [createOpen, defaultClassId])
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
for (const d of WEEKDAYS) byDay.set(d.key, [])
for (const item of schedule) byDay.get(item.weekday)?.push(item)
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("classId", createClassId || defaultClassId)
formData.set("weekday", String(createWeekday))
const res = await createClassScheduleItemAction(null, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create schedule item")
}
} catch {
toast.error("Failed to create schedule item")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
formData.set("classId", editClassId)
formData.set("weekday", editWeekday)
const res = await updateClassScheduleItemAction(editItem.id, null, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update schedule item")
}
} catch {
toast.error("Failed to update schedule item")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteClassScheduleItemAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete schedule item")
}
} catch {
toast.error("Failed to delete schedule item")
} finally {
setIsWorking(false)
}
}
return (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{WEEKDAYS.map((d) => {
const items = byDay.get(d.key) ?? []
return (
<Card key={d.key} className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">{d.label}</CardTitle>
<Badge variant="secondary" className={cn(items.length === 0 && "opacity-60")}>
{items.length} items
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={classes.length === 0}
onClick={() => {
setCreateWeekday(d.key)
setCreateOpen(true)
}}
>
<Plus className="size-4" />
</Button>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<div className="text-muted-foreground text-sm">No classes scheduled.</div>
) : (
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="space-y-1 border-b pb-4 last:border-0 last:pb-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="font-medium leading-none">{item.course}</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{classNameById.get(item.classId) ?? "Class"}</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditItem(item)}>
<Pencil className="mr-2 size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(item)}
>
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="text-muted-foreground flex items-center gap-3 text-sm">
<span className="inline-flex items-center gap-1 tabular-nums">
<Clock className="h-3.5 w-3.5" />
{item.startTime}{item.endTime}
</span>
{item.location ? (
<span className="inline-flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{item.location}
</span>
) : null}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
})}
<Dialog
open={createOpen}
onOpenChange={(v) => {
if (isWorking) return
setCreateOpen(v)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Add schedule item</DialogTitle>
<DialogDescription>Create a class schedule entry.</DialogDescription>
</DialogHeader>
<form action={handleCreate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={createClassId} onValueChange={setCreateClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="classId" value={createClassId} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Weekday</Label>
<Input value={WEEKDAYS.find((w) => w.key === createWeekday)?.label ?? ""} readOnly className="col-span-3" />
<input type="hidden" name="weekday" value={String(createWeekday)} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-startTime" className="text-right">
Start
</Label>
<Input id="create-startTime" name="startTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-endTime" className="text-right">
End
</Label>
<Input id="create-endTime" name="endTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-course" className="text-right">
Course
</Label>
<Input id="create-course" name="course" className="col-span-3" placeholder="e.g. Math" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-location" className="text-right">
Location
</Label>
<Input id="create-location" name="location" className="col-span-3" placeholder="Optional" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !createClassId}>
{isWorking ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(editItem)}
onOpenChange={(v) => {
if (isWorking) return
if (!v) setEditItem(null)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Edit schedule item</DialogTitle>
<DialogDescription>Update this schedule entry.</DialogDescription>
</DialogHeader>
{editItem ? (
<form action={handleUpdate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={editClassId} onValueChange={setEditClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="classId" value={editClassId} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Weekday</Label>
<div className="col-span-3">
<Select value={editWeekday} onValueChange={setEditWeekday}>
<SelectTrigger>
<SelectValue placeholder="Select weekday" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Mon</SelectItem>
<SelectItem value="2">Tue</SelectItem>
<SelectItem value="3">Wed</SelectItem>
<SelectItem value="4">Thu</SelectItem>
<SelectItem value="5">Fri</SelectItem>
<SelectItem value="6">Sat</SelectItem>
<SelectItem value="7">Sun</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="weekday" value={editWeekday} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-startTime" className="text-right">
Start
</Label>
<Input
id="edit-startTime"
name="startTime"
type="time"
className="col-span-3"
defaultValue={editItem.startTime}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-endTime" className="text-right">
End
</Label>
<Input
id="edit-endTime"
name="endTime"
type="time"
className="col-span-3"
defaultValue={editItem.endTime}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-course" className="text-right">
Course
</Label>
<Input
id="edit-course"
name="course"
className="col-span-3"
defaultValue={editItem.course}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-location" className="text-right">
Location
</Label>
<Input
id="edit-location"
name="location"
className="col-span-3"
defaultValue={editItem.location ?? ""}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteItem)}
onOpenChange={(v) => {
if (isWorking) return
if (!v) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete schedule item?</AlertDialogTitle>
<AlertDialogDescription>
{deleteItem ? (
<>
This will permanently delete <span className="font-medium text-foreground">{deleteItem.course}</span>{" "}
({deleteItem.startTime}{deleteItem.endTime}).
</>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={isWorking}
>
{isWorking ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,169 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Search, UserPlus, X } from "lucide-react"
import { toast } from "sonner"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Label } from "@/shared/components/ui/label"
import type { TeacherClass } from "../types"
import { enrollStudentByEmailAction } from "../actions"
export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
const router = useRouter()
const [open, setOpen] = useState(false)
const [isWorking, setIsWorking] = useState(false)
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
const [enrollClassId, setEnrollClassId] = useState(defaultClassId)
useEffect(() => {
if (!open) return
setEnrollClassId(defaultClassId)
}, [open, defaultClassId])
const handleEnroll = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await enrollStudentByEmailAction(enrollClassId, null, formData)
if (res.success) {
toast.success(res.message)
setOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to add student")
}
} catch {
toast.error("Failed to add student")
} finally {
setIsWorking(false)
}
}
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 md:max-w-sm">
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
<Input
placeholder="Search students..."
className="pl-8"
value={search}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Class" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Classes</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
{(search || classId !== "all") && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setClassId(null)
}}
className="h-8 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
<Dialog
open={open}
onOpenChange={(v) => {
if (isWorking) return
setOpen(v)
}}
>
<DialogTrigger asChild>
<Button className="gap-2" disabled={classes.length === 0}>
<UserPlus className="size-4" />
Add student
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Add student</DialogTitle>
<DialogDescription>Enroll a student by email to a class.</DialogDescription>
</DialogHeader>
<form action={handleEnroll}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={enrollClassId} onValueChange={setEnrollClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="student-email" className="text-right">
Email
</Label>
<Input
id="student-email"
name="email"
type="email"
className="col-span-3"
placeholder="student@example.com"
required
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !enrollClassId}>
{isWorking ? "Adding..." : "Add"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,158 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import type { ClassStudent } from "../types"
import { setStudentEnrollmentStatusAction } from "../actions"
export function StudentsTable({ students }: { students: ClassStudent[] }) {
const router = useRouter()
const [workingKey, setWorkingKey] = useState<string | null>(null)
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
const key = `${student.classId}:${student.id}:${status}`
setWorkingKey(key)
try {
const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status)
if (res.success) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message || "Failed to update student")
}
} catch {
toast.error("Failed to update student")
} finally {
setWorkingKey(null)
}
}
return (
<>
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Email</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.map((s) => (
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-12", s.status !== "active" && "opacity-70")}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-muted-foreground">{s.email}</TableCell>
<TableCell>{s.className}</TableCell>
<TableCell>
<Badge variant={s.status === "active" ? "secondary" : "outline"}>
{s.status === "active" ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={workingKey !== null}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{s.status !== "active" ? (
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
<UserCheck className="mr-2 size-4" />
Set active
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
<UserX className="mr-2 size-4" />
Set inactive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setRemoveTarget(s)}
className="text-destructive focus:text-destructive"
disabled={s.status === "inactive" || workingKey !== null}
>
<UserX className="mr-2 size-4" />
Remove from class
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<AlertDialog
open={Boolean(removeTarget)}
onOpenChange={(open) => {
if (workingKey !== null) return
if (!open) setRemoveTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove student from class?</AlertDialogTitle>
<AlertDialogDescription>
{removeTarget ? (
<>
This will set <span className="font-medium text-foreground">{removeTarget.name}</span> to inactive in{" "}
<span className="font-medium text-foreground">{removeTarget.className}</span>.
</>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={workingKey !== null}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={workingKey !== null}
onClick={() => {
if (!removeTarget) return
setRemoveTarget(null)
setStatus(removeTarget, "inactive")
}}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
export type TeacherClass = {
id: string
schoolName?: string | null
name: string
grade: string
homeroom?: string | null
room?: string | null
invitationCode?: string | null
studentCount: number
}
export type TeacherOption = {
id: string
name: string
email: string
}
export const DEFAULT_CLASS_SUBJECTS = ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"] as const
export type ClassSubject = (typeof DEFAULT_CLASS_SUBJECTS)[number]
export type ClassSubjectTeacherAssignment = {
subject: ClassSubject
teacher: TeacherOption | null
}
export type AdminClassListItem = {
id: string
schoolName?: string | null
schoolId?: string | null
name: string
grade: string
gradeId?: string | null
homeroom?: string | null
room?: string | null
invitationCode?: string | null
teacher: TeacherOption
subjectTeachers: ClassSubjectTeacherAssignment[]
studentCount: number
createdAt: string
updatedAt: string
}
export type CreateTeacherClassInput = {
schoolName?: string | null
schoolId?: string | null
name: string
grade: string
gradeId?: string | null
homeroom?: string | null
room?: string | null
}
export type UpdateTeacherClassInput = {
schoolName?: string | null
schoolId?: string | null
name?: string
grade?: string
gradeId?: string | null
homeroom?: string | null
room?: string | null
}
export type ClassStudent = {
id: string
name: string
email: string
classId: string
className: string
status: "active" | "inactive"
}
export type ClassScheduleItem = {
id: string
classId: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime: string
endTime: string
course: string
location?: string | null
}
export type StudentEnrolledClass = {
id: string
schoolName?: string | null
name: string
grade: string
homeroom?: string | null
room?: string | null
}
export type StudentScheduleItem = {
id: string
classId: string
className: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime: string
endTime: string
course: string
location?: string | null
}
export type CreateClassScheduleItemInput = {
classId: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime: string
endTime: string
course: string
location?: string | null
}
export type UpdateClassScheduleItemInput = {
classId?: string
weekday?: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime?: string
endTime?: string
course?: string
location?: string | null
}
export type ClassBasicInfo = {
id: string
name: string
grade: string
homeroom?: string | null
room?: string | null
invitationCode?: string | null
}
export type ScoreStats = {
count: number
avg: number | null
median: number | null
min: number | null
max: number | null
}
export type ClassHomeworkAssignmentStats = {
assignmentId: string
title: string
status: string
createdAt: string
dueAt: string | null
isActive: boolean
isOverdue: boolean
maxScore: number
targetCount: number
submittedCount: number
gradedCount: number
scoreStats: ScoreStats
}
export type ClassHomeworkInsights = {
class: ClassBasicInfo
studentCounts: {
total: number
active: number
inactive: number
}
assignments: ClassHomeworkAssignmentStats[]
latest: ClassHomeworkAssignmentStats | null
overallScores: ScoreStats
}
export type GradeHomeworkClassSummary = {
class: ClassBasicInfo
studentCounts: {
total: number
active: number
inactive: number
}
latestAvg: number | null
prevAvg: number | null
deltaAvg: number | null
overallScores: ScoreStats
}
export type GradeHomeworkInsights = {
grade: {
id: string
name: string
school: { id: string; name: string }
}
classCount: number
studentCounts: {
total: number
active: number
inactive: number
}
assignments: ClassHomeworkAssignmentStats[]
latest: ClassHomeworkAssignmentStats | null
overallScores: ScoreStats
classes: GradeHomeworkClassSummary[]
}

View File

@@ -0,0 +1,158 @@
import type { ReactNode } from "react"
import { Users, LayoutDashboard, BookOpen, FileText, ClipboardList, Library, Activity } from "lucide-react"
import type { AdminDashboardData } from "@/modules/dashboard/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<div className="text-sm text-muted-foreground">System overview across users, learning content, and activity.</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-2">
<Activity className="h-4 w-4" />
{data.activeSessionsCount} active sessions
</Badge>
<Badge variant="outline" className="gap-2">
<Users className="h-4 w-4" />
{data.userCount} users
</Badge>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard title="Users" value={data.userCount} icon={<Users className="h-4 w-4" />} />
<KpiCard title="Classes" value={data.classCount} icon={<LayoutDashboard className="h-4 w-4" />} />
<KpiCard title="Homework (published)" value={data.homeworkAssignmentPublishedCount} icon={<ClipboardList className="h-4 w-4" />} />
<KpiCard title="To grade" value={data.homeworkSubmissionToGradeCount} icon={<FileText className="h-4 w-4" />} />
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>User Roles</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.userRoleCounts.length === 0 ? (
<EmptyState title="No users" description="No user records found." />
) : (
data.userRoleCounts.map((r) => (
<div key={r.role} className="flex items-center justify-between">
<Badge variant="secondary">{r.role}</Badge>
<div className="text-sm font-medium tabular-nums">{r.count}</div>
</div>
))
)}
</CardContent>
</Card>
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>Content</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<ContentRow label="Textbooks" value={data.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label="Chapters" value={data.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label="Questions" value={data.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label="Exams" value={data.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
</CardContent>
</Card>
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>Homework Activity</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<ContentRow label="Assignments" value={data.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label="Submissions" value={data.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label="To grade" value={data.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Recent Users</CardTitle>
</CardHeader>
<CardContent>
{data.recentUsers.length === 0 ? (
<EmptyState title="No users yet" description="Seed the database to see users here." />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.recentUsers.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name || "-"}</TableCell>
<TableCell className="text-muted-foreground">{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">{u.role ?? "unknown"}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(u.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}
function KpiCard({
title,
value,
icon,
}: {
title: string
value: number
icon: ReactNode
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<div className="text-muted-foreground">{icon}</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{value}</div>
</CardContent>
</Card>
)
}
function ContentRow({
label,
value,
icon,
}: {
label: string
value: number
icon: ReactNode
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon}
<div className="text-sm text-muted-foreground">{label}</div>
</div>
<div className="text-sm font-medium tabular-nums">{value}</div>
</div>
)
}

View File

@@ -1,25 +0,0 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
export function AdminDashboard() {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader><CardTitle>System Status</CardTitle></CardHeader>
<CardContent className="text-green-600 font-bold">Operational</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Total Users</CardTitle></CardHeader>
<CardContent>2,450</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Active Sessions</CardTitle></CardHeader>
<CardContent>142</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
import Link from "next/link"
import { Button } from "@/shared/components/ui/button"
export function StudentDashboardHeader({ studentName }: { studentName: string }) {
return (
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<div className="text-sm text-muted-foreground">Welcome back, {studentName}.</div>
</div>
<Button asChild variant="outline">
<Link href="/student/learning/assignments">View assignments</Link>
</Button>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import type { StudentDashboardProps } from "@/modules/dashboard/types"
import { StudentDashboardHeader } from "./student-dashboard-header"
import { StudentGradesCard } from "./student-grades-card"
import { StudentRankingCard } from "./student-ranking-card"
import { StudentStatsGrid } from "./student-stats-grid"
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
export function StudentDashboard({
studentName,
enrolledClassCount,
dueSoonCount,
overdueCount,
gradedCount,
todayScheduleItems,
upcomingAssignments,
grades,
}: StudentDashboardProps) {
return (
<div className="space-y-6">
<StudentDashboardHeader studentName={studentName} />
<StudentStatsGrid
enrolledClassCount={enrolledClassCount}
dueSoonCount={dueSoonCount}
overdueCount={overdueCount}
gradedCount={gradedCount}
/>
<div className="grid gap-4 md:grid-cols-2">
<StudentGradesCard grades={grades} />
<StudentRankingCard ranking={grades.ranking} />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<StudentTodayScheduleCard items={todayScheduleItems} />
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
import Link from "next/link"
import { BarChart3 } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
export function StudentGradesCard({ grades }: { grades: StudentDashboardGradeProps }) {
const hasGradeTrend = grades.trend.length > 0
const hasRecentGrades = grades.recent.length > 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
Recent Grades
</CardTitle>
</CardHeader>
<CardContent>
{!hasGradeTrend ? (
<EmptyState
icon={BarChart3}
title="No graded work yet"
description="Finish and submit assignments to see your score trend."
className="border-none h-72"
/>
) : (
<div className="space-y-4">
<div className="rounded-md border bg-card p-4">
<svg viewBox="0 0 100 40" className="h-24 w-full">
<polyline
fill="none"
stroke="currentColor"
strokeWidth="2"
points={grades.trend
.map((p, i) => {
const t = grades.trend.length > 1 ? i / (grades.trend.length - 1) : 0
const x = t * 100
const v = Number.isFinite(p.percentage) ? Math.max(0, Math.min(100, p.percentage)) : 0
const y = 40 - (v / 100) * 40
return `${x},${y}`
})
.join(" ")}
className="text-primary"
/>
</svg>
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
<div>
Latest:{" "}
<span className="font-medium text-foreground tabular-nums">
{Math.round(grades.trend[grades.trend.length - 1]?.percentage ?? 0)}%
</span>
</div>
<div>
Points:{" "}
<span className="font-medium text-foreground tabular-nums">
{grades.trend[grades.trend.length - 1]?.score ?? 0}/{grades.trend[grades.trend.length - 1]?.maxScore ?? 0}
</span>
</div>
</div>
</div>
{!hasRecentGrades ? null : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Assignment</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">When</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{grades.recent.map((r) => (
<TableRow key={r.assignmentId} className="h-12">
<TableCell className="font-medium">
<Link href={`/student/learning/assignments/${r.assignmentId}`} className="hover:underline">
{r.assignmentTitle}
</Link>
</TableCell>
<TableCell className="tabular-nums">
{r.score}/{r.maxScore} <span className="text-muted-foreground">({Math.round(r.percentage)}%)</span>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.submittedAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,47 @@
import { Trophy } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { StudentRanking } from "@/modules/homework/types"
export function StudentRankingCard({ ranking }: { ranking: StudentRanking | null }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-muted-foreground" />
Ranking
</CardTitle>
</CardHeader>
<CardContent>
{!ranking ? (
<EmptyState
icon={Trophy}
title="No ranking available"
description="Join a class and complete graded work to see your rank."
className="border-none h-72"
/>
) : (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-md border bg-card p-4">
<div className="text-sm text-muted-foreground">Class Rank</div>
<div className="mt-1 text-3xl font-bold tabular-nums">
{ranking.rank}/{ranking.classSize}
</div>
</div>
<div className="rounded-md border bg-card p-4">
<div className="text-sm text-muted-foreground">Overall</div>
<div className="mt-1 text-3xl font-bold tabular-nums">{Math.round(ranking.percentage)}%</div>
<div className="text-xs text-muted-foreground tabular-nums">
{ranking.totalScore}/{ranking.totalMaxScore} pts
</div>
</div>
</div>
<div className="text-sm text-muted-foreground">Based on latest graded submissions per assignment for your class.</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,66 @@
import { BookOpen, CheckCircle2, PenTool, TriangleAlert } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
type Stat = {
title: string
value: string
description: string
icon: typeof BookOpen
}
export function StudentStatsGrid({
enrolledClassCount,
dueSoonCount,
overdueCount,
gradedCount,
}: {
enrolledClassCount: number
dueSoonCount: number
overdueCount: number
gradedCount: number
}) {
const stats: readonly Stat[] = [
{
title: "My Classes",
value: String(enrolledClassCount),
description: "Enrolled classes",
icon: BookOpen,
},
{
title: "Due Soon",
value: String(dueSoonCount),
description: "Next 7 days",
icon: PenTool,
},
{
title: "Overdue",
value: String(overdueCount),
description: "Needs attention",
icon: TriangleAlert,
},
{
title: "Graded",
value: String(gradedCount),
description: "With score",
icon: CheckCircle2,
},
]
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{stat.value}</div>
<div className="text-xs text-muted-foreground">{stat.description}</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { CalendarDays, CalendarX, Clock, MapPin } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { StudentTodayScheduleItem } from "@/modules/dashboard/types"
export function StudentTodayScheduleCard({ items }: { items: StudentTodayScheduleItem[] }) {
const hasSchedule = items.length > 0
return (
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-muted-foreground" />
Today&apos;s Schedule
</CardTitle>
</CardHeader>
<CardContent>
{!hasSchedule ? (
<EmptyState
icon={CalendarX}
title="No classes today"
description="Your timetable is clear for today."
className="border-none h-72"
/>
) : (
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0">
<div className="space-y-1 min-w-0">
<div className="font-medium leading-none truncate">{item.course}</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<div className="flex items-center">
<Clock className="mr-1 h-3 w-3" />
<span>
{item.startTime}{item.endTime}
</span>
</div>
{item.location ? (
<div className="flex items-center">
<MapPin className="mr-1 h-3 w-3" />
<span className="truncate">{item.location}</span>
</div>
) : null}
</div>
</div>
<Badge variant="secondary" className="shrink-0">
{item.className}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,83 @@
import Link from "next/link"
import { PenTool } from "lucide-react"
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"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
if (status === "graded") return "default"
if (status === "submitted") return "secondary"
if (status === "in_progress") return "secondary"
return "outline"
}
const getStatusLabel = (status: string) => {
if (status === "graded") return "Graded"
if (status === "submitted") return "Submitted"
if (status === "in_progress") return "In progress"
return "Not started"
}
export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
const hasAssignments = upcomingAssignments.length > 0
return (
<Card className="lg:col-span-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<PenTool className="h-4 w-4 text-muted-foreground" />
Upcoming Assignments
</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href="/student/learning/assignments">View all</Link>
</Button>
</CardHeader>
<CardContent>
{!hasAssignments ? (
<EmptyState
icon={PenTool}
title="No assignments"
description="You have no assigned homework right now."
className="border-none h-72"
/>
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Title</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Due</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{upcomingAssignments.map((a) => (
<TableRow key={a.id} className="h-12">
<TableCell className="font-medium">
<Link href={`/student/learning/assignments/${a.id}`} className="truncate hover:underline">
{a.title}
</Link>
</TableCell>
<TableCell>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,21 +0,0 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
export function StudentDashboard() {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight">Student Dashboard</h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader><CardTitle>My Courses</CardTitle></CardHeader>
<CardContent>Enrolled in 5 courses</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Assignments</CardTitle></CardHeader>
<CardContent>2 due this week</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,62 +1,22 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar";
import { EmptyState } from "@/shared/components/ui/empty-state";
import { Inbox } from "lucide-react";
import { formatDate } from "@/shared/lib/utils";
import type { HomeworkSubmissionListItem } from "@/modules/homework/types";
interface SubmissionItem {
id: string;
studentName: string;
studentAvatar?: string;
assignment: string;
submittedAt: string;
status: "submitted" | "late";
}
const MOCK_SUBMISSIONS: SubmissionItem[] = [
{
id: "1",
studentName: "Alice Johnson",
assignment: "React Component Composition",
submittedAt: "10 minutes ago",
status: "submitted",
},
{
id: "2",
studentName: "Bob Smith",
assignment: "Design System Analysis",
submittedAt: "1 hour ago",
status: "submitted",
},
{
id: "3",
studentName: "Charlie Brown",
assignment: "React Component Composition",
submittedAt: "2 hours ago",
status: "late",
},
{
id: "4",
studentName: "Diana Prince",
assignment: "CSS Grid Layout",
submittedAt: "Yesterday",
status: "submitted",
},
{
id: "5",
studentName: "Evan Wright",
assignment: "Design System Analysis",
submittedAt: "Yesterday",
status: "submitted",
},
];
export function RecentSubmissions() {
const hasSubmissions = MOCK_SUBMISSIONS.length > 0;
export function RecentSubmissions({ submissions }: { submissions: HomeworkSubmissionListItem[] }) {
const hasSubmissions = submissions.length > 0;
return (
<Card className="col-span-4 lg:col-span-4">
<CardHeader>
<CardTitle>Recent Submissions</CardTitle>
<CardTitle className="flex items-center gap-2">
<Inbox className="h-4 w-4 text-muted-foreground" />
Recent Submissions
</CardTitle>
</CardHeader>
<CardContent>
{!hasSubmissions ? (
@@ -64,15 +24,16 @@ export function RecentSubmissions() {
icon={Inbox}
title="No New Submissions"
description="All caught up! There are no new submissions to review."
action={{ label: "View submissions", href: "/teacher/homework/submissions" }}
className="border-none h-[300px]"
/>
) : (
<div className="space-y-6">
{MOCK_SUBMISSIONS.map((item) => (
{submissions.map((item) => (
<div key={item.id} className="flex items-center justify-between group">
<div className="flex items-center space-x-4">
<Avatar className="h-9 w-9">
<AvatarImage src={item.studentAvatar} alt={item.studentName} />
<AvatarImage src={undefined} alt={item.studentName} />
<AvatarFallback>{item.studentName.charAt(0)}</AvatarFallback>
</Avatar>
<div className="space-y-1">
@@ -80,16 +41,20 @@ export function RecentSubmissions() {
{item.studentName}
</p>
<p className="text-sm text-muted-foreground">
Submitted <span className="font-medium text-foreground">{item.assignment}</span>
<Link
href={`/teacher/homework/submissions/${item.id}`}
className="font-medium text-foreground hover:underline"
>
{item.assignmentTitle}
</Link>
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="text-sm text-muted-foreground">
{/* Using static date for demo to prevent hydration mismatch */}
{item.submittedAt}
{item.submittedAt ? formatDate(item.submittedAt) : "-"}
</div>
{item.status === "late" && (
{item.isLate && (
<span className="inline-flex items-center rounded-full border border-destructive px-2 py-0.5 text-xs font-semibold text-destructive transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
Late
</span>

View File

@@ -0,0 +1,86 @@
import Link from "next/link"
import { Users } from "lucide-react"
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"
import type { TeacherClass } from "@/modules/classes/types"
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
const totalStudents = classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
const topClassesByStudents = [...classes].sort((a, b) => (b.studentCount ?? 0) - (a.studentCount ?? 0)).slice(0, 8)
const maxStudentCount = Math.max(1, ...topClassesByStudents.map((c) => c.studentCount ?? 0))
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
My Classes
</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href="/teacher/classes/my">View all</Link>
</Button>
</CardHeader>
<CardContent className="grid gap-3">
{classes.length === 0 ? (
<EmptyState
icon={Users}
title="No classes yet"
description="Create a class to start managing students and schedules."
action={{ label: "Create class", href: "/teacher/classes/my" }}
className="border-none h-72"
/>
) : (
<>
{topClassesByStudents.length > 0 ? (
<div className="rounded-md border bg-card p-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Students by class</div>
<div className="text-xs text-muted-foreground tabular-nums">Total {totalStudents}</div>
</div>
<div className="mt-3 grid gap-2">
{topClassesByStudents.map((c) => {
const count = c.studentCount ?? 0
const pct = Math.max(0, Math.min(100, (count / maxStudentCount) * 100))
return (
<div key={c.id} className="grid grid-cols-[minmax(0,1fr)_120px_52px] items-center gap-3">
<div className="truncate text-sm">{c.name}</div>
<div className="h-2 rounded-full bg-muted">
<div className="h-2 rounded-full bg-primary" style={{ width: `${pct}%` }} />
</div>
<div className="text-right text-xs tabular-nums text-muted-foreground">{count}</div>
</div>
)
})}
</div>
</div>
) : null}
{classes.slice(0, 6).map((c) => (
<Link
key={c.id}
href={`/teacher/classes/my/${encodeURIComponent(c.id)}`}
className="flex items-center justify-between rounded-md border bg-card px-4 py-3 hover:bg-muted/50"
>
<div className="min-w-0">
<div className="font-medium truncate">{c.name}</div>
<div className="text-sm text-muted-foreground">
{c.grade}
{c.homeroom ? ` · ${c.homeroom}` : ""}
{c.room ? ` · ${c.room}` : ""}
</div>
</div>
<Badge variant="outline" className="flex items-center gap-1">
<Users className="h-3 w-3" />
{c.studentCount} students
</Badge>
</Link>
))}
</>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,13 @@
import { TeacherQuickActions } from "./teacher-quick-actions"
export function TeacherDashboardHeader() {
return (
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Teacher</h2>
<p className="text-muted-foreground">Overview of today&apos;s work and your classes.</p>
</div>
<TeacherQuickActions />
</div>
)
}

View File

@@ -0,0 +1,59 @@
import type { TeacherDashboardData, TeacherTodayScheduleItem } from "@/modules/dashboard/types"
import { TeacherClassesCard } from "./teacher-classes-card"
import { TeacherDashboardHeader } from "./teacher-dashboard-header"
import { TeacherHomeworkCard } from "./teacher-homework-card"
import { RecentSubmissions } from "./recent-submissions"
import { TeacherSchedule } from "./teacher-schedule"
import { TeacherStats } from "./teacher-stats"
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
const day = d.getDay()
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
}
export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
const totalStudents = data.classes.reduce((sum, c) => sum + (c.studentCount ?? 0), 0)
const todayWeekday = toWeekday(new Date())
const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const))
const todayScheduleItems: TeacherTodayScheduleItem[] = data.schedule
.filter((s) => s.weekday === todayWeekday)
.sort((a, b) => a.startTime.localeCompare(b.startTime))
.map((s): TeacherTodayScheduleItem => ({
id: s.id,
classId: s.classId,
className: classNameById.get(s.classId) ?? "Class",
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt))
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
const recentSubmissions = submittedSubmissions.slice(0, 6)
return (
<div className="flex h-full flex-col space-y-8 p-8">
<TeacherDashboardHeader />
<TeacherStats
totalStudents={totalStudents}
classCount={data.classes.length}
toGradeCount={toGradeCount}
todayScheduleCount={todayScheduleItems.length}
/>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<TeacherSchedule items={todayScheduleItems} />
<RecentSubmissions submissions={recentSubmissions} />
</div>
<div className="grid gap-6 lg:grid-cols-2">
<TeacherClassesCard classes={data.classes} />
<TeacherHomeworkCard assignments={data.assignments} />
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import Link from "next/link"
import { PenTool } from "lucide-react"
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"
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<PenTool className="h-4 w-4 text-muted-foreground" />
Homework
</CardTitle>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/teacher/homework/assignments">Open list</Link>
</Button>
<Button asChild size="sm">
<Link href="/teacher/homework/assignments/create">New</Link>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{assignments.length === 0 ? (
<EmptyState
icon={PenTool}
title="No homework assignments yet"
description="Create an assignment from an exam and publish it to students."
action={{ label: "Create assignment", href: "/teacher/homework/assignments/create" }}
className="border-none h-72"
/>
) : (
assignments.slice(0, 6).map((a) => (
<Link
key={a.id}
href={`/teacher/homework/assignments/${encodeURIComponent(a.id)}`}
className="flex items-center justify-between rounded-md border bg-card px-4 py-3 hover:bg-muted/50"
>
<div className="min-w-0">
<div className="font-medium truncate">{a.title}</div>
<div className="text-sm text-muted-foreground truncate">{a.sourceExamTitle}</div>
</div>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</Link>
))
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,29 @@
import Link from "next/link";
import { Button } from "@/shared/components/ui/button";
import { PlusCircle, CheckSquare, Users } from "lucide-react";
export function TeacherQuickActions() {
return (
<div className="flex items-center space-x-2">
<Button asChild size="sm">
<Link href="/teacher/homework/assignments/create">
<PlusCircle className="mr-2 h-4 w-4" />
Create Assignment
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/teacher/homework/submissions">
<CheckSquare className="mr-2 h-4 w-4" />
Grade
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/teacher/classes/my">
<Users className="mr-2 h-4 w-4" />
My Classes
</Link>
</Button>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import { Clock, MapPin, CalendarDays, CalendarX } from "lucide-react";
import { EmptyState } from "@/shared/components/ui/empty-state";
type TeacherTodayScheduleItem = {
id: string;
classId: string;
className: string;
course: string;
startTime: string;
endTime: string;
location: string | null;
};
export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
const hasSchedule = items.length > 0;
return (
<Card className="col-span-3">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-muted-foreground" />
Today&apos;s Schedule
</CardTitle>
</CardHeader>
<CardContent>
{!hasSchedule ? (
<EmptyState
icon={CalendarX}
title="No Classes Today"
description="No timetable entries for today."
action={{ label: "View schedule", href: "/teacher/classes/schedule" }}
className="border-none h-[300px]"
/>
) : (
<div className="space-y-4">
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
>
<div className="space-y-1">
<Link
href={`/teacher/classes/schedule?classId=${encodeURIComponent(item.classId)}`}
className="font-medium leading-none hover:underline"
>
{item.course}
</Link>
<div className="flex items-center text-sm text-muted-foreground">
<Clock className="mr-1 h-3 w-3" />
<span className="mr-3">{item.startTime}{item.endTime}</span>
{item.location ? (
<>
<MapPin className="mr-1 h-3 w-3" />
<span>{item.location}</span>
</>
) : null}
</div>
</div>
<Badge variant="secondary">
{item.className}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -2,45 +2,21 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
import { Users, BookOpen, FileCheck, Calendar } from "lucide-react";
import { Skeleton } from "@/shared/components/ui/skeleton";
interface StatItem {
title: string;
value: string;
description: string;
icon: React.ElementType;
}
const MOCK_STATS: StatItem[] = [
{
title: "Total Students",
value: "1,248",
description: "+12% from last semester",
icon: Users,
},
{
title: "Active Courses",
value: "4",
description: "2 lectures, 2 workshops",
icon: BookOpen,
},
{
title: "To Grade",
value: "28",
description: "5 submissions pending review",
icon: FileCheck,
},
{
title: "Upcoming Classes",
value: "3",
description: "Today's schedule",
icon: Calendar,
},
];
interface TeacherStatsProps {
totalStudents: number;
classCount: number;
toGradeCount: number;
todayScheduleCount: number;
isLoading?: boolean;
}
export function TeacherStats({ isLoading = false }: TeacherStatsProps) {
export function TeacherStats({
totalStudents,
classCount,
toGradeCount,
todayScheduleCount,
isLoading = false,
}: TeacherStatsProps) {
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@@ -60,9 +36,36 @@ export function TeacherStats({ isLoading = false }: TeacherStatsProps) {
);
}
const stats = [
{
title: "Total Students",
value: String(totalStudents),
description: "Across all your classes",
icon: Users,
},
{
title: "My Classes",
value: String(classCount),
description: "Active classes you manage",
icon: BookOpen,
},
{
title: "To Grade",
value: String(toGradeCount),
description: "Submitted homework waiting for grading",
icon: FileCheck,
},
{
title: "Today",
value: String(todayScheduleCount),
description: "Scheduled items today",
icon: Calendar,
},
] as const;
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{MOCK_STATS.map((stat, i) => (
{stats.map((stat, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">

View File

@@ -1,21 +0,0 @@
import { Button } from "@/shared/components/ui/button";
import { PlusCircle, CheckSquare, MessageSquare } from "lucide-react";
export function TeacherQuickActions() {
return (
<div className="flex items-center space-x-2">
<Button size="sm">
<PlusCircle className="mr-2 h-4 w-4" />
Create Assignment
</Button>
<Button variant="outline" size="sm">
<CheckSquare className="mr-2 h-4 w-4" />
Grade All
</Button>
<Button variant="outline" size="sm">
<MessageSquare className="mr-2 h-4 w-4" />
Message Class
</Button>
</div>
);
}

View File

@@ -1,81 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import { Clock, MapPin, CalendarX } from "lucide-react";
import { EmptyState } from "@/shared/components/ui/empty-state";
interface ScheduleItem {
id: string;
course: string;
time: string;
location: string;
type: "Lecture" | "Workshop" | "Lab";
}
// MOCK_SCHEDULE can be empty to test empty state
const MOCK_SCHEDULE: ScheduleItem[] = [
{
id: "1",
course: "Advanced Web Development",
time: "09:00 AM - 10:30 AM",
location: "Room 304",
type: "Lecture",
},
{
id: "2",
course: "UI/UX Design Principles",
time: "11:00 AM - 12:30 PM",
location: "Design Studio A",
type: "Workshop",
},
{
id: "3",
course: "Frontend Frameworks",
time: "02:00 PM - 03:30 PM",
location: "Online (Zoom)",
type: "Lecture",
},
];
export function TeacherSchedule() {
const hasSchedule = MOCK_SCHEDULE.length > 0;
return (
<Card className="col-span-3">
<CardHeader>
<CardTitle>Today&apos;s Schedule</CardTitle>
</CardHeader>
<CardContent>
{!hasSchedule ? (
<EmptyState
icon={CalendarX}
title="No Classes Today"
description="You have no classes scheduled for today. Enjoy your free time!"
className="border-none h-[300px]"
/>
) : (
<div className="space-y-4">
{MOCK_SCHEDULE.map((item) => (
<div
key={item.id}
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
>
<div className="space-y-1">
<p className="font-medium leading-none">{item.course}</p>
<div className="flex items-center text-sm text-muted-foreground">
<Clock className="mr-1 h-3 w-3" />
<span className="mr-3">{item.time}</span>
<MapPin className="mr-1 h-3 w-3" />
<span>{item.location}</span>
</div>
</div>
<Badge variant={item.type === "Lecture" ? "default" : "secondary"}>
{item.type}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,25 +0,0 @@
"use client"
import { TeacherQuickActions } from "@/modules/dashboard/components/teacher-quick-actions";
import { TeacherStats } from "@/modules/dashboard/components/teacher-stats";
import { TeacherSchedule } from "@/modules/dashboard/components/teacher-schedule";
import { RecentSubmissions } from "@/modules/dashboard/components/recent-submissions";
// This component is now exclusively for the Teacher Role View
export function TeacherDashboard() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Teacher Dashboard</h1>
<TeacherQuickActions />
</div>
<TeacherStats />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<TeacherSchedule />
<RecentSubmissions />
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
import "server-only"
import { cache } from "react"
import { count, desc, eq, gt } from "drizzle-orm"
import { db } from "@/shared/db"
import {
chapters,
classes,
exams,
homeworkAssignments,
homeworkSubmissions,
questions,
sessions,
textbooks,
users,
} from "@/shared/db/schema"
import type { AdminDashboardData } from "./types"
export const getAdminDashboardData = cache(async (): Promise<AdminDashboardData> => {
const now = new Date()
const [
activeSessionsRow,
userCountRow,
userRoleRows,
classCountRow,
textbookCountRow,
chapterCountRow,
questionCountRow,
examCountRow,
homeworkAssignmentCountRow,
homeworkAssignmentPublishedCountRow,
homeworkSubmissionCountRow,
homeworkSubmissionToGradeCountRow,
recentUserRows,
] = await Promise.all([
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
db.select({ value: count() }).from(users),
db.select({ role: users.role, value: count() }).from(users).groupBy(users.role),
db.select({ value: count() }).from(classes),
db.select({ value: count() }).from(textbooks),
db.select({ value: count() }).from(chapters),
db.select({ value: count() }).from(questions),
db.select({ value: count() }).from(exams),
db.select({ value: count() }).from(homeworkAssignments),
db.select({ value: count() }).from(homeworkAssignments).where(eq(homeworkAssignments.status, "published")),
db.select({ value: count() }).from(homeworkSubmissions),
db.select({ value: count() }).from(homeworkSubmissions).where(eq(homeworkSubmissions.status, "submitted")),
db
.select({
id: users.id,
name: users.name,
email: users.email,
role: users.role,
createdAt: users.createdAt,
})
.from(users)
.orderBy(desc(users.createdAt))
.limit(8),
])
const activeSessionsCount = Number(activeSessionsRow[0]?.value ?? 0)
const userCount = Number(userCountRow[0]?.value ?? 0)
const classCount = Number(classCountRow[0]?.value ?? 0)
const textbookCount = Number(textbookCountRow[0]?.value ?? 0)
const chapterCount = Number(chapterCountRow[0]?.value ?? 0)
const questionCount = Number(questionCountRow[0]?.value ?? 0)
const examCount = Number(examCountRow[0]?.value ?? 0)
const homeworkAssignmentCount = Number(homeworkAssignmentCountRow[0]?.value ?? 0)
const homeworkAssignmentPublishedCount = Number(homeworkAssignmentPublishedCountRow[0]?.value ?? 0)
const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0)
const homeworkSubmissionToGradeCount = Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0)
const userRoleCounts = userRoleRows
.map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) }))
.sort((a, b) => b.count - a.count)
const recentUsers = recentUserRows.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role,
createdAt: u.createdAt.toISOString(),
}))
return {
activeSessionsCount,
userCount,
userRoleCounts,
classCount,
textbookCount,
chapterCount,
questionCount,
examCount,
homeworkAssignmentCount,
homeworkAssignmentPublishedCount,
homeworkSubmissionCount,
homeworkSubmissionToGradeCount,
recentUsers,
}
})

View File

@@ -0,0 +1,70 @@
import type { StudentDashboardGradeProps, StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
import type { TeacherClass, ClassScheduleItem } from "@/modules/classes/types"
import type { HomeworkAssignmentListItem, HomeworkSubmissionListItem } from "@/modules/homework/types"
export type AdminDashboardUserRoleCount = {
role: string
count: number
}
export type AdminDashboardRecentUser = {
id: string
name: string | null
email: string
role: string | null
createdAt: string
}
export type AdminDashboardData = {
activeSessionsCount: number
userCount: number
userRoleCounts: AdminDashboardUserRoleCount[]
classCount: number
textbookCount: number
chapterCount: number
questionCount: number
examCount: number
homeworkAssignmentCount: number
homeworkAssignmentPublishedCount: number
homeworkSubmissionCount: number
homeworkSubmissionToGradeCount: number
recentUsers: AdminDashboardRecentUser[]
}
export type StudentTodayScheduleItem = {
id: string
classId: string
className: string
course: string
startTime: string
endTime: string
location: string | null
}
export type StudentDashboardProps = {
studentName: string
enrolledClassCount: number
dueSoonCount: number
overdueCount: number
gradedCount: number
todayScheduleItems: StudentTodayScheduleItem[]
upcomingAssignments: StudentHomeworkAssignmentListItem[]
grades: StudentDashboardGradeProps
}
export type TeacherTodayScheduleItem = {
id: string
classId: string
className: string
course: string
startTime: string
endTime: string
location: string | null
}
export type TeacherDashboardData = {
classes: TeacherClass[]
schedule: ClassScheduleItem[]
assignments: HomeworkAssignmentListItem[]
submissions: HomeworkSubmissionListItem[]
}

View File

@@ -75,8 +75,7 @@ export async function createExamAction(
startTime: scheduled ? new Date(scheduled) : null,
status: "draft",
})
} catch (error) {
console.error("Failed to create exam:", error)
} catch {
return {
success: false,
message: "Database error: Failed to create exam",
@@ -156,8 +155,7 @@ export async function updateExamAction(
await db.update(exams).set(updateData).where(eq(exams.id, examId))
}
} catch (error) {
console.error("Failed to update exam:", error)
} catch {
return {
success: false,
message: "Database error: Failed to update exam",
@@ -197,8 +195,7 @@ export async function deleteExamAction(
try {
await db.delete(exams).where(eq(exams.id, examId))
} catch (error) {
console.error("Failed to delete exam:", error)
} catch {
return {
success: false,
message: "Database error: Failed to delete exam",
@@ -292,8 +289,7 @@ export async function duplicateExamAction(
)
}
})
} catch (error) {
console.error("Failed to duplicate exam:", error)
} catch {
return {
success: false,
message: "Database error: Failed to duplicate exam",

View File

@@ -0,0 +1,184 @@
"use client"
import { useMemo, type ReactNode } from "react"
import { cn } from "@/shared/lib/utils"
type ChoiceOption = {
id: string
text: string
}
type QuestionLike = {
questionId: string
questionType: string
questionContent: unknown
maxScore: number
}
type ExamViewerProps = {
structure: unknown
questions: QuestionLike[]
className?: string
selectedQuestionId?: string | null
onQuestionSelect?: (questionId: string) => void
}
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return ""
return typeof content.text === "string" ? content.text : ""
}
const getOptions = (content: unknown): ChoiceOption[] => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: ChoiceOption[] = []
for (const item of raw) {
if (!isRecord(item)) continue
const id = typeof item.id === "string" ? item.id : ""
const text = typeof item.text === "string" ? item.text : ""
if (!id || !text) continue
out.push({ id, text })
}
return out
}
export function ExamViewer(props: ExamViewerProps) {
const { structure, questions, className } = props
const questionById = useMemo(() => new Map(questions.map((q) => [q.questionId, q] as const)), [questions])
const questionNumberById = useMemo(() => {
const ids: string[] = []
const visit = (nodes: unknown) => {
if (!Array.isArray(nodes)) return
for (const node of nodes) {
if (!isRecord(node)) continue
if (node.type === "question") {
const questionId = typeof node.questionId === "string" ? node.questionId : ""
if (questionId) ids.push(questionId)
continue
}
if (node.type === "group") {
visit(Array.isArray(node.children) ? node.children : [])
}
}
}
if (Array.isArray(structure) && structure.length > 0) {
visit(structure)
} else {
for (const q of questions) ids.push(q.questionId)
}
const out = new Map<string, number>()
let n = 0
for (const id of ids) {
if (out.has(id)) continue
n += 1
out.set(id, n)
}
return out
}, [structure, questions])
const renderNodes = (rawNodes: unknown, depth: number): ReactNode => {
if (!Array.isArray(rawNodes)) return null
return (
<div className={depth > 0 ? "space-y-4 pl-4 border-l" : "space-y-6"}>
{rawNodes.map((node, idx) => {
if (!isRecord(node)) return null
const type = node.type
if (type === "group") {
const title = typeof node.title === "string" && node.title.trim().length > 0 ? node.title : "Section"
const children = Array.isArray(node.children) ? node.children : []
return (
<div key={`g-${depth}-${idx}`} className="space-y-3">
<div className={depth === 0 ? "text-base font-semibold" : "text-sm font-semibold text-muted-foreground"}>
{title}
</div>
{renderNodes(children, depth + 1)}
</div>
)
}
if (type === "question") {
const questionId = typeof node.questionId === "string" ? node.questionId : ""
if (!questionId) return null
const questionNumber = questionNumberById.get(questionId) ?? 0
const q = questionById.get(questionId) ?? null
const text = getQuestionText(q?.questionContent ?? null)
const options = getOptions(q?.questionContent ?? null)
const scoreFromStructure = typeof node.score === "number" ? node.score : null
const maxScore = scoreFromStructure ?? q?.maxScore ?? 0
const isSelected = props.selectedQuestionId === questionId
const isClickable = typeof props.onQuestionSelect === "function"
return (
<div
key={`q-${questionId}`}
className={cn(
"rounded-md border bg-card p-4",
isClickable && "cursor-pointer hover:bg-muted/30 transition-colors",
isSelected && "ring-2 ring-primary/30"
)}
role={isClickable ? "button" : undefined}
tabIndex={isClickable ? 0 : undefined}
onClick={isClickable ? () => props.onQuestionSelect?.(questionId) : undefined}
onKeyDown={
isClickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
props.onQuestionSelect?.(questionId)
}
}
: undefined
}
>
<div className="flex gap-3 min-w-0">
<div className="font-semibold tabular-nums">{questionNumber > 0 ? `${questionNumber}.` : "—"}</div>
<div className="min-w-0">
<div className="whitespace-pre-wrap text-sm">{text || "—"}</div>
<div className="mt-2 text-xs text-muted-foreground">
<span className="capitalize">{q?.questionType ?? "unknown"}</span>
<span className="mx-2"></span>
<span>Score: {maxScore}</span>
</div>
</div>
</div>
{(q?.questionType === "single_choice" || q?.questionType === "multiple_choice") && options.length > 0 ? (
<div className="mt-4 grid grid-cols-1 gap-2 md:grid-cols-2">
{options.map((opt) => (
<div key={opt.id} className="flex gap-2 text-sm text-muted-foreground">
<span className="font-medium">{opt.id}.</span>
<span className="whitespace-pre-wrap">{opt.text}</span>
</div>
))}
</div>
) : null}
</div>
)
}
return null
})}
</div>
)
}
if (Array.isArray(structure) && structure.length > 0) {
return <div className={className}>{renderNodes(structure, 0)}</div>
}
if (questions.length > 0) {
const flatNodes = questions.map((q) => ({ type: "question", questionId: q.questionId, score: q.maxScore }))
return <div className={className}>{renderNodes(flatNodes, 0)}</div>
}
return <div className={className ?? "text-sm text-muted-foreground"}>No questions available.</div>
}

View File

@@ -7,6 +7,8 @@ import { and, count, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
exams,
homeworkAnswers,
homeworkAssignmentQuestions,
@@ -78,6 +80,7 @@ export async function createHomeworkAssignmentAction(
const parsed = CreateHomeworkAssignmentSchema.safeParse({
sourceExamId: formData.get("sourceExamId"),
classId: formData.get("classId"),
title: formData.get("title") || undefined,
description: formData.get("description") || undefined,
availableAt: formData.get("availableAt") || undefined,
@@ -105,6 +108,13 @@ export async function createHomeworkAssignmentAction(
const input = parsed.data
const publish = input.publish ?? true
const [ownedClass] = await db
.select({ id: classes.id })
.from(classes)
.where(user.role === "admin" ? eq(classes.id, input.classId) : and(eq(classes.id, input.classId), eq(classes.teacherId, user.id)))
.limit(1)
if (!ownedClass) return { success: false, message: "Class not found" }
const exam = await db.query.exams.findFirst({
where: eq(exams.id, input.sourceExamId),
with: {
@@ -122,15 +132,30 @@ export async function createHomeworkAssignmentAction(
const dueAt = input.dueAt ? new Date(input.dueAt) : null
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
const classStudentIds = (
await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where(
and(
eq(classEnrollments.classId, input.classId),
eq(classEnrollments.status, "active"),
user.role === "admin" ? eq(classes.id, input.classId) : eq(classes.teacherId, user.id)
)
)
).map((r) => r.studentId)
const classStudentIdSet = new Set(classStudentIds)
const targetStudentIds =
input.targetStudentIds && input.targetStudentIds.length > 0
? input.targetStudentIds
: (
await db
.select({ id: users.id })
.from(users)
.where(eq(users.role, "student"))
).map((r) => r.id)
? input.targetStudentIds.filter((id) => classStudentIdSet.has(id))
: classStudentIds
if (publish && targetStudentIds.length === 0) {
return { success: false, message: "No active students in this class" }
}
await db.transaction(async (tx) => {
await tx.insert(homeworkAssignments).values({

View File

@@ -0,0 +1,29 @@
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { HomeworkAssignmentExamErrorExplorerLazy } from "@/modules/homework/components/homework-assignment-exam-error-explorer-lazy"
export function HomeworkAssignmentExamContentCard({
structure,
questions,
gradedSampleCount,
}: {
structure: unknown
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
</CardHeader>
<CardContent>
<HomeworkAssignmentExamErrorExplorerLazy
structure={structure}
questions={questions}
gradedSampleCount={gradedSampleCount}
/>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,79 @@
"use client"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import dynamic from "next/dynamic"
import { Skeleton } from "@/shared/components/ui/skeleton"
function ExamErrorExplorerFallback() {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 h-[560px]">
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3 text-sm font-medium"></div>
<div className="flex-1 p-4 space-y-3">
<Skeleton className="h-10 w-[40%]" />
<Skeleton className="h-10 w-[60%]" />
<Skeleton className="h-10 w-[75%]" />
<Skeleton className="h-10 w-[55%]" />
<Skeleton className="h-10 w-[68%]" />
</div>
</div>
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3">
<div className="text-sm font-medium"></div>
<div className="mt-2 flex items-center gap-3">
<Skeleton className="size-12 rounded-full" />
<div className="min-w-0 flex-1 grid gap-1">
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-12" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
</div>
</div>
</div>
<div className="flex-1 p-4 space-y-3">
<Skeleton className="h-4 w-[45%]" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</div>
</div>
)
}
const LazyHomeworkAssignmentExamErrorExplorer = dynamic(
() =>
import("./homework-assignment-exam-error-explorer").then((m) => ({
default: m.HomeworkAssignmentExamErrorExplorer,
})),
{ ssr: false, loading: ExamErrorExplorerFallback }
)
export function HomeworkAssignmentExamErrorExplorerLazy({
structure,
questions,
gradedSampleCount,
}: {
structure: unknown
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<LazyHomeworkAssignmentExamErrorExplorer
structure={structure}
questions={questions}
gradedSampleCount={gradedSampleCount}
/>
)
}

View File

@@ -0,0 +1,44 @@
"use client"
import { useMemo, useState } from "react"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { HomeworkAssignmentExamPreviewPane } from "@/modules/homework/components/homework-assignment-exam-preview-pane"
import { HomeworkAssignmentQuestionErrorDetailPanel } from "@/modules/homework/components/homework-assignment-question-error-detail-panel"
export function HomeworkAssignmentExamErrorExplorer({
structure,
questions,
gradedSampleCount,
heightClassName = "h-[560px]",
}: {
structure: unknown
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
heightClassName?: string
}) {
const firstQuestionId = questions[0]?.questionId ?? null
const [selectedQuestionId, setSelectedQuestionId] = useState<string | null>(firstQuestionId)
const selected = useMemo(() => {
if (!selectedQuestionId) return null
return questions.find((q) => q.questionId === selectedQuestionId) ?? null
}, [questions, selectedQuestionId])
return (
<div className={`grid grid-cols-1 gap-4 md:grid-cols-3 ${heightClassName}`}>
<HomeworkAssignmentExamPreviewPane
structure={structure}
questions={questions.map((q) => ({
questionId: q.questionId,
questionType: q.questionType,
questionContent: q.questionContent,
maxScore: q.maxScore,
}))}
selectedQuestionId={selectedQuestionId}
onQuestionSelect={setSelectedQuestionId}
/>
<HomeworkAssignmentQuestionErrorDetailPanel selected={selected} gradedSampleCount={gradedSampleCount} />
</div>
)
}

View File

@@ -0,0 +1,35 @@
"use client"
import { ExamViewer } from "@/modules/exams/components/exam-viewer"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
export function HomeworkAssignmentExamPreviewPane({
structure,
questions,
selectedQuestionId,
onQuestionSelect,
}: {
structure: unknown
questions: Array<{
questionId: string
questionType: string
questionContent: unknown
maxScore: number
}>
selectedQuestionId: string | null
onQuestionSelect: (questionId: string) => void
}) {
return (
<div className="md:col-span-2 flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3 text-sm font-medium"></div>
<ScrollArea className="flex-1 p-4">
<ExamViewer
structure={structure}
questions={questions}
selectedQuestionId={selectedQuestionId}
onQuestionSelect={onQuestionSelect}
/>
</ScrollArea>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { useMemo, useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useSearchParams } from "next/navigation"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
@@ -13,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Textarea } from "@/shared/components/ui/textarea"
import { createHomeworkAssignmentAction } from "../actions"
import type { TeacherClass } from "@/modules/classes/types"
type ExamOption = { id: string; title: string }
@@ -25,11 +27,18 @@ function SubmitButton() {
)
}
export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]; classes: TeacherClass[] }) {
const router = useRouter()
const searchParams = useSearchParams()
const initialExamId = useMemo(() => exams[0]?.id ?? "", [exams])
const [examId, setExamId] = useState<string>(initialExamId)
const initialClassId = useMemo(() => {
const fromQuery = searchParams.get("classId") || ""
if (fromQuery && classes.some((c) => c.id === fromQuery)) return fromQuery
return classes[0]?.id ?? ""
}, [classes, searchParams])
const [classId, setClassId] = useState<string>(initialClassId)
const [allowLate, setAllowLate] = useState<boolean>(false)
const handleSubmit = async (formData: FormData) => {
@@ -37,7 +46,12 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
toast.error("Please select an exam")
return
}
if (!classId) {
toast.error("Please select a class")
return
}
formData.set("sourceExamId", examId)
formData.set("classId", classId)
formData.set("allowLate", allowLate ? "true" : "false")
formData.set("publish", "true")
@@ -58,6 +72,23 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label>Class</Label>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="classId" value={classId} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Source Exam</Label>
<Select value={examId} onValueChange={setExamId}>
@@ -121,7 +152,7 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
<Textarea
id="targetStudentIdsText"
name="targetStudentIdsText"
placeholder="Leave empty to assign to all students. You can paste IDs separated by comma or newline."
placeholder="Optional. If provided, targets will be limited to students in the selected class."
className="min-h-[90px]"
/>
</div>
@@ -135,4 +166,3 @@ export function HomeworkAssignmentForm({ exams }: { exams: ExamOption[] }) {
</Card>
)
}

View File

@@ -0,0 +1,150 @@
"use client"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const getOptions = (content: unknown): Array<{ id: string; text: string }> => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: Array<{ id: string; text: string }> = []
for (const item of raw) {
if (!isRecord(item)) continue
const id = typeof item.id === "string" ? item.id : ""
const text = typeof item.text === "string" ? item.text : ""
if (!id || !text) continue
out.push({ id, text })
}
return out
}
const safeInlineJson = (v: unknown) => {
try {
const s = JSON.stringify(v)
if (typeof s === "string" && s.length > 180) return `${s.slice(0, 180)}`
return s ?? String(v)
} catch {
return String(v)
}
}
const formatAnswer = (answerContent: unknown, question: HomeworkAssignmentQuestionAnalytics | null) => {
if (isRecord(answerContent) && "answer" in answerContent) answerContent = answerContent.answer
if (answerContent === null || answerContent === undefined) return "未作答"
const options = getOptions(question?.questionContent ?? null)
const optionTextById = new Map(options.map((o) => [o.id, o.text] as const))
if (typeof answerContent === "boolean") return answerContent ? "True" : "False"
if (typeof answerContent === "string") return optionTextById.get(answerContent) ?? answerContent
if (Array.isArray(answerContent)) {
const parts = answerContent
.map((x) => (typeof x === "string" ? optionTextById.get(x) ?? x : x))
.map((x) => (typeof x === "string" ? x : safeInlineJson(x)))
return parts.join(", ")
}
return safeInlineJson(answerContent)
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
function ErrorRatePieChart({ errorRate }: { errorRate: number }) {
const pct = clamp01(errorRate) * 100
const r = 15.91549430918954
const dashA = pct
const dashB = 100 - pct
const showError = pct > 0
return (
<svg viewBox="0 0 36 36" className="size-12" role="img" aria-label={`错误率 ${pct.toFixed(1)}%`}>
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="3.5" className="stroke-border" />
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="3.5" className="stroke-chart-2" />
{showError ? (
<circle
cx="18"
cy="18"
r={r}
fill="none"
strokeWidth="3.5"
strokeLinecap="round"
strokeDasharray={`${dashA} ${dashB}`}
transform="rotate(-90 18 18)"
className="stroke-destructive"
/>
) : null}
<text x="18" y="19.2" textAnchor="middle" className="fill-foreground text-[8px] font-medium tabular-nums">
{pct.toFixed(0)}%
</text>
</svg>
)
}
export function HomeworkAssignmentQuestionErrorDetailPanel({
selected,
gradedSampleCount,
}: {
selected: HomeworkAssignmentQuestionAnalytics | null
gradedSampleCount: number
}) {
const wrongAnswers = selected?.wrongAnswers ?? []
const errorCount = selected?.errorCount ?? 0
const errorRate = selected?.errorRate ?? 0
return (
<div className="flex h-full flex-col overflow-hidden rounded-md border bg-card">
<div className="border-b px-4 py-3">
<div className="text-sm font-medium"></div>
{selected ? (
<div className="mt-2 flex items-center gap-3">
<div className="shrink-0">
<ErrorRatePieChart errorRate={errorRate} />
</div>
<div className="min-w-0 flex-1 grid gap-1 text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{errorCount}</span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{(errorRate * 100).toFixed(1)}%</span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="tabular-nums text-foreground">{gradedSampleCount}</span>
</div>
</div>
</div>
) : (
<div className="mt-2 text-xs text-muted-foreground"></div>
)}
</div>
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full p-4">
{!selected ? (
<div className="text-sm text-muted-foreground"></div>
) : wrongAnswers.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground"></div>
<div className="space-y-2">
{wrongAnswers.map((item, idx) => (
<div key={`${selected.questionId}-${idx}`} className="rounded-md border bg-muted/20 px-3 py-2">
<div className="flex items-start gap-3">
<div className="w-24 shrink-0 text-xs text-muted-foreground truncate">{item.studentName}</div>
<div className="min-w-0 flex-1 text-sm wrap-break-word whitespace-pre-wrap">
{formatAnswer(item.answerContent, selected)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</ScrollArea>
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
export function HomeworkAssignmentQuestionErrorDetailsCard({
questions,
gradedSampleCount,
}: {
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<Card className="md:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Details</CardTitle>
</CardHeader>
<CardContent className="p-0">
{questions.length === 0 || gradedSampleCount === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No data available.</div>
) : (
<ScrollArea className="h-72">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[70px]">Question</TableHead>
<TableHead className="text-right">Error Count</TableHead>
<TableHead className="text-right">Error Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questions.map((q, index) => (
<TableRow key={q.questionId}>
<TableCell className="text-sm">
<div className="font-medium">Q{index + 1}</div>
</TableCell>
<TableCell className="text-right text-sm tabular-nums">{q.errorCount}</TableCell>
<TableCell className="text-right text-sm tabular-nums">{(q.errorRate * 100).toFixed(1)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,134 @@
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
function ErrorRateChart({
questions,
gradedSampleCount,
}: {
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
const w = 100
const h = 60
const padL = 10
const padR = 3
const padT = 4
const padB = 10
const plotW = w - padL - padR
const plotH = h - padT - padB
const n = questions.length
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
const xFor = (i: number) => padL + (n <= 1 ? 0 : (i / (n - 1)) * plotW)
const yFor = (rate: number) => padT + (1 - clamp01(rate)) * plotH
const points = questions.map((q, i) => `${xFor(i)},${yFor(q.errorRate)}`).join(" ")
const areaD =
n === 0
? ""
: `M ${padL} ${padT + plotH} L ${points.split(" ").join(" L ")} L ${padL + plotW} ${padT + plotH} Z`
const gridYs = [
{ v: 1, label: "100%" },
{ v: 0.5, label: "50%" },
{ v: 0, label: "0%" },
]
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-full w-full">
{gridYs.map((g) => {
const y = yFor(g.v)
return (
<g key={g.label}>
<line x1={padL} y1={y} x2={padL + plotW} y2={y} className="stroke-border" strokeWidth={0.5} />
<text x={2} y={y + 1.2} className="fill-muted-foreground text-[3px]">
{g.label}
</text>
</g>
)
})}
<line x1={padL} y1={padT} x2={padL} y2={padT + plotH} className="stroke-border" strokeWidth={0.7} />
<line
x1={padL}
y1={padT + plotH}
x2={padL + plotW}
y2={padT + plotH}
className="stroke-border"
strokeWidth={0.7}
/>
{n >= 2 ? <path d={areaD} className="fill-primary/10" /> : null}
<polyline
points={points}
fill="none"
className="stroke-primary"
strokeWidth={1.2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{questions.map((q, i) => {
const cx = xFor(i)
const cy = yFor(q.errorRate)
const label = `Q${i + 1}`
return (
<g key={q.questionId}>
<circle cx={cx} cy={cy} r={1.2} className="fill-primary" />
<title>{`${label}: ${(q.errorRate * 100).toFixed(1)}% (${q.errorCount} / ${gradedSampleCount})`}</title>
</g>
)
})}
{questions.map((q, i) => {
if (n > 12 && i % 2 === 1) return null
const x = xFor(i)
return (
<text
key={`x-${q.questionId}`}
x={x}
y={h - 2}
textAnchor="middle"
className="fill-muted-foreground text-[3px]"
>
{i + 1}
</text>
)
})}
</svg>
)
}
export function HomeworkAssignmentQuestionErrorOverviewCard({
questions,
gradedSampleCount,
}: {
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
return (
<Card className="md:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Question Error Overview</CardTitle>
</CardHeader>
<CardContent>
{questions.length === 0 || gradedSampleCount === 0 ? (
<div className="text-sm text-muted-foreground">
No graded submissions yet. Error analytics will appear here after grading.
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Graded students</span>
<span className="font-medium text-foreground">{gradedSampleCount}</span>
</div>
<div className="h-56 rounded-md border bg-muted/40 px-3 py-2">
<ErrorRateChart questions={questions} gradedSampleCount={gradedSampleCount} />
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -3,6 +3,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Check, MessageSquarePlus, X } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -11,6 +12,7 @@ import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
import { gradeHomeworkSubmissionAction } from "../actions"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
@@ -44,12 +46,22 @@ export function HomeworkGradingView({
answers: initialAnswers,
}: HomeworkGradingViewProps) {
const router = useRouter()
const [answers, setAnswers] = useState(initialAnswers)
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
const [isSubmitting, setIsSubmitting] = useState(false)
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>({})
const handleScoreChange = (id: string, val: string) => {
const score = val === "" ? 0 : parseInt(val)
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score } : a)))
const handleManualScoreChange = (id: string, val: string) => {
const parsed = val === "" ? 0 : Number(val)
const nextScore = Number.isFinite(parsed) ? parsed : 0
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
}
const handleMarkCorrect = (id: string) => {
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: a.maxScore } : a)))
}
const handleMarkIncorrect = (id: string) => {
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: 0 } : a)))
}
const handleFeedbackChange = (id: string, val: string) => {
@@ -57,14 +69,18 @@ export function HomeworkGradingView({
}
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
const binaryAnswers = answers.filter(shouldUseBinaryGrading)
const correctCount = binaryAnswers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
const incorrectCount = binaryAnswers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
const ungradedCount = binaryAnswers.length - correctCount - incorrectCount
const handleSubmit = async () => {
setIsSubmitting(true)
const payload = answers.map((a) => ({
id: a.id,
score: a.score || 0,
feedback: a.feedback,
}))
const payload = answers.map((a) => {
const feedback =
typeof a.feedback === "string" && a.feedback.trim().length > 0 ? a.feedback.trim() : undefined
return { id: a.id, score: a.score || 0, feedback }
})
const formData = new FormData()
formData.set("submissionId", submissionId)
@@ -102,9 +118,7 @@ export function HomeworkGradingView({
<div className="rounded-md bg-muted/50 p-4">
<Label className="mb-2 block text-xs text-muted-foreground">Student Answer</Label>
<p className="text-sm font-medium">
{isRecord(ans.studentAnswer) && typeof ans.studentAnswer.answer === "string"
? ans.studentAnswer.answer
: JSON.stringify(ans.studentAnswer)}
{formatStudentAnswer(ans.studentAnswer)}
</p>
</div>
@@ -122,6 +136,21 @@ export function HomeworkGradingView({
<span className="text-muted-foreground">Total Score</span>
<span className="font-bold text-lg text-primary">{currentTotal}</span>
</div>
{binaryAnswers.length > 0 ? (
<div className="mt-3 flex items-center gap-2">
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-700">
Correct {correctCount}
</Badge>
<Badge variant="outline" className="border-red-200 bg-red-50 text-red-700">
Incorrect {incorrectCount}
</Badge>
{ungradedCount > 0 ? (
<Badge variant="outline" className="text-muted-foreground">
Ungraded {ungradedCount}
</Badge>
) : null}
</div>
) : null}
</div>
<ScrollArea className="flex-1 p-4">
@@ -129,32 +158,90 @@ export function HomeworkGradingView({
{answers.map((ans, index) => (
<Card key={ans.id} className="border-l-4 border-l-primary/20">
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex justify-between">
Q{index + 1}
<span className="text-xs text-muted-foreground">Max: {ans.maxScore}</span>
</CardTitle>
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<span>Q{index + 1}</span>
<span className="text-xs text-muted-foreground font-normal">Max: {ans.maxScore}</span>
{shouldUseBinaryGrading(ans) ? (
<Badge
variant="outline"
className={getCorrectnessBadgeClassName(ans)}
>
{getCorrectnessLabel(ans)}
</Badge>
) : null}
</CardTitle>
<div className="flex items-center gap-1">
{shouldUseBinaryGrading(ans) ? (
<>
<Button
type="button"
variant="outline"
size="icon"
aria-label="mark correct"
className={getMarkCorrectButtonClassName(ans)}
onClick={() => handleMarkCorrect(ans.id)}
>
<Check />
</Button>
<Button
type="button"
variant="outline"
size="icon"
aria-label="mark incorrect"
className={getMarkIncorrectButtonClassName(ans)}
onClick={() => handleMarkIncorrect(ans.id)}
>
<X />
</Button>
</>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="add feedback"
className={getFeedbackIconButtonClassName(ans, showFeedbackByAnswerId[ans.id] ?? false)}
onClick={() =>
setShowFeedbackByAnswerId((prev) => ({ ...prev, [ans.id]: !(prev[ans.id] ?? false) }))
}
>
<MessageSquarePlus />
</Button>
</TooltipTrigger>
<TooltipContent>add feedback</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="py-3 px-4 space-y-3">
<div className="grid gap-2">
<Label htmlFor={`score-${ans.id}`}>Score</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
value={ans.score ?? ""}
onChange={(e) => handleScoreChange(ans.id, e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`fb-${ans.id}`}>Feedback</Label>
<Textarea
id={`fb-${ans.id}`}
placeholder="Optional feedback..."
className="min-h-[60px] resize-none"
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
/>
{!shouldUseBinaryGrading(ans) ? (
<div className="grid gap-2">
<Label htmlFor={`score-${ans.id}`}>Score</Label>
<Input
id={`score-${ans.id}`}
type="number"
min={0}
max={ans.maxScore}
value={ans.score ?? ""}
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
/>
</div>
) : null}
{(showFeedbackByAnswerId[ans.id] ?? false) ? (
<Textarea
id={`fb-${ans.id}`}
placeholder="Optional feedback..."
className="min-h-[60px] resize-none"
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
/>
) : null}
</div>
</CardContent>
</Card>
@@ -171,3 +258,156 @@ export function HomeworkGradingView({
</div>
)
}
type ChoiceOption = { id?: unknown; isCorrect?: unknown }
const normalizeText = (v: string) => v.trim().replace(/\s+/g, " ").toLowerCase()
const extractAnswerValue = (studentAnswer: unknown): unknown => {
if (isRecord(studentAnswer) && "answer" in studentAnswer) return studentAnswer.answer
return studentAnswer
}
const getChoiceCorrectIds = (content: QuestionContent | null): string[] => {
if (!content) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const ids: string[] = []
for (const item of raw) {
const opt = item as ChoiceOption
const id = typeof opt.id === "string" ? opt.id : null
const isCorrect = opt.isCorrect === true
if (id && isCorrect) ids.push(id)
}
return ids
}
const getTextCorrectAnswers = (content: QuestionContent | null): string[] => {
if (!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 getJudgmentCorrectAnswer = (content: QuestionContent | null): boolean | null => {
if (!content) return null
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
}
const shouldUseBinaryGrading = (ans: Answer): boolean => {
if (ans.questionType === "single_choice") return true
if (ans.questionType === "multiple_choice") return true
if (ans.questionType === "judgment") return true
if (ans.questionType === "text") return getTextCorrectAnswers(ans.questionContent).length > 0
return false
}
const isAutoGradable = (ans: Answer): boolean => {
if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0
if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null
if (ans.questionType === "text") return getTextCorrectAnswers(ans.questionContent).length > 0
return false
}
const computeIsCorrect = (ans: Answer): boolean | null => {
const studentVal = extractAnswerValue(ans.studentAnswer)
if (ans.questionType === "single_choice") {
const correct = getChoiceCorrectIds(ans.questionContent)
if (correct.length === 0) return null
if (typeof studentVal !== "string") return false
return correct.includes(studentVal)
}
if (ans.questionType === "multiple_choice") {
const correct = getChoiceCorrectIds(ans.questionContent)
if (correct.length === 0) return null
const studentArr = Array.isArray(studentVal) ? studentVal.filter((x): x is string => typeof x === "string") : []
const correctSet = new Set(correct)
const studentSet = new Set(studentArr)
if (studentSet.size !== correctSet.size) return false
for (const id of correctSet) {
if (!studentSet.has(id)) return false
}
return true
}
if (ans.questionType === "judgment") {
const correct = getJudgmentCorrectAnswer(ans.questionContent)
if (correct === null) return null
if (typeof studentVal !== "boolean") return false
return studentVal === correct
}
if (ans.questionType === "text") {
const correctAnswers = getTextCorrectAnswers(ans.questionContent)
if (correctAnswers.length === 0) return null
if (typeof studentVal !== "string") return false
const normalizedStudent = normalizeText(studentVal)
return correctAnswers.some((c) => normalizeText(c) === normalizedStudent)
}
return null
}
const applyAutoGrades = (incoming: Answer[]): Answer[] => {
return incoming.map((a) => {
if (a.score !== null) return a
if (!isAutoGradable(a)) return a
const isCorrect = computeIsCorrect(a)
if (isCorrect === null) return a
return { ...a, score: isCorrect ? a.maxScore : 0 }
})
}
type CorrectnessState = "ungraded" | "correct" | "incorrect" | "partial"
const getCorrectnessState = (ans: Answer): CorrectnessState => {
if (ans.score === null) return "ungraded"
if (ans.score === ans.maxScore) return "correct"
if (ans.score === 0) return "incorrect"
return "partial"
}
const getCorrectnessLabel = (ans: Answer): string => {
const s = getCorrectnessState(ans)
if (s === "correct") return "Correct"
if (s === "incorrect") return "Incorrect"
if (s === "partial") return "Partial"
return "Ungraded"
}
const getCorrectnessBadgeClassName = (ans: Answer): string => {
const s = getCorrectnessState(ans)
if (s === "correct") return "border-emerald-200 bg-emerald-50 text-emerald-700"
if (s === "incorrect") return "border-red-200 bg-red-50 text-red-700"
if (s === "partial") return "border-amber-200 bg-amber-50 text-amber-800"
return "text-muted-foreground"
}
const getMarkCorrectButtonClassName = (ans: Answer): string => {
const active = getCorrectnessState(ans) === "correct"
return active ? "border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100" : "text-muted-foreground"
}
const getMarkIncorrectButtonClassName = (ans: Answer): string => {
const active = getCorrectnessState(ans) === "incorrect"
return active ? "border-red-300 bg-red-50 text-red-700 hover:bg-red-100" : "text-muted-foreground"
}
const getFeedbackIconButtonClassName = (ans: Answer, isOpen: boolean): string => {
const hasFeedback = typeof ans.feedback === "string" && ans.feedback.trim().length > 0
if (isOpen) return "text-primary"
if (hasFeedback) return "text-primary/80"
return "text-muted-foreground"
}
const formatStudentAnswer = (studentAnswer: unknown): string => {
const v = extractAnswerValue(studentAnswer)
if (typeof v === "string") return v
if (typeof v === "boolean") return v ? "True" : "False"
if (Array.isArray(v)) return v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ")
if (v == null) return "—"
return JSON.stringify(v)
}

View File

@@ -1,10 +1,11 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, inArray, isNull, lte, or } from "drizzle-orm"
import { and, count, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classEnrollments,
homeworkAnswers,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
@@ -15,13 +16,19 @@ import {
import type {
HomeworkAssignmentListItem,
HomeworkAssignmentReviewListItem,
HomeworkQuestionContent,
HomeworkAssignmentStatus,
HomeworkSubmissionDetails,
HomeworkSubmissionListItem,
HomeworkAssignmentAnalytics,
HomeworkAssignmentQuestionAnalytics,
StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus,
StudentHomeworkTakeData,
StudentDashboardGradeProps,
StudentHomeworkScoreAnalytics,
StudentRanking,
} from "./types"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
@@ -31,11 +38,42 @@ const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
return v as HomeworkQuestionContent
}
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[] }) => {
const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<string, number>> => {
const ids = assignmentIds.filter((v) => v.trim().length > 0)
if (ids.length === 0) return new Map()
const rows = await db
.select({
assignmentId: homeworkAssignmentQuestions.assignmentId,
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
})
.from(homeworkAssignmentQuestions)
.where(inArray(homeworkAssignmentQuestions.assignmentId, ids))
.groupBy(homeworkAssignmentQuestions.assignmentId)
const map = new Map<string, number>()
for (const r of rows) map.set(r.assignmentId, Number(r.maxScore ?? 0))
return map
}
export const getHomeworkAssignments = cache(async (params?: { creatorId?: string; ids?: string[]; classId?: string }) => {
const conditions = []
if (params?.creatorId) conditions.push(eq(homeworkAssignments.creatorId, params.creatorId))
if (params?.ids && params.ids.length > 0) conditions.push(inArray(homeworkAssignments.id, params.ids))
if (params?.classId) {
const classStudentIds = db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(eq(classEnrollments.classId, params.classId))
const targetAssignmentIds = db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
}
const data = await db.query.homeworkAssignments.findMany({
where: conditions.length ? and(...conditions) : undefined,
@@ -64,9 +102,102 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
})
})
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string }) => {
export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId: string }) => {
const creatorId = params.creatorId.trim()
if (!creatorId) return []
const assignments = await db.query.homeworkAssignments.findMany({
where: eq(homeworkAssignments.creatorId, creatorId),
orderBy: [desc(homeworkAssignments.createdAt)],
with: { sourceExam: true },
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const targetCountRows = await db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId)
const targetCountByAssignmentId = new Map<string, number>()
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
const submittedCountRows = await db
.select({
assignmentId: homeworkSubmissions.assignmentId,
submittedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
})
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
.groupBy(homeworkSubmissions.assignmentId)
const submittedCountByAssignmentId = new Map<string, number>()
for (const r of submittedCountRows) submittedCountByAssignmentId.set(r.assignmentId, Number(r.submittedCount ?? 0))
const gradedCountRows = await db
.select({
assignmentId: homeworkSubmissions.assignmentId,
gradedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
})
.from(homeworkSubmissions)
.where(and(inArray(homeworkSubmissions.assignmentId, assignmentIds), eq(homeworkSubmissions.status, "graded")))
.groupBy(homeworkSubmissions.assignmentId)
const gradedCountByAssignmentId = new Map<string, number>()
for (const r of gradedCountRows) gradedCountByAssignmentId.set(r.assignmentId, Number(r.gradedCount ?? 0))
return assignments.map((a) => {
const item: HomeworkAssignmentReviewListItem = {
id: a.id,
title: a.title,
status: (a.status as HomeworkAssignmentReviewListItem["status"]) ?? "draft",
sourceExamTitle: a.sourceExam.title,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
targetCount: targetCountByAssignmentId.get(a.id) ?? 0,
submittedCount: submittedCountByAssignmentId.get(a.id) ?? 0,
gradedCount: gradedCountByAssignmentId.get(a.id) ?? 0,
createdAt: a.createdAt.toISOString(),
}
return item
})
})
export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: string; classId?: string; creatorId?: string }) => {
const conditions = []
if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId))
if (params?.classId) {
const classStudentIds = db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(eq(classEnrollments.classId, params.classId))
const targetAssignmentIds = db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds))
conditions.push(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
}
if (params?.creatorId) {
const creatorAssignmentIds = db
.select({ assignmentId: homeworkAssignments.id })
.from(homeworkAssignments)
.where(eq(homeworkAssignments.creatorId, params.creatorId))
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
}
const data = await db.query.homeworkSubmissions.findMany({
where: conditions.length ? and(...conditions) : undefined,
@@ -112,6 +243,18 @@ export const getHomeworkAssignmentById = cache(async (id: string) => {
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, id))
const [submittedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(eq(homeworkSubmissions.assignmentId, id), inArray(homeworkSubmissions.status, ["submitted", "graded"]))
)
const [gradedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(and(eq(homeworkSubmissions.assignmentId, id), eq(homeworkSubmissions.status, "graded")))
return {
id: assignment.id,
title: assignment.title,
@@ -119,6 +262,7 @@ export const getHomeworkAssignmentById = cache(async (id: string) => {
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
sourceExamId: assignment.sourceExamId,
sourceExamTitle: assignment.sourceExam.title,
structure: assignment.structure as unknown,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
@@ -126,11 +270,159 @@ export const getHomeworkAssignmentById = cache(async (id: string) => {
maxAttempts: assignment.maxAttempts,
targetCount: targetsRow?.c ?? 0,
submissionCount: submissionsRow?.c ?? 0,
submittedCount: submittedRow?.c ?? 0,
gradedCount: gradedRow?.c ?? 0,
createdAt: assignment.createdAt.toISOString(),
updatedAt: assignment.updatedAt.toISOString(),
}
})
export const getHomeworkAssignmentAnalytics = cache(
async (assignmentId: string): Promise<HomeworkAssignmentAnalytics | null> => {
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, assignmentId),
with: {
sourceExam: true,
},
})
if (!assignment) return null
const [targetsRow] = await db
.select({ c: count() })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId))
const [submissionsRow] = await db
.select({ c: count() })
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, assignmentId))
const [submittedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(
eq(homeworkSubmissions.assignmentId, assignmentId),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
const [gradedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded")))
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
with: { question: true },
orderBy: (q, { asc }) => [asc(q.order)],
})
const statsByQuestionId = new Map<string, HomeworkAssignmentQuestionAnalytics>()
for (const aq of assignmentQuestions) {
statsByQuestionId.set(aq.questionId, {
questionId: aq.questionId,
questionType: aq.question.type,
questionContent: toQuestionContent(aq.question.content),
maxScore: aq.score ?? 0,
order: aq.order ?? 0,
errorCount: 0,
errorRate: 0,
})
}
const gradedSubmissionsAll = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.status, "graded")
),
orderBy: (s, { desc }) => [desc(s.updatedAt)],
with: {
answers: true,
student: true,
},
})
const latestByStudentId = new Map<string, (typeof gradedSubmissionsAll)[number]>()
for (const s of gradedSubmissionsAll) {
if (!latestByStudentId.has(s.studentId)) latestByStudentId.set(s.studentId, s)
}
const gradedSubmissions = Array.from(latestByStudentId.values())
const scoreBySubmissionQuestion = new Map<string, number>()
const answerBySubmissionQuestion = new Map<string, unknown>()
for (const sub of gradedSubmissions) {
for (const ans of sub.answers) {
const key = `${sub.id}|${ans.questionId}`
if (scoreBySubmissionQuestion.has(key)) continue
scoreBySubmissionQuestion.set(key, ans.score ?? 0)
const raw = ans.answerContent
if (isRecord(raw) && "answer" in raw) {
answerBySubmissionQuestion.set(key, raw.answer)
} else {
answerBySubmissionQuestion.set(key, raw)
}
}
}
const denom = gradedSubmissions.length
if (denom > 0) {
for (const q of statsByQuestionId.values()) {
if (q.maxScore <= 0) continue
let errors = 0
const wrongAnswers: Array<{ studentId: string; studentName: string; answerContent: unknown }> = []
for (const sub of gradedSubmissions) {
const key = `${sub.id}|${q.questionId}`
const score = scoreBySubmissionQuestion.get(key) ?? 0
if (score < q.maxScore) {
errors += 1
wrongAnswers.push({
studentId: sub.studentId,
studentName: sub.student.name || "Unknown",
answerContent: answerBySubmissionQuestion.get(key),
})
}
}
q.errorCount = errors
q.errorRate = errors / denom
q.wrongAnswers = wrongAnswers.slice(0, 500)
}
}
const questions: HomeworkAssignmentQuestionAnalytics[] = Array.from(statsByQuestionId.values())
.sort((a, b) => a.order - b.order)
const analytics: HomeworkAssignmentAnalytics = {
assignment: {
id: assignment.id,
title: assignment.title,
description: assignment.description,
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
sourceExamId: assignment.sourceExamId,
sourceExamTitle: assignment.sourceExam.title,
structure: assignment.structure as unknown,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
targetCount: targetsRow?.c ?? 0,
submissionCount: submissionsRow?.c ?? 0,
submittedCount: submittedRow?.c ?? 0,
gradedCount: gradedRow?.c ?? 0,
createdAt: assignment.createdAt.toISOString(),
updatedAt: assignment.updatedAt.toISOString(),
},
gradedSampleCount: gradedSubmissions.length,
questions,
}
return analytics
}
)
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
@@ -332,3 +624,158 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
})),
}
})
export const getStudentDashboardGrades = cache(async (studentId: string): Promise<StudentDashboardGradeProps> => {
const id = studentId.trim()
if (!id) return { trend: [], recent: [], ranking: null }
const targetAssignmentIdsRows = await db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.studentId, id))
const targetAssignmentIds = Array.from(new Set(targetAssignmentIdsRows.map((r) => r.assignmentId)))
if (targetAssignmentIds.length === 0) return { trend: [], recent: [], ranking: null }
const gradedSubmissions = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.studentId, id),
inArray(homeworkSubmissions.assignmentId, targetAssignmentIds),
eq(homeworkSubmissions.status, "graded")
),
orderBy: (s, { desc }) => [desc(s.updatedAt)],
limit: 200,
})
const latestByAssignmentId = new Map<string, (typeof gradedSubmissions)[number]>()
for (const s of gradedSubmissions) {
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
}
const unique = Array.from(latestByAssignmentId.values()).sort((a, b) => {
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
return aTime - bTime
})
const trendSubmissions = unique.slice(-10)
const recentSubmissions = [...trendSubmissions].sort((a, b) => {
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
return bTime - aTime
})
const assignmentIds = Array.from(new Set(trendSubmissions.map((s) => s.assignmentId)))
const assignments = await db.query.homeworkAssignments.findMany({
where: inArray(homeworkAssignments.id, assignmentIds),
})
const titleByAssignmentId = new Map(assignments.map((a) => [a.id, a.title] as const))
const maxScoreByAssignmentId = await getAssignmentMaxScoreById(assignmentIds)
const toAnalytics = (s: (typeof trendSubmissions)[number]): StudentHomeworkScoreAnalytics => {
const maxScore = maxScoreByAssignmentId.get(s.assignmentId) ?? 0
const score = s.score ?? 0
const percentage = maxScore > 0 ? (score / maxScore) * 100 : 0
return {
assignmentId: s.assignmentId,
assignmentTitle: titleByAssignmentId.get(s.assignmentId) ?? "Untitled",
score,
maxScore,
percentage,
submittedAt: (s.submittedAt ?? s.updatedAt).toISOString(),
}
}
const trend = trendSubmissions.map(toAnalytics)
const recent = recentSubmissions.map(toAnalytics).slice(0, 5)
const enrollment = await db.query.classEnrollments.findFirst({
where: and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")),
orderBy: (e, { asc }) => [asc(e.createdAt)],
})
if (!enrollment) return { trend, recent, ranking: null }
const classStudents = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(and(eq(classEnrollments.classId, enrollment.classId), eq(classEnrollments.status, "active")))
const classStudentIds = Array.from(new Set(classStudents.map((r) => r.studentId)))
const classSize = classStudentIds.length
if (classSize === 0) return { trend, recent, ranking: null }
const classAssignmentIdsRows = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
const classAssignmentIds = Array.from(new Set(classAssignmentIdsRows.map((r) => r.assignmentId)))
if (classAssignmentIds.length === 0) return { trend, recent, ranking: null }
const classMaxScoreByAssignmentId = await getAssignmentMaxScoreById(classAssignmentIds)
const classGradedSubmissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.studentId, classStudentIds),
inArray(homeworkSubmissions.assignmentId, classAssignmentIds),
eq(homeworkSubmissions.status, "graded")
),
orderBy: (s, { desc }) => [desc(s.updatedAt)],
limit: 5000,
})
const latestByStudentAssignment = new Map<string, (typeof classGradedSubmissions)[number]>()
for (const s of classGradedSubmissions) {
const key = `${s.studentId}|${s.assignmentId}`
if (!latestByStudentAssignment.has(key)) latestByStudentAssignment.set(key, s)
}
const totalsByStudentId = new Map<string, { score: number; maxScore: number }>()
for (const sub of latestByStudentAssignment.values()) {
const maxScore = classMaxScoreByAssignmentId.get(sub.assignmentId) ?? 0
const score = sub.score ?? 0
const prev = totalsByStudentId.get(sub.studentId) ?? { score: 0, maxScore: 0 }
totalsByStudentId.set(sub.studentId, {
score: prev.score + score,
maxScore: prev.maxScore + maxScore,
})
}
const classUsers = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, classStudentIds))
const nameByStudentId = new Map(classUsers.map((u) => [u.id, u.name ?? "Student"] as const))
const myName = nameByStudentId.get(id) ?? "Student"
const ranked = classStudentIds
.map((studentId) => {
const totals = totalsByStudentId.get(studentId) ?? { score: 0, maxScore: 0 }
const percentage = totals.maxScore > 0 ? (totals.score / totals.maxScore) * 100 : 0
return { studentId, percentage, totals }
})
.sort((a, b) => {
if (b.percentage !== a.percentage) return b.percentage - a.percentage
return a.studentId.localeCompare(b.studentId)
})
const myIndex = ranked.findIndex((r) => r.studentId === id)
if (myIndex < 0) return { trend, recent, ranking: null }
const myTotals = ranked[myIndex]?.totals ?? { score: 0, maxScore: 0 }
const myPercentage = myTotals.maxScore > 0 ? (myTotals.score / myTotals.maxScore) * 100 : 0
const ranking: StudentRanking = {
studentId: id,
studentName: myName,
rank: myIndex + 1,
classSize,
totalScore: myTotals.score,
totalMaxScore: myTotals.maxScore,
percentage: myPercentage,
}
return { trend, recent, ranking }
})

View File

@@ -2,6 +2,7 @@ import { z } from "zod"
export const CreateHomeworkAssignmentSchema = z.object({
sourceExamId: z.string().min(1),
classId: z.string().min(1),
title: z.string().optional(),
description: z.string().optional(),
availableAt: z.string().optional(),
@@ -25,4 +26,3 @@ export const GradeHomeworkSchema = z.object({
})
),
})

View File

@@ -17,6 +17,18 @@ export interface HomeworkAssignmentListItem {
updatedAt: string
}
export interface HomeworkAssignmentReviewListItem {
id: string
title: string
status: HomeworkAssignmentStatus
sourceExamTitle: string
dueAt: string | null
targetCount: number
submittedCount: number
gradedCount: number
createdAt: string
}
export interface HomeworkSubmissionListItem {
id: string
assignmentId: string
@@ -68,6 +80,23 @@ export interface StudentHomeworkAssignmentListItem {
latestScore: number | null
}
export type StudentHomeworkPerformanceItem = {
assignmentId: string
title: string
gradedAt: string
score: number
maxScore: number
percent: number
rank: number
gradedCount: number
}
export type StudentHomeworkPerformanceSummary = {
averagePercent: number | null
bestPercent: number | null
latestRank: { rank: number; gradedCount: number; percent: number } | null
}
export type StudentHomeworkTakeQuestion = {
questionId: string
questionType: string
@@ -97,3 +126,64 @@ export type StudentHomeworkTakeData = {
} | null
questions: StudentHomeworkTakeQuestion[]
}
export type HomeworkAssignmentQuestionAnalytics = {
questionId: string
questionType: string
questionContent: HomeworkQuestionContent | null
maxScore: number
order: number
errorCount: number
errorRate: number
wrongAnswers?: Array<{ studentId: string; studentName: string; answerContent: unknown }>
}
export type HomeworkAssignmentAnalytics = {
assignment: {
id: string
title: string
description: string | null
status: HomeworkAssignmentStatus
sourceExamId: string
sourceExamTitle: string
structure: unknown | null
availableAt: string | null
dueAt: string | null
allowLate: boolean
lateDueAt: string | null
maxAttempts: number
targetCount: number
submissionCount: number
submittedCount: number
gradedCount: number
createdAt: string
updatedAt: string
}
gradedSampleCount: number
questions: HomeworkAssignmentQuestionAnalytics[]
}
export interface StudentHomeworkScoreAnalytics {
assignmentId: string
assignmentTitle: string
score: number
maxScore: number
percentage: number
submittedAt: string
}
export interface StudentRanking {
studentId: string
studentName: string
rank: number
classSize: number
totalScore: number
totalMaxScore: number
percentage: number
}
export interface StudentDashboardGradeProps {
trend: StudentHomeworkScoreAnalytics[]
recent: StudentHomeworkScoreAnalytics[]
ranking: StudentRanking | null
}

View File

@@ -3,6 +3,7 @@
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useSession } from "next-auth/react"
import { ChevronRight } from "lucide-react"
import {
@@ -17,13 +18,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { cn } from "@/shared/lib/utils"
import { useSidebar } from "./sidebar-provider"
import { NAV_CONFIG, Role } from "../config/navigation"
@@ -35,11 +29,10 @@ interface AppSidebarProps {
export function AppSidebar({ mode }: AppSidebarProps) {
const { expanded, toggleSidebar, isMobile } = useSidebar()
const pathname = usePathname()
// MOCK ROLE: In real app, get this from auth context / session
const [currentRole, setCurrentRole] = React.useState<Role>("admin")
const { data } = useSession()
const currentRole = (data?.user?.role ?? "teacher") as Role
const navItems = NAV_CONFIG[currentRole]
const navItems = NAV_CONFIG[currentRole] ?? NAV_CONFIG.teacher
// Ensure consistent state for hydration
if (!expanded && mode === 'mobile') return null
@@ -62,26 +55,6 @@ export function AppSidebar({ mode }: AppSidebarProps) {
)}
</div>
{/* Role Switcher (Dev Only - for Demo) */}
{(expanded || isMobile) && (
<div className="px-4">
<label className="text-muted-foreground mb-2 block text-xs font-medium uppercase">
View As (Dev Mode)
</label>
<Select value={currentRole} onValueChange={(v) => setCurrentRole(v as Role)}>
<SelectTrigger>
<SelectValue placeholder="Select Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="teacher">Teacher</SelectItem>
<SelectItem value="student">Student</SelectItem>
<SelectItem value="parent">Parent</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Navigation */}
<ScrollArea className="flex-1 px-3">
<nav className="flex flex-col gap-2 py-4">

View File

@@ -1,7 +1,6 @@
"use client"
import * as React from "react"
import { Menu } from "lucide-react"
import {
Sheet,

View File

@@ -1,7 +1,9 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { Bell, Menu, Search } from "lucide-react"
import { signOut } from "next-auth/react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
@@ -14,7 +16,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/shared/components/ui/breadcrumb"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
@@ -78,7 +80,6 @@ export function SiteHeader() {
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative size-8 rounded-full">
<Avatar className="size-8">
<AvatarImage src="/avatars/01.png" alt="@user" />
<AvatarFallback>AD</AvatarFallback>
</Avatar>
</Button>
@@ -91,10 +92,20 @@ export function SiteHeader() {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/profile">Profile</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings">Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:bg-destructive/10">
<DropdownMenuItem
className="text-destructive focus:bg-destructive/10"
onSelect={(e) => {
e.preventDefault()
signOut({ callbackUrl: "/login" })
}}
>
Log out
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -37,8 +37,11 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
icon: Shield,
href: "/admin/school",
items: [
{ title: "Schools", href: "/admin/school/schools" },
{ title: "Grades", href: "/admin/school/grades" },
{ title: "Grade Insights", href: "/admin/school/grades/insights" },
{ title: "Departments", href: "/admin/school/departments" },
{ title: "Classrooms", href: "/admin/school/classrooms" },
{ title: "Classes", href: "/admin/school/classes" },
{ title: "Academic Year", href: "/admin/school/academic-year" },
]
},
@@ -82,7 +85,7 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
{
title: "Dashboard",
icon: LayoutDashboard,
href: "/dashboard",
href: "/teacher/dashboard",
},
{
title: "Textbooks",
@@ -120,6 +123,8 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
{ title: "My Classes", href: "/teacher/classes/my" },
{ title: "Students", href: "/teacher/classes/students" },
{ title: "Schedule", href: "/teacher/classes/schedule" },
{ title: "Insights", href: "/teacher/classes/insights" },
{ title: "Grade Insights", href: "/teacher/grades/insights" },
]
},
],
@@ -127,7 +132,7 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
{
title: "Dashboard",
icon: LayoutDashboard,
href: "/dashboard",
href: "/student/dashboard",
},
{
title: "My Learning",
@@ -136,7 +141,7 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
items: [
{ title: "Courses", href: "/student/learning/courses" },
{ title: "Assignments", href: "/student/learning/assignments" },
{ title: "Grades", href: "/student/learning/grades" },
{ title: "Textbooks", href: "/student/learning/textbooks" },
]
},
{

View File

@@ -104,13 +104,11 @@ export async function createNestedQuestion(
};
} catch (error) {
console.error("Failed to create question:", error);
if (error instanceof Error) {
return {
success: false,
message: error.message || "Database error occurred",
};
return {
success: false,
message: error.message || "Database error occurred",
};
}
return {

View File

@@ -0,0 +1,291 @@
"use server"
import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2"
import { eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { academicYears, departments, grades, schools } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import { UpsertAcademicYearSchema, UpsertDepartmentSchema, UpsertGradeSchema, UpsertSchoolSchema } from "./schema"
export async function createDepartmentAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertDepartmentSchema.parse({
name: formData.get("name"),
description: formData.get("description"),
})
await db.insert(departments).values({
id: createId(),
name: parsed.name,
description: parsed.description ?? null,
})
revalidatePath("/admin/school/departments")
return { success: true, message: "Department created" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to create department" }
}
}
export async function updateDepartmentAction(
departmentId: string,
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertDepartmentSchema.parse({
name: formData.get("name"),
description: formData.get("description"),
})
await db
.update(departments)
.set({
name: parsed.name,
description: parsed.description ?? null,
})
.where(eq(departments.id, departmentId))
revalidatePath("/admin/school/departments")
return { success: true, message: "Department updated" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to update department" }
}
}
export async function deleteDepartmentAction(departmentId: string): Promise<ActionState<string>> {
try {
await db.delete(departments).where(eq(departments.id, departmentId))
revalidatePath("/admin/school/departments")
return { success: true, message: "Department deleted" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to delete department" }
}
}
export async function createAcademicYearAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertAcademicYearSchema.parse({
name: formData.get("name"),
startDate: formData.get("startDate"),
endDate: formData.get("endDate"),
isActive: formData.get("isActive") ?? "false",
})
await db.transaction(async (tx) => {
if (parsed.isActive) {
await tx.update(academicYears).set({ isActive: false })
}
await tx.insert(academicYears).values({
id: createId(),
name: parsed.name,
startDate: new Date(parsed.startDate),
endDate: new Date(parsed.endDate),
isActive: parsed.isActive,
})
})
revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year created" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to create academic year" }
}
}
export async function updateAcademicYearAction(
academicYearId: string,
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertAcademicYearSchema.parse({
name: formData.get("name"),
startDate: formData.get("startDate"),
endDate: formData.get("endDate"),
isActive: formData.get("isActive") ?? "false",
})
await db.transaction(async (tx) => {
if (parsed.isActive) {
await tx.update(academicYears).set({ isActive: false })
}
await tx
.update(academicYears)
.set({
name: parsed.name,
startDate: new Date(parsed.startDate),
endDate: new Date(parsed.endDate),
isActive: parsed.isActive,
})
.where(eq(academicYears.id, academicYearId))
})
revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year updated" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to update academic year" }
}
}
export async function deleteAcademicYearAction(academicYearId: string): Promise<ActionState<string>> {
try {
await db.delete(academicYears).where(eq(academicYears.id, academicYearId))
revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year deleted" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to delete academic year" }
}
}
export async function createSchoolAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertSchoolSchema.parse({
name: formData.get("name"),
code: formData.get("code"),
})
await db.insert(schools).values({
id: createId(),
name: parsed.name,
code: parsed.code?.trim() ? parsed.code.trim() : null,
})
revalidatePath("/admin/school/schools")
return { success: true, message: "School created" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to create school" }
}
}
export async function updateSchoolAction(
schoolId: string,
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertSchoolSchema.parse({
name: formData.get("name"),
code: formData.get("code"),
})
await db
.update(schools)
.set({
name: parsed.name,
code: parsed.code?.trim() ? parsed.code.trim() : null,
})
.where(eq(schools.id, schoolId))
revalidatePath("/admin/school/schools")
return { success: true, message: "School updated" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to update school" }
}
}
export async function deleteSchoolAction(schoolId: string): Promise<ActionState<string>> {
try {
await db.delete(schools).where(eq(schools.id, schoolId))
revalidatePath("/admin/school/schools")
revalidatePath("/admin/school/grades")
return { success: true, message: "School deleted" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to delete school" }
}
}
export async function createGradeAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertGradeSchema.parse({
schoolId: formData.get("schoolId"),
name: formData.get("name"),
order: formData.get("order"),
gradeHeadId: formData.get("gradeHeadId"),
teachingHeadId: formData.get("teachingHeadId"),
})
await db.insert(grades).values({
id: createId(),
schoolId: parsed.schoolId,
name: parsed.name,
order: parsed.order,
gradeHeadId: parsed.gradeHeadId,
teachingHeadId: parsed.teachingHeadId,
})
revalidatePath("/admin/school/grades")
return { success: true, message: "Grade created" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to create grade" }
}
}
export async function updateGradeAction(
gradeId: string,
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
const parsed = UpsertGradeSchema.parse({
schoolId: formData.get("schoolId"),
name: formData.get("name"),
order: formData.get("order"),
gradeHeadId: formData.get("gradeHeadId"),
teachingHeadId: formData.get("teachingHeadId"),
})
await db
.update(grades)
.set({
schoolId: parsed.schoolId,
name: parsed.name,
order: parsed.order,
gradeHeadId: parsed.gradeHeadId,
teachingHeadId: parsed.teachingHeadId,
})
.where(eq(grades.id, gradeId))
revalidatePath("/admin/school/grades")
return { success: true, message: "Grade updated" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to update grade" }
}
}
export async function deleteGradeAction(gradeId: string): Promise<ActionState<string>> {
try {
await db.delete(grades).where(eq(grades.id, gradeId))
revalidatePath("/admin/school/grades")
return { success: true, message: "Grade deleted" }
} catch (error) {
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to delete grade" }
}
}

View File

@@ -0,0 +1,315 @@
"use client"
import { useMemo, useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import type { AcademicYearListItem } from "../types"
import { createAcademicYearAction, deleteAcademicYearAction, updateAcademicYearAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { formatDate } from "@/shared/lib/utils"
const toDateInput = (iso: string) => iso.slice(0, 10)
export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [createActive, setCreateActive] = useState(true)
const [editItem, setEditItem] = useState<AcademicYearListItem | null>(null)
const [editActive, setEditActive] = useState(false)
const [deleteItem, setDeleteItem] = useState<AcademicYearListItem | null>(null)
const activeYear = useMemo(() => years.find((y) => y.isActive) ?? null, [years])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("isActive", createActive ? "true" : "false")
const res = await createAcademicYearAction(undefined, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create academic year")
}
} catch {
toast.error("Failed to create academic year")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
formData.set("isActive", editActive ? "true" : "false")
const res = await updateAcademicYearAction(editItem.id, undefined, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update academic year")
}
} catch {
toast.error("Failed to update academic year")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteAcademicYearAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete academic year")
}
} catch {
toast.error("Failed to delete academic year")
} finally {
setIsWorking(false)
}
}
return (
<>
<div className="flex justify-end">
<Button
onClick={() => {
setCreateActive(activeYear === null)
setCreateOpen(true)
}}
disabled={isWorking}
>
<Plus className="mr-2 h-4 w-4" />
New academic year
</Button>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1 shadow-none">
<CardHeader>
<CardTitle className="text-base">Active year</CardTitle>
</CardHeader>
<CardContent>
{activeYear ? (
<div className="space-y-2">
<div className="text-lg font-semibold">{activeYear.name}</div>
<div className="text-sm text-muted-foreground">
{formatDate(activeYear.startDate)} {formatDate(activeYear.endDate)}
</div>
<Badge variant="secondary">Active</Badge>
</div>
) : (
<EmptyState
title="No active year"
description="Set one academic year as active."
className="h-auto border-none shadow-none"
/>
)}
</CardContent>
</Card>
<Card className="lg:col-span-2 shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">All years</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{years.length}
</Badge>
</CardHeader>
<CardContent>
{years.length === 0 ? (
<EmptyState
title="No academic years"
description="Create an academic year to define school calendar."
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Range</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{years.map((y) => (
<TableRow key={y.id}>
<TableCell className="font-medium">{y.name}</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(y.startDate)} {formatDate(y.endDate)}
</TableCell>
<TableCell>{y.isActive ? <Badge variant="secondary">Active</Badge> : <Badge variant="outline">Inactive</Badge>}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditItem(y)
setEditActive(y.isActive)
}}
>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(y)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New academic year</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" placeholder="e.g. 2025-2026" autoFocus />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startDate">Start date</Label>
<Input id="startDate" name="startDate" type="date" />
</div>
<div className="space-y-2">
<Label htmlFor="endDate">End date</Label>
<Input id="endDate" name="endDate" type="date" />
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox checked={createActive} onCheckedChange={(v) => setCreateActive(Boolean(v))} />
<Label className="cursor-pointer" onClick={() => setCreateActive((v) => !v)}>
Set as active
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editItem)} onOpenChange={(open) => {
if (!open) setEditItem(null)
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit academic year</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Name</Label>
<Input id="edit-name" name="name" defaultValue={editItem.name} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-startDate">Start date</Label>
<Input id="edit-startDate" name="startDate" type="date" defaultValue={toDateInput(editItem.startDate)} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-endDate">End date</Label>
<Input id="edit-endDate" name="endDate" type="date" defaultValue={toDateInput(editItem.endDate)} />
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox checked={editActive} onCheckedChange={(v) => setEditActive(Boolean(v))} />
<Label className="cursor-pointer" onClick={() => setEditActive((v) => !v)}>
Set as active
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking}>
Save
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deleteItem)} onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete academic year</AlertDialogTitle>
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this academic year"}.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

Some files were not shown because too many files have changed in this diff Show More