feat: enhance textbook reader with anchor text support and improve knowledge point management

This commit is contained in:
SpecialX
2026-01-16 10:22:16 +08:00
parent 9bfc621d3f
commit bb4555f611
44 changed files with 6284 additions and 2090 deletions

View File

@@ -0,0 +1,109 @@
import Link from "next/link"
import { ChevronRight, FileText } from "lucide-react"
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"
import { formatDate } from "@/shared/lib/utils"
interface AssignmentSummary {
id: string
title: string
status: string
isActive: boolean
isOverdue: boolean
dueAt: Date | null
submittedCount: number
targetCount: number
avgScore: number | null
medianScore: number | null
}
interface ClassAssignmentsWidgetProps {
classId: string
assignments: AssignmentSummary[]
}
export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignmentsWidgetProps) {
const activeAssignments = assignments.filter((a) => a.isActive)
return (
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">Recent Homework</CardTitle>
<CardDescription>
{activeAssignments.length} active assignments
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(classId)}`}>
View All
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="pt-4">
{assignments.length === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
<div className="rounded-full bg-muted p-3">
<FileText className="h-6 w-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">No homework yet</p>
<p className="text-xs text-muted-foreground">
Create an assignment to get started.
</p>
</div>
<Button size="sm" asChild>
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
Create Homework
</Link>
</Button>
</div>
) : (
<div className="space-y-4">
{assignments.slice(0, 5).map((assignment) => (
<div
key={assignment.id}
className="flex items-start justify-between space-x-4 rounded-md border p-3 transition-all hover:bg-muted/50"
>
<div className="space-y-1">
<Link
href={`/teacher/homework/assignments/${assignment.id}`}
className="block font-medium hover:underline line-clamp-1"
>
{assignment.title}
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={assignment.isOverdue ? "text-destructive font-medium" : ""}>
Due {assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}
</span>
<span></span>
<span>
{assignment.submittedCount}/{assignment.targetCount} Submitted
</span>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Badge
variant={assignment.isActive ? "default" : "secondary"}
className="rounded-sm px-1.5 py-0.5 text-[10px] uppercase"
>
{assignment.status}
</Badge>
{typeof assignment.avgScore === "number" && (
<span className="text-xs font-medium tabular-nums">
Avg: {assignment.avgScore.toFixed(0)}%
</span>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import { useState } from "react"
import { MoreHorizontal, Pencil, Settings, Share2 } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { EditClassDialog } from "./edit-class-dialog"
interface ClassHeaderProps {
classId: string
name: string
grade: string
homeroom?: string | null
room?: string | null
schoolName?: string | null
studentCount: number
}
export function ClassHeader({
classId,
name,
grade,
homeroom,
room,
schoolName,
studentCount,
}: ClassHeaderProps) {
const [showEdit, setShowEdit] = useState(false)
return (
<>
<div className="flex flex-col gap-4 border-b bg-background px-6 py-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{name}
</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{schoolName && (
<>
<span>{schoolName}</span>
<span className="text-muted-foreground/40"></span>
</>
)}
<Badge variant="secondary" className="font-medium">
{grade}
</Badge>
{homeroom && (
<>
<span className="text-muted-foreground/40"></span>
<span>Homeroom {homeroom}</span>
</>
)}
{room && (
<>
<span className="text-muted-foreground/40"></span>
<span>Room {room}</span>
</>
)}
<span className="text-muted-foreground/40"></span>
<span>{studentCount} Students</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="hidden sm:flex">
<Share2 className="mr-2 h-4 w-4" />
Invite
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setShowEdit(true)}>
<Pencil className="mr-2 h-4 w-4" />
Edit details
</DropdownMenuItem>
<DropdownMenuItem>
<Share2 className="mr-2 h-4 w-4" />
Invite students
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Settings className="mr-2 h-4 w-4" />
Class settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<EditClassDialog
open={showEdit}
onOpenChange={setShowEdit}
classId={classId}
initialData={{
name,
grade,
homeroom,
room,
schoolName
}}
/>
</>
)
}

View File

@@ -0,0 +1,74 @@
import { AlertCircle, BarChart3, CheckCircle2, PenTool } from "lucide-react"
import { Card, CardContent } from "@/shared/components/ui/card"
interface ClassOverviewStatsProps {
averageScore: number | null
submissionRate: number
papersToGrade: number
overdueCount: number
}
export function ClassOverviewStats({
averageScore,
submissionRate,
papersToGrade,
overdueCount,
}: ClassOverviewStatsProps) {
return (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatsCard
title="Class Average"
value={averageScore ? `${averageScore.toFixed(1)}%` : "-"}
subValue="Overall performance"
icon={BarChart3}
/>
<StatsCard
title="Submission Rate"
value={`${submissionRate.toFixed(0)}%`}
subValue="Average turn-in rate"
icon={CheckCircle2}
/>
<StatsCard
title="To Grade"
value={papersToGrade.toString()}
subValue="Pending reviews"
icon={PenTool}
/>
<StatsCard
title="Missed Deadlines"
value={overdueCount.toString()}
subValue="Active assignments past due"
icon={AlertCircle}
/>
</div>
)
}
function StatsCard({
title,
value,
subValue,
icon: Icon,
}: {
title: string
value: string
subValue: string
icon: React.ElementType
}) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground">{subValue}</p>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,42 @@
import Link from "next/link"
import { Calendar, FilePlus, Mail, MessageSquare, Settings } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
interface ClassQuickActionsProps {
classId: string
}
export function ClassQuickActions({ classId }: ClassQuickActionsProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
<Button asChild className="w-full justify-start" size="sm">
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
<FilePlus className="mr-2 h-4 w-4" />
Create Homework
</Link>
</Button>
<Button asChild variant="outline" className="w-full justify-start" size="sm">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
<Calendar className="mr-2 h-4 w-4" />
Manage Schedule
</Link>
</Button>
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
<MessageSquare className="mr-2 h-4 w-4" />
Message Class (Coming soon)
</Button>
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
<Settings className="mr-2 h-4 w-4" />
Class Settings
</Button>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,111 @@
import Link from "next/link"
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/shared/components/ui/hover-card"
import type { ClassScheduleItem } from "@/modules/classes/types"
interface ClassScheduleWidgetProps {
classId: string
schedule: ClassScheduleItem[]
}
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
const WEEKDAY_INDICES = [1, 2, 3, 4, 5, 6, 7] // 1=Mon, 7=Sun
export function ClassScheduleGrid({ schedule, compact = false }: { schedule: ClassScheduleItem[], compact?: boolean }) {
// Group by weekday
const groupedSchedule = schedule.reduce((acc, item) => {
const day = item.weekday
if (!acc[day]) acc[day] = []
acc[day].push(item)
return acc
}, {} as Record<number, ClassScheduleItem[]>)
// Sort items within each day by start time
Object.keys(groupedSchedule).forEach(key => {
groupedSchedule[Number(key)].sort((a, b) => a.startTime.localeCompare(b.startTime))
})
if (schedule.length === 0) {
return (
<div className="flex flex-col items-center justify-center space-y-3 py-6 text-center">
<div className="rounded-full bg-muted p-3">
<Calendar className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">No sessions scheduled.</p>
</div>
)
}
return (
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
{WEEKDAYS.slice(0, 5).map((day, i) => (
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
{day}
</div>
))}
{WEEKDAY_INDICES.slice(0, 5).map((dayNum) => {
const items = groupedSchedule[dayNum] || []
return (
<div key={dayNum} className={`flex flex-col gap-1 py-1 border-r last:border-r-0 border-muted/30 ${compact ? 'max-h-[140px]' : 'min-h-[100px]'}`}>
{items.length === 0 ? (
<div className="flex-1" />
) : (
items.map(item => (
<HoverCard key={item.id}>
<HoverCardTrigger asChild>
<div className="bg-primary/5 text-primary rounded-[2px] p-1 text-[10px] text-left relative hover:bg-primary/10 transition-colors cursor-default leading-tight shrink-0">
<div className="font-semibold truncate">{item.course}</div>
<div className="opacity-70 scale-90 origin-left mt-0.5 whitespace-nowrap">{item.startTime}-{item.endTime}</div>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-48 p-3" align="start" side="top">
<div className="flex flex-col gap-2">
<div className="font-semibold text-sm border-b pb-1 mb-1">{item.course}</div>
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{item.startTime} - {item.endTime}</span>
</div>
{item.location && (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span>{item.location}</span>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
))
)}
</div>
)
})}
</div>
)
}
export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetProps) {
return (
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base font-semibold">Weekly Schedule</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
Manage
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="pt-4">
<ClassScheduleGrid schedule={schedule} />
<div className="mt-2 text-[10px] text-muted-foreground text-center">
* Showing Mon-Fri schedule
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,106 @@
import Link from "next/link"
import { ChevronRight, Users } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
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"
import { formatDate } from "@/shared/lib/utils"
interface StudentSummary {
id: string
name: string
email: string
image?: string | null
status: string
subjectScores?: Record<string, number | null>
}
interface ClassStudentsWidgetProps {
classId: string
students: StudentSummary[]
}
export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetProps) {
const activeCount = students.filter(s => s.status === "active").length
return (
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">Students</CardTitle>
<CardDescription>
{activeCount} active students
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(classId)}`}>
View All
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="pt-4">
{students.length === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
<div className="rounded-full bg-muted p-3">
<Users className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">No students enrolled yet.</p>
</div>
) : (
<div className="space-y-4">
{students.slice(0, 6).map((student) => (
<div key={student.id} className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={student.image || undefined} alt={student.name} />
<AvatarFallback className="text-xs">
{student.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className="space-y-0.5">
<div className="text-sm font-medium leading-none">{student.name}</div>
<div className="text-xs text-muted-foreground line-clamp-1">{student.email}</div>
</div>
</div>
<Badge
variant={student.status === "active" ? "outline" : "secondary"}
className="text-[10px] capitalize"
>
{student.status}
</Badge>
</div>
{/* Subject Scores */}
{student.subjectScores && Object.keys(student.subjectScores).length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{Object.entries(student.subjectScores).map(([subject, score]) => (
<div key={subject} className="flex items-center gap-1.5 rounded bg-muted/50 px-2 py-1 text-[10px]">
<span className="font-medium text-muted-foreground">{subject}</span>
{score !== null ? (
<span className={score >= 60 ? "font-semibold text-primary" : "font-semibold text-destructive"}>
{score}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,398 @@
"use client"
import { useState, useMemo } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChevronDown } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
interface AssignmentSummary {
id: string
title: string
status: string
subject?: string | null
isActive: boolean
isOverdue: boolean
dueAt: Date | null
submittedCount: number
targetCount: number
avgScore: number | null
medianScore: number | null
}
interface ClassTrendsWidgetProps {
classId: string
assignments: AssignmentSummary[]
compact?: boolean
className?: string
}
const chartConfig = {
submitted: {
label: "Submitted",
color: "hsl(var(--primary))",
},
target: {
label: "Total Students",
color: "hsl(var(--muted-foreground))",
},
avg: {
label: "Average Score",
color: "hsl(var(--chart-2))",
},
median: {
label: "Median Score",
color: "hsl(var(--chart-4))",
},
} satisfies ChartConfig
export function transformAssignmentsToChartData(assignments: AssignmentSummary[], limit?: number) {
const data = [...assignments].reverse().map(a => ({
title: a.title.length > 10 ? a.title.substring(0, 10) + "..." : a.title,
fullTitle: a.title,
submitted: a.submittedCount,
target: a.targetCount,
avg: a.avgScore ? Math.round(a.avgScore) : null,
median: a.medianScore ? Math.round(a.medianScore) : null,
}))
if (limit) {
return data.slice(-limit)
}
return data
}
export function ClassSubmissionTrendChart({
data,
className
}: {
data: any[]
className?: string
}) {
return (
<ChartContainer config={chartConfig} className={className}>
<LineChart accessibilityLayer data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={10}
hide
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={10}
domain={[0, 'auto']}
tickFormatter={(value) => `${value}`}
hide
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/>
<Line
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
</LineChart>
</ChartContainer>
)
}
export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) {
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
const [selectedSubject, setSelectedSubject] = useState<string>("all")
// Extract unique subjects
const subjects = Array.from(new Set(assignments.map(a => a.subject).filter(Boolean))) as string[]
const activeAssignments = assignments.filter((a) => {
if (selectedSubject !== "all" && a.subject !== selectedSubject) return false
return a.isActive || a.status === "published" // Include published even if not "active" in terms of due date
})
const chartData = transformAssignmentsToChartData(activeAssignments, 7)
if (chartData.length === 0 && selectedSubject === "all") return null
if (compact) {
// Calculate simple stats for compact view
const lastAssignment = chartData[chartData.length - 1]
let metricValue = "0%"
let metricLabel = "Latest"
if (lastAssignment) {
if (chartTab === "submission") {
metricValue = lastAssignment.target > 0
? `${Math.round((lastAssignment.submitted / lastAssignment.target) * 100)}%`
: "0%"
} else {
metricValue = lastAssignment.avg ? `${lastAssignment.avg}` : "-"
}
}
return (
<div className={`flex flex-col h-full ${className || ""}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs font-semibold text-foreground/80 hover:bg-muted">
{chartTab === "submission" ? "Submission" : "Score"}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setChartTab("submission")} className="text-xs">
Submission Trends
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setChartTab("score")} className="text-xs">
Score Trends
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{subjects.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground">
{selectedSubject === "all" ? "All Subjects" : selectedSubject}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setSelectedSubject("all")} className="text-xs">
All Subjects
</DropdownMenuItem>
{subjects.map(s => (
<DropdownMenuItem key={s} onClick={() => setSelectedSubject(s)} className="text-xs">
{s}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="text-xs font-medium text-muted-foreground">
{metricLabel}: <span className="text-foreground">{metricValue}</span>
</div>
</div>
{/* Compact Sparkline Chart */}
<div className="flex-1 w-full min-h-0">
<ChartContainer config={chartConfig} className="h-full w-full">
{chartTab === "submission" ? (
<AreaChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="fillSubmitted" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-submitted)" stopOpacity={0.3}/>
<stop offset="95%" stopColor="var(--color-submitted)" stopOpacity={0.05}/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
<XAxis dataKey="title" hide />
<YAxis hide domain={[0, 'auto']} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" hideLabel />}
/>
<Area
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
fill="url(#fillSubmitted)"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={1}
strokeDasharray="2 2"
dot={false}
activeDot={false}
/>
</AreaChart>
) : (
<LineChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
<XAxis dataKey="title" hide />
<YAxis hide domain={[0, 100]} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Line
type="monotone"
dataKey="avg"
stroke="var(--color-avg)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
<Line
type="monotone"
dataKey="median"
stroke="var(--color-median)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
activeDot={{ r: 4 }}
/>
</LineChart>
)}
</ChartContainer>
</div>
</div>
)
}
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">
{chartTab === "submission" ? "Submission Trends" : "Score Trends"}
</CardTitle>
<CardDescription>
{chartTab === "submission" ? "Recent assignment turn-in rates" : "Average vs Median performance"}
</CardDescription>
</div>
<Tabs value={chartTab} onValueChange={(v) => setChartTab(v as "submission" | "score")} className="w-auto">
<TabsList className="grid w-full grid-cols-2 h-8">
<TabsTrigger value="submission" className="text-xs">Submission</TabsTrigger>
<TabsTrigger value="score" className="text-xs">Score</TabsTrigger>
</TabsList>
</Tabs>
</div>
{subjects.length > 0 && (
<Tabs value={selectedSubject} onValueChange={setSelectedSubject} className="w-full">
<TabsList className="h-8 w-auto flex-wrap justify-start bg-transparent p-0">
<TabsTrigger
value="all"
className="h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
>
All Subjects
</TabsTrigger>
{subjects.map(s => (
<TabsTrigger
key={s}
value={s}
className="ml-2 h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
>
{s}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
</CardHeader>
<CardContent>
{chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="h-[250px] w-full">
{chartTab === "submission" ? (
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={12}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
domain={[0, 'auto']}
tickFormatter={(value) => `${value}`}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/>
<Line
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
</LineChart>
) : (
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={12}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="avg"
stroke="var(--color-avg)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="median"
stroke="var(--color-median)"
strokeWidth={2}
strokeDasharray="4 4"
activeDot={{ r: 6 }}
/>
</LineChart>
)}
</ChartContainer>
) : (
<div className="flex h-[250px] items-center justify-center text-sm text-muted-foreground">
No data for this subject
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,143 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
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 { updateTeacherClassAction } from "../../actions"
interface EditClassDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
classId: string
initialData: {
name: string
grade: string
homeroom?: string | null
room?: string | null
schoolName?: string | null
}
}
export function EditClassDialog({
open,
onOpenChange,
classId,
initialData,
}: EditClassDialogProps) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const handleEdit = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await updateTeacherClassAction(classId, null, formData)
if (res.success) {
toast.success(res.message)
onOpenChange(false)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
}
} catch {
toast.error("Failed to update class")
} finally {
setIsWorking(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(val) => {
if (isWorking) return
onOpenChange(val)
}}
>
<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="schoolName" className="text-right">
School
</Label>
<Input
id="schoolName"
name="schoolName"
className="col-span-3"
defaultValue={initialData.schoolName ?? ""}
placeholder="Optional"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
name="name"
className="col-span-3"
defaultValue={initialData.name}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade" className="text-right">
Grade
</Label>
<Input
id="grade"
name="grade"
className="col-span-3"
defaultValue={initialData.grade}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="homeroom" className="text-right">
Homeroom
</Label>
<Input
id="homeroom"
name="homeroom"
className="col-span-3"
defaultValue={initialData.homeroom ?? ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="room" className="text-right">
Room
</Label>
<Input
id="room"
name="room"
className="col-span-3"
defaultValue={initialData.room ?? ""}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}