feat: enhance textbook reader with anchor text support and improve knowledge point management
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
119
src/modules/classes/components/class-detail/class-header.tsx
Normal file
119
src/modules/classes/components/class-detail/class-header.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,44 +4,21 @@ import Link from "next/link"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
Calendar,
|
||||
Copy,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Copy,
|
||||
Users,
|
||||
GraduationCap,
|
||||
MapPin,
|
||||
ChartBar,
|
||||
GraduationCap,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { parseAsString, useQueryState } from "nuqs"
|
||||
|
||||
import { Card, CardContent, CardFooter, 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,
|
||||
@@ -53,15 +30,11 @@ import {
|
||||
} 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||
import type { TeacherClass } from "../types"
|
||||
import type { TeacherClass, ClassScheduleItem } from "../types"
|
||||
import {
|
||||
createTeacherClassAction,
|
||||
deleteTeacherClassAction,
|
||||
ensureClassInvitationCodeAction,
|
||||
regenerateClassInvitationCodeAction,
|
||||
updateTeacherClassAction,
|
||||
joinClassByInvitationCodeAction,
|
||||
} from "../actions"
|
||||
|
||||
@@ -82,26 +55,6 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [joinOpen, setJoinOpen] = 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 handleJoin = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
@@ -123,117 +76,98 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filter Bar */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative flex-1 md:max-w-[320px]">
|
||||
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search classes..."
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value || null)}
|
||||
className="pl-9 bg-background"
|
||||
/>
|
||||
</div>
|
||||
<Select value={grade} onValueChange={(v) => setGrade(v === "all" ? null : v)}>
|
||||
<SelectTrigger className="w-[160px] bg-background">
|
||||
<SelectValue placeholder="All Grades" />
|
||||
</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"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setQ(null)
|
||||
setGrade(null)
|
||||
}}
|
||||
title="Clear filters"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={joinOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (isWorking) return
|
||||
setJoinOpen(open)
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2 shadow-sm" disabled={isWorking}>
|
||||
<Plus className="size-4" />
|
||||
Join Class
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Join Class</DialogTitle>
|
||||
<DialogDescription>Enter the invitation code to join a class.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form action={handleJoin}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="join-code" className="text-right">
|
||||
Code
|
||||
</Label>
|
||||
<Input
|
||||
id="join-code"
|
||||
name="code"
|
||||
className="col-span-3"
|
||||
placeholder="e.g. 123456"
|
||||
required
|
||||
maxLength={6}
|
||||
pattern="\d{6}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Joining..." : "Join Class"}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end">
|
||||
<div className="relative">
|
||||
<Dialog
|
||||
open={joinOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (isWorking) return
|
||||
setJoinOpen(open)
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<div className="group relative">
|
||||
{/* Decorative Ticket Stub Effect */}
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-r from-primary/20 to-secondary/20 rounded-lg blur opacity-30 group-hover:opacity-60 transition duration-500"></div>
|
||||
<Button className="relative gap-2 h-10 px-5 shadow-sm border border-primary/10 hover:shadow-md transition-all bg-background text-foreground hover:bg-muted/50" disabled={isWorking} variant="outline">
|
||||
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary group-hover:scale-110 transition-transform duration-300">
|
||||
<Plus className="size-3.5" strokeWidth={3} />
|
||||
</div>
|
||||
<span className="font-semibold tracking-tight">Join New Class</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[480px] p-0 overflow-hidden gap-0 border-none shadow-2xl">
|
||||
{/* Header with Pattern */}
|
||||
<div className="relative bg-primary/5 p-6 border-b border-border/50">
|
||||
<div className="absolute inset-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '12px 12px' }}></div>
|
||||
<DialogHeader className="relative z-10">
|
||||
<DialogTitle className="text-xl font-bold flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
|
||||
<Plus className="size-5" />
|
||||
</div>
|
||||
Join a Class
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground mt-1.5">
|
||||
Enter the 6-digit invitation code provided by your administrator.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<form action={handleJoin} className="bg-card">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="join-code" className="text-sm font-medium">
|
||||
Invitation Code
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="join-code"
|
||||
name="code"
|
||||
className="h-12 text-center text-2xl font-mono tracking-[0.5em] font-bold uppercase placeholder:tracking-normal placeholder:font-sans placeholder:text-base placeholder:font-normal"
|
||||
placeholder="e.g. 123456"
|
||||
required
|
||||
maxLength={6}
|
||||
pattern="\d{6}"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/30 pointer-events-none">
|
||||
<Users className="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ask your administrator for the code if you don't have one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
|
||||
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isWorking} className="min-w-[100px]">
|
||||
{isWorking ? "Joining..." : "Join Class"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{/* List */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{classes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No classes yet"
|
||||
description="Join a class to start managing students and schedules."
|
||||
icon={Users}
|
||||
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
|
||||
className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
|
||||
/>
|
||||
) : filteredClasses.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No classes match your filters"
|
||||
description="Try clearing filters or adjusting keywords."
|
||||
icon={Search}
|
||||
action={{
|
||||
label: "Clear filters",
|
||||
onClick: () => {
|
||||
setQ(null)
|
||||
setGrade(null)
|
||||
},
|
||||
}}
|
||||
className="h-[360px] bg-card border-dashed sm:col-span-2 lg:col-span-3 xl:col-span-4"
|
||||
className="h-[360px] bg-card border-dashed"
|
||||
/>
|
||||
) : (
|
||||
filteredClasses.map((c) => (
|
||||
<ClassCard key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
|
||||
classes.map((c) => (
|
||||
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -241,7 +175,12 @@ export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherCla
|
||||
)
|
||||
}
|
||||
|
||||
function ClassCard({
|
||||
import { ClassScheduleGrid } from "./class-detail/class-schedule-widget"
|
||||
import { ClassTrendsWidget } from "./class-detail/class-trends-widget"
|
||||
|
||||
// Removed MiniSchedule since we're using ClassScheduleGrid now
|
||||
|
||||
function ClassTicket({
|
||||
c,
|
||||
isWorking,
|
||||
onWorkingChange,
|
||||
@@ -251,8 +190,6 @@ function ClassCard({
|
||||
onWorkingChange: (v: boolean) => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
|
||||
const handleEnsureCode = async () => {
|
||||
onWorkingChange(true)
|
||||
@@ -299,277 +236,160 @@ function ClassCard({
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Real data for chart
|
||||
const recentAssignments = c.recentAssignments ?? []
|
||||
|
||||
// Calculate performance change for indicator (still needed for the top indicator)
|
||||
// We can't reuse chart data easily here without recalculating, but ClassTrendsWidget handles its own data now
|
||||
const lastTwoAssignments = [...recentAssignments].reverse().slice(-2)
|
||||
const performanceChange = lastTwoAssignments.length === 2 && lastTwoAssignments[0].submittedCount > 0
|
||||
? ((lastTwoAssignments[1].submittedCount - lastTwoAssignments[0].submittedCount) / lastTwoAssignments[0].submittedCount) * 100
|
||||
: 0
|
||||
const isPositive = performanceChange >= 0
|
||||
|
||||
return (
|
||||
<Card className={cn("group flex flex-col transition-all hover:shadow-md", getClassGradient(c.id))}>
|
||||
<CardHeader className="relative pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="line-clamp-1 text-lg font-bold leading-none tracking-tight">
|
||||
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="hover:underline">
|
||||
<div className="group relative flex w-full overflow-hidden rounded-xl border bg-card shadow-sm transition-all hover:shadow-md">
|
||||
{/* Realistic Paper Texture & Noise */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.02]" style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg viewBox=\'0 0 200 200\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'noiseFilter\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.65\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23noiseFilter)\'/%3E%3C/svg%3E")' }}></div>
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '16px 16px' }}></div>
|
||||
|
||||
{/* Decorative Barcode Strip */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} className="w-full h-px bg-primary/20" style={{ marginBottom: Math.random() * 8 + 2 + 'px' }}></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Left Section: Basic Info (Narrower) */}
|
||||
<div className="flex w-full flex-col justify-between p-5 pl-7 sm:w-[320px] sm:flex-shrink-0 relative z-10 border-r border-dashed border-muted-foreground/20">
|
||||
{/* Punch Hole Effect Top-Left */}
|
||||
<div className="absolute -left-2 -top-2 h-6 w-6 rounded-full bg-background border border-border shadow-[inset_1px_1px_2px_rgba(0,0,0,0.1)] z-20"></div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/5 text-xl font-bold text-primary shadow-sm border border-primary/10">
|
||||
{c.grade.replace(/[^0-9]/g, '')}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="text-lg font-bold hover:underline tracking-tight line-clamp-1">
|
||||
{c.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant="secondary" className="h-5 px-1.5 font-medium">
|
||||
{c.grade}
|
||||
<Badge variant="secondary" className="w-fit font-normal text-xs bg-muted/50 font-mono tracking-tight">
|
||||
{c.grade} • {c.id.slice(-4).toUpperCase()}
|
||||
</Badge>
|
||||
{c.homeroom && (
|
||||
<Badge variant="outline" className="h-5 border-dashed bg-transparent px-1.5 font-normal">
|
||||
{c.homeroom}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 -mr-2" disabled={isWorking}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">Actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setShowEdit(true)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Edit Class
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDelete(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete Class
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 pb-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Students</span>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<Users className="size-3.5 text-muted-foreground" />
|
||||
{c.studentCount}
|
||||
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="size-4 text-muted-foreground/70" />
|
||||
<span className="font-medium text-foreground/80">{c.studentCount}</span> Students
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Room</span>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<MapPin className="size-3.5 text-muted-foreground" />
|
||||
{c.room || "—"}
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="size-4 text-muted-foreground/70" />
|
||||
<span className="font-medium text-foreground/80">{c.room || "No Room"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between rounded-md border bg-background/50 p-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] uppercase text-muted-foreground">Invite Code</span>
|
||||
<span className="font-mono text-sm font-medium tracking-wider">{c.invitationCode || "—"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{c.invitationCode ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopyCode} disabled={isWorking}>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy Code</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleRegenerateCode}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Regenerate</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleEnsureCode} disabled={isWorking}>
|
||||
Generate
|
||||
</Button>
|
||||
{c.schoolName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="size-4 text-muted-foreground/70" />
|
||||
<span className="line-clamp-1">{c.schoolName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="grid grid-cols-3 gap-2 border-t p-2">
|
||||
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
|
||||
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(c.id)}`}>
|
||||
<Users className="mr-1.5 size-3.5" />
|
||||
Students
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
|
||||
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(c.id)}`}>
|
||||
<Calendar className="mr-1.5 size-3.5" />
|
||||
Schedule
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm" className="h-8 w-full justify-center px-0 text-xs">
|
||||
<Link href={`/teacher/classes/insights?classId=${encodeURIComponent(c.id)}`}>
|
||||
<ChartBar className="mr-1.5 size-3.5" />
|
||||
Insights
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
{/* Invitation Code Section */}
|
||||
<div className="mt-6 pt-4 border-t border-dashed border-border relative">
|
||||
{/* Tiny Cut marks */}
|
||||
<div className="absolute -left-5 top-[-1px] w-2 h-[2px] bg-border"></div>
|
||||
<div className="absolute -right-5 top-[-1px] w-2 h-[2px] bg-border"></div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<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 Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider">Entry Pass</span>
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="w-0.5 h-2 bg-muted-foreground/20"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 bg-muted/30 px-3 py-1.5 rounded-sm border border-dashed border-muted-foreground/30 relative overflow-hidden">
|
||||
<span className="font-mono text-lg font-bold tracking-widest text-foreground z-10">{c.invitationCode || "—"}</span>
|
||||
|
||||
{/* Faint QR Code Placeholder Background */}
|
||||
<div className="absolute right-10 top-1/2 -translate-y-1/2 opacity-[0.03]">
|
||||
<div className="w-8 h-8 bg-current grid grid-cols-4 grid-rows-4 gap-px">
|
||||
{Array.from({ length: 16 }).map((_, i) => (
|
||||
<div key={i} className={cn("bg-transparent", Math.random() > 0.5 && "bg-black")}></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 Class"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
{c.invitationCode ? (
|
||||
<div className="flex gap-1 z-10">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleCopyCode} title="Copy">
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleRegenerateCode} title="Regenerate">
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs z-10" onClick={handleEnsureCode}>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashed Divider (Ticket perforation) */}
|
||||
<div className="relative hidden w-4 flex-col items-center justify-center sm:flex -ml-2 z-20">
|
||||
<div className="absolute -top-2 h-4 w-4 rounded-full bg-background border border-border shadow-[inset_0_-1px_1px_rgba(0,0,0,0.05)]" />
|
||||
<div className="h-full w-px border-l-2 border-dashed border-muted-foreground/20 relative">
|
||||
{/* Scissor Icon */}
|
||||
<div className="absolute top-1/2 -left-[5px] -translate-y-1/2 text-muted-foreground/20 -rotate-90 text-[10px]">✂</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-2 h-4 w-4 rounded-full bg-background border border-border shadow-[inset_0_1px_1px_rgba(0,0,0,0.05)]" />
|
||||
</div>
|
||||
|
||||
{/* Right Section: Stats & Actions (Wider) */}
|
||||
<div className="flex flex-1 flex-col bg-muted/5 p-6 relative z-10">
|
||||
<div className="flex flex-1 gap-6">
|
||||
{/* Left: Submission Trends */}
|
||||
<div className="flex-1 flex flex-col gap-4 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-foreground/80">Submission Trends</h4>
|
||||
<span className={cn(
|
||||
"text-xs font-bold px-2 py-0.5 rounded-full border flex items-center gap-1",
|
||||
isPositive
|
||||
? "text-emerald-600 bg-emerald-50 border-emerald-100"
|
||||
: "text-red-600 bg-red-50 border-red-100"
|
||||
)}>
|
||||
{isPositive ? "+" : ""}{Math.round(performanceChange)}% <span className={cn("font-normal opacity-70 hidden sm:inline")}>vs last week</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Real Chart */}
|
||||
<div className="h-[140px] w-full">
|
||||
<ClassTrendsWidget
|
||||
classId={c.id}
|
||||
assignments={recentAssignments}
|
||||
compact
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Weekly Schedule */}
|
||||
<div className="flex-1 flex flex-col gap-4 border-l border-dashed border-muted-foreground/20 pl-6 min-w-0">
|
||||
<div className="h-[170px] w-full overflow-y-auto pr-1">
|
||||
<ClassScheduleGrid schedule={c.schedule ?? []} compact />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Plus, X } from "lucide-react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -29,7 +29,7 @@ import type { TeacherClass } from "../types"
|
||||
import { createClassScheduleItemAction } from "../actions"
|
||||
|
||||
export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
|
||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all").withOptions({ shallow: false }))
|
||||
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -64,33 +64,29 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
const selectedClass = classes.find((c) => c.id === classId)
|
||||
const title = selectedClass ? selectedClass.name : "All Classes"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="relative flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder="Class" />
|
||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? "all" : val)}>
|
||||
<SelectTrigger className="h-8 w-[180px] text-xs bg-transparent border-none shadow-none hover:bg-muted/50 focus:ring-0 text-muted-foreground hover:text-foreground">
|
||||
<SelectValue placeholder="All Classes" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Classes</SelectItem>
|
||||
<SelectItem value="all" className="text-xs">All Classes</SelectItem>
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{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 className="absolute left-1/2 -translate-x-1/2 text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
@@ -101,9 +97,13 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" disabled={classes.length === 0}>
|
||||
<Plus className="size-4" />
|
||||
Add item
|
||||
<Button
|
||||
className="h-8 gap-1.5 text-xs px-3 shadow-none border-transparent bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
disabled={classes.length === 0}
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add Event
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
|
||||
@@ -151,88 +151,145 @@ export function ScheduleView({
|
||||
}
|
||||
}
|
||||
|
||||
const getPositionStyle = (startTime: string, endTime: string) => {
|
||||
// Range 8:00 (480 min) -> 18:00 (1080 min)
|
||||
// Total duration: 600 min
|
||||
const startParts = startTime.split(':').map(Number)
|
||||
const endParts = endTime.split(':').map(Number)
|
||||
|
||||
const startMinutes = startParts[0] * 60 + startParts[1]
|
||||
const endMinutes = endParts[0] * 60 + endParts[1]
|
||||
|
||||
const minTime = 8 * 60
|
||||
const maxTime = 18 * 60
|
||||
const totalDuration = maxTime - minTime
|
||||
|
||||
// Calculate percentage positions
|
||||
const top = Math.max(0, ((startMinutes - minTime) / totalDuration) * 100)
|
||||
const height = Math.min(100 - top, ((endMinutes - startMinutes) / totalDuration) * 100)
|
||||
|
||||
return {
|
||||
top: `${top}%`,
|
||||
height: `${height}%`,
|
||||
}
|
||||
}
|
||||
|
||||
const HOURS = Array.from({ length: 11 }, (_, i) => 8 + i) // 8, 9, ..., 18
|
||||
|
||||
// Predefined colors for different subjects to add visual variety
|
||||
const getSubjectColor = (subject: string) => {
|
||||
const s = subject.toLowerCase()
|
||||
if (s.includes('math')) return 'bg-blue-500/10 text-blue-700 border-blue-500/20 hover:bg-blue-500/20'
|
||||
if (s.includes('physics') || s.includes('science')) return 'bg-purple-500/10 text-purple-700 border-purple-500/20 hover:bg-purple-500/20'
|
||||
if (s.includes('english') || s.includes('lit')) return 'bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/20'
|
||||
if (s.includes('history') || s.includes('geo')) return 'bg-orange-500/10 text-orange-700 border-orange-500/20 hover:bg-orange-500/20'
|
||||
if (s.includes('art') || s.includes('music')) return 'bg-pink-500/10 text-pink-700 border-pink-500/20 hover:bg-pink-500/20'
|
||||
if (s.includes('sport') || s.includes('pe')) return 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/20'
|
||||
return 'bg-primary/10 text-primary border-primary/20 hover:bg-primary/20'
|
||||
}
|
||||
|
||||
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)
|
||||
}}
|
||||
<div className="h-[600px] flex flex-col">
|
||||
<div className="flex h-full">
|
||||
{/* Time Axis */}
|
||||
<div className="w-14 flex-shrink-0 flex flex-col">
|
||||
<div className="h-10" /> {/* Header spacer */}
|
||||
<div className="flex-1 relative">
|
||||
{HOURS.map((h, i) => (
|
||||
<div
|
||||
key={h}
|
||||
className="absolute w-full text-right pr-3 text-[11px] text-muted-foreground/60 font-medium -translate-y-1/2 font-mono"
|
||||
style={{ top: `${(i / 10) * 100}%` }}
|
||||
>
|
||||
<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>
|
||||
{h}:00
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Days Columns */}
|
||||
<div className="flex-1 grid grid-cols-5">
|
||||
{WEEKDAYS.slice(0, 5).map((d) => (
|
||||
<div key={d.key} className="flex flex-col h-full min-w-0">
|
||||
<div className="flex items-center justify-center py-2 h-10 group">
|
||||
<span className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors uppercase tracking-wider">{d.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full mx-1">
|
||||
{/* Subtle vertical guideline */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-px bg-border/30" />
|
||||
|
||||
{(byDay.get(d.key) ?? []).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group absolute w-full px-1 z-10"
|
||||
style={getPositionStyle(item.startTime, item.endTime)}
|
||||
>
|
||||
<div className={cn(
|
||||
"rounded-md p-2 text-xs text-left relative transition-all cursor-default leading-tight h-full border overflow-hidden shadow-sm hover:shadow-md flex flex-col justify-center",
|
||||
getSubjectColor(item.course)
|
||||
)}>
|
||||
<div className="flex justify-between items-start gap-1">
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
|
||||
<div className="font-bold truncate text-[11px] leading-none tracking-tight">{item.course}</div>
|
||||
<div className="opacity-80 scale-95 origin-left whitespace-nowrap tabular-nums text-[10px] font-medium leading-none font-mono">
|
||||
{item.startTime} - {item.endTime}
|
||||
</div>
|
||||
<div className="opacity-70 scale-95 origin-left truncate text-[9px] leading-none mt-0.5 font-medium">
|
||||
{classNameById.get(item.classId)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{classNameById.get(item.classId) ?? "Class"}</Badge>
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-1 right-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 hover:bg-background/20 p-0" disabled={isWorking}>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditItem(item)}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem onClick={() => setEditItem(item)} className="text-xs">
|
||||
<Pencil className="mr-2 h-3 w-3" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteItem(item)}
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
<Trash2 className="mr-2 h-3 w-3" />
|
||||
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>
|
||||
))}
|
||||
|
||||
{/* Add Button Overlay - Only visible on hover of the column */}
|
||||
<div className="absolute inset-0 opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
|
||||
<div className="absolute top-2 right-2 pointer-events-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full shadow-sm bg-background/80 backdrop-blur-sm hover:bg-primary hover:text-primary-foreground transition-all"
|
||||
disabled={classes.length === 0}
|
||||
onClick={() => {
|
||||
setCreateWeekday(d.key)
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
@@ -311,7 +368,7 @@ export function ScheduleView({
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(editItem)}
|
||||
open={!!editItem}
|
||||
onOpenChange={(v) => {
|
||||
if (isWorking) return
|
||||
if (!v) setEditItem(null)
|
||||
@@ -320,116 +377,118 @@ export function ScheduleView({
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit schedule item</DialogTitle>
|
||||
<DialogDescription>Update this schedule entry.</DialogDescription>
|
||||
<DialogDescription>Update class 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 ?? ""}
|
||||
/>
|
||||
<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>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking}>
|
||||
{isWorking ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="edit-weekday" 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}
|
||||
placeholder="e.g. Math"
|
||||
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 ?? ""}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isWorking || !editClassId}>
|
||||
{isWorking ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(deleteItem)}
|
||||
open={!!deleteItem}
|
||||
onOpenChange={(v) => {
|
||||
if (isWorking) return
|
||||
if (!v) setDeleteItem(null)
|
||||
@@ -437,22 +496,20 @@ export function ScheduleView({
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete schedule item?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteItem ? (
|
||||
<>
|
||||
This will permanently delete <span className="font-medium text-foreground">{deleteItem.course}</span>{" "}
|
||||
({deleteItem.startTime}–{deleteItem.endTime}).
|
||||
</>
|
||||
) : null}
|
||||
This will permanently delete this schedule item.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDelete}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
disabled={isWorking}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isWorking ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
@@ -461,5 +518,4 @@ export function ScheduleView({
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,18 +3,19 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, UserPlus, X } from "lucide-react"
|
||||
import { Search, UserPlus, X, ChevronDown, Check } 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"
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,26 +25,35 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
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 [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
||||
export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherClass[], defaultClassId?: string }) {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault("").withOptions({ shallow: false, throttleMs: 500 }))
|
||||
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault(defaultClassId || "all").withOptions({ shallow: false }))
|
||||
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all").withOptions({ shallow: false }))
|
||||
|
||||
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)
|
||||
const effectiveClassId = classId === "all" && defaultClassId ? defaultClassId : classId
|
||||
|
||||
const [enrollClassId, setEnrollClassId] = useState(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setEnrollClassId(defaultClassId)
|
||||
}, [open, defaultClassId])
|
||||
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
|
||||
}, [open, effectiveClassId, classes])
|
||||
|
||||
const handleEnroll = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
@@ -63,58 +73,84 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
const selectedClass = classes.find(c => c.id === classId)
|
||||
const classLabel = classId === "all" ? "All Classes" : (selectedClass?.name || "Unknown Class")
|
||||
|
||||
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
|
||||
|
||||
const hasFilters = search || classId !== "all" || status !== "all"
|
||||
|
||||
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" />
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search - Minimal */}
|
||||
<div className="relative group">
|
||||
<Search className="text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 group-hover:text-foreground transition-colors" />
|
||||
<Input
|
||||
placeholder="Search students..."
|
||||
className="pl-8"
|
||||
className="pl-8 h-8 w-[180px] text-xs bg-transparent border-transparent hover:bg-muted/50 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-input transition-all"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Class" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Classes</SelectItem>
|
||||
<div className="h-4 w-[1px] bg-border mx-1" />
|
||||
|
||||
{/* Class Filter - Compact */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
|
||||
<span className="truncate max-w-[120px]">{classLabel}</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[200px]">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Class</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setClassId("all")}
|
||||
className="text-xs flex items-center justify-between"
|
||||
>
|
||||
All Classes
|
||||
{classId === "all" && <Check className="h-3 w-3" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{classes.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
<DropdownMenuItem
|
||||
key={c.id}
|
||||
onClick={() => setClassId(c.id)}
|
||||
className="text-xs flex items-center justify-between"
|
||||
>
|
||||
<span className="truncate">{c.name}</span>
|
||||
{classId === c.id && <Check className="h-3 w-3" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(search || classId !== "all" || status !== "all") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch(null)
|
||||
setClassId(null)
|
||||
setStatus(null)
|
||||
}}
|
||||
className="h-8 px-2 lg:px-3"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Status Filter - Compact */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
|
||||
{statusLabel}
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Status</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setStatus(null)} className="text-xs flex items-center justify-between">
|
||||
All Status
|
||||
{status === "all" && <Check className="h-3 w-3" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setStatus("active")} className="text-xs flex items-center justify-between">
|
||||
Active
|
||||
{status === "active" && <Check className="h-3 w-3" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setStatus("inactive")} className="text-xs flex items-center justify-between">
|
||||
Inactive
|
||||
{status === "inactive" && <Check className="h-3 w-3" />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
@@ -125,8 +161,8 @@ export function StudentsFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" disabled={classes.length === 0}>
|
||||
<UserPlus className="size-4" />
|
||||
<Button size="sm" className="h-8 gap-1.5 text-xs px-3" disabled={classes.length === 0}>
|
||||
<UserPlus className="size-3.5" />
|
||||
Add student
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { MoreHorizontal, UserCheck, UserX, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
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 { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -27,31 +27,16 @@ import {
|
||||
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"
|
||||
|
||||
const ITEMS_PER_PAGE = 10
|
||||
|
||||
export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
const router = useRouter()
|
||||
const [workingKey, setWorkingKey] = useState<string | null>(null)
|
||||
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const totalPages = Math.ceil(students.length / ITEMS_PER_PAGE)
|
||||
const startIndex = (page - 1) * ITEMS_PER_PAGE
|
||||
const paginatedStudents = students.slice(startIndex, startIndex + ITEMS_PER_PAGE)
|
||||
|
||||
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
|
||||
const key = `${student.classId}:${student.id}:${status}`
|
||||
const key = `${student.classId}:${student.id}`
|
||||
setWorkingKey(key)
|
||||
try {
|
||||
const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status)
|
||||
@@ -59,10 +44,10 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
toast.success(res.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to update student")
|
||||
toast.error(res.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update student")
|
||||
toast.error("Failed to update status")
|
||||
} finally {
|
||||
setWorkingKey(null)
|
||||
}
|
||||
@@ -79,133 +64,112 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">All Students</CardTitle>
|
||||
<Badge variant="secondary" className="rounded-sm px-1.5 font-normal">
|
||||
{students.length} total
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
<TableHead className="pl-6 text-xs font-medium uppercase text-muted-foreground">Student</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Class</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Joined</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
||||
<TableHead className="pr-6 text-right text-xs font-medium uppercase text-muted-foreground">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedStudents.map((s) => (
|
||||
<TableRow key={`${s.classId}:${s.id}`} className={cn("h-16", s.status !== "active" && "opacity-70")}>
|
||||
<TableCell className="pl-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9 border">
|
||||
<AvatarImage src={s.image || undefined} alt={s.name} />
|
||||
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium leading-none">{s.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{s.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{s.className}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDate(s.joinedAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={s.status === "active" ? "secondary" : "outline"}
|
||||
className={cn(
|
||||
"font-medium",
|
||||
s.status === "active"
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 hover:bg-emerald-500/20"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{s.status === "active" ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 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>
|
||||
</CardContent>
|
||||
{totalPages > 1 && (
|
||||
<CardFooter className="flex items-center justify-between border-t px-6 py-4">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Showing <strong>{startIndex + 1}</strong>-
|
||||
<strong>{Math.min(startIndex + ITEMS_PER_PAGE, students.length)}</strong> of{" "}
|
||||
<strong>{students.length}</strong> students
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">
|
||||
{page} / {totalPages}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{students.map((s) => (
|
||||
<Card key={`${s.classId}:${s.id}`} className="overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-4 space-y-0 p-4 pb-2">
|
||||
<div className="relative">
|
||||
<Avatar className="h-10 w-10 border">
|
||||
<AvatarImage src={s.image || undefined} alt={s.name} />
|
||||
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className={cn(
|
||||
"absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background",
|
||||
s.status === "active" ? "bg-emerald-500" : "bg-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col overflow-hidden mr-2">
|
||||
<span className="truncate font-semibold text-sm">{s.name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{s.email}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 text-xs text-muted-foreground shrink-0">
|
||||
<span className="text-[10px] font-medium text-foreground/80">
|
||||
{s.className}
|
||||
</span>
|
||||
<span className="text-[10px]">
|
||||
{new Date(s.joinedAt).toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit"
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
{s.subjectScores && Object.keys(s.subjectScores).length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(s.subjectScores).slice(0, 4).map(([subject, score]) => (
|
||||
<div key={subject} className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1 text-xs border border-muted/50">
|
||||
<span className="font-medium text-muted-foreground/80">{subject}</span>
|
||||
{score !== null ? (
|
||||
<span className={cn(
|
||||
"font-bold",
|
||||
score >= 90 ? "text-emerald-600" :
|
||||
score >= 80 ? "text-primary" :
|
||||
score >= 60 ? "text-yellow-600" : "text-destructive"
|
||||
)}>
|
||||
{score}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(s.subjectScores).length > 4 && (
|
||||
<div className="flex items-center justify-center rounded-md bg-muted/50 px-2 py-1 text-xs text-muted-foreground font-medium border border-muted/50">
|
||||
+{Object.keys(s.subjectScores).length - 4}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[32px] rounded-md bg-muted/20 border border-dashed border-muted">
|
||||
<span className="text-xs text-muted-foreground/50 italic">No recent scores</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between border-t bg-muted/50 p-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground" asChild>
|
||||
<a href={`mailto:${s.email}`}>Email</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={workingKey !== null}>
|
||||
<MoreHorizontal className="size-4 text-muted-foreground" />
|
||||
</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>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(removeTarget)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import "server-only";
|
||||
|
||||
import { randomInt } from "node:crypto"
|
||||
import { cache } from "react"
|
||||
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, inArray, or, sql, type SQL } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
schools,
|
||||
subjects,
|
||||
exams,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
@@ -169,7 +171,35 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
||||
}))
|
||||
|
||||
list.sort(compareClassLike)
|
||||
return list
|
||||
|
||||
// Fetch recent assignments for trends and schedule
|
||||
const listWithTrends = await Promise.all(
|
||||
list.map(async (c) => {
|
||||
const [insights, schedule] = await Promise.all([
|
||||
getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 }),
|
||||
getClassSchedule({ classId: c.id, teacherId }),
|
||||
])
|
||||
|
||||
const recentAssignments = insights
|
||||
? insights.assignments.map((a) => ({
|
||||
id: a.assignmentId,
|
||||
title: a.title,
|
||||
status: a.status,
|
||||
subject: a.subject,
|
||||
isActive: a.isActive,
|
||||
isOverdue: a.isOverdue,
|
||||
dueAt: a.dueAt ? new Date(a.dueAt) : null,
|
||||
submittedCount: a.submittedCount,
|
||||
targetCount: a.targetCount,
|
||||
avgScore: a.scoreStats.avg,
|
||||
medianScore: a.scoreStats.median,
|
||||
}))
|
||||
: []
|
||||
return { ...c, recentAssignments, schedule }
|
||||
})
|
||||
)
|
||||
|
||||
return listWithTrends
|
||||
})
|
||||
|
||||
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
|
||||
@@ -752,11 +782,22 @@ export const getClassHomeworkInsights = cache(
|
||||
}
|
||||
|
||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
||||
const assignments = await db.query.homeworkAssignments.findMany({
|
||||
where: and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)),
|
||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||
limit,
|
||||
})
|
||||
const assignments = await db
|
||||
.select({
|
||||
id: homeworkAssignments.id,
|
||||
title: homeworkAssignments.title,
|
||||
status: homeworkAssignments.status,
|
||||
createdAt: homeworkAssignments.createdAt,
|
||||
dueAt: homeworkAssignments.dueAt,
|
||||
subjectId: exams.subjectId,
|
||||
subjectName: subjects.name
|
||||
})
|
||||
.from(homeworkAssignments)
|
||||
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||
.where(and(inArray(homeworkAssignments.id, assignmentIds), eq(homeworkAssignments.creatorId, teacherId)))
|
||||
.orderBy(desc(homeworkAssignments.createdAt))
|
||||
.limit(limit)
|
||||
|
||||
const usedAssignmentIds = assignments.map((a) => a.id)
|
||||
if (usedAssignmentIds.length === 0) {
|
||||
@@ -845,6 +886,7 @@ export const getClassHomeworkInsights = cache(
|
||||
assignmentId: a.id,
|
||||
title: a.title,
|
||||
status: (a.status as string) ?? "draft",
|
||||
subject: a.subjectName,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||
isActive: dueMs === null || dueMs >= nowMs,
|
||||
@@ -1694,3 +1736,104 @@ export async function deleteClassScheduleItem(scheduleId: string): Promise<void>
|
||||
|
||||
await db.delete(classSchedule).where(eq(classSchedule.id, id))
|
||||
}
|
||||
|
||||
export const getStudentsSubjectScores = cache(
|
||||
async (studentIds: string[]): Promise<Map<string, Record<string, number | null>>> => {
|
||||
if (studentIds.length === 0) return new Map()
|
||||
|
||||
// 1. Find assignments targeted at these students
|
||||
const assignmentTargets = await db
|
||||
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
|
||||
|
||||
const assignmentIds = Array.from(new Set(assignmentTargets.map(t => t.assignmentId)))
|
||||
if (assignmentIds.length === 0) return new Map()
|
||||
|
||||
// 2. Get assignment details including subject from linked exam
|
||||
const assignments = await db
|
||||
.select({
|
||||
id: homeworkAssignments.id,
|
||||
createdAt: homeworkAssignments.createdAt,
|
||||
subjectId: exams.subjectId,
|
||||
subjectName: subjects.name
|
||||
})
|
||||
.from(homeworkAssignments)
|
||||
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||
.where(and(
|
||||
inArray(homeworkAssignments.id, assignmentIds),
|
||||
eq(homeworkAssignments.status, "published")
|
||||
))
|
||||
.orderBy(desc(homeworkAssignments.createdAt))
|
||||
|
||||
// 3. Filter subjects (exclude PE, Music, Art)
|
||||
const excludeSubjects = ["体育", "音乐", "美术"]
|
||||
const subjectAssignments = new Map<string, string>() // subject -> assignmentId (latest)
|
||||
|
||||
for (const a of assignments) {
|
||||
if (!a.subjectName) continue
|
||||
if (excludeSubjects.includes(a.subjectName)) continue
|
||||
if (!subjectAssignments.has(a.subjectName)) {
|
||||
subjectAssignments.set(a.subjectName, a.id)
|
||||
}
|
||||
}
|
||||
|
||||
const targetAssignmentIds = Array.from(subjectAssignments.values())
|
||||
if (targetAssignmentIds.length === 0) return new Map()
|
||||
|
||||
// 4. Get submissions for these assignments
|
||||
const submissions = await db
|
||||
.select({
|
||||
studentId: homeworkSubmissions.studentId,
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
score: homeworkSubmissions.score,
|
||||
createdAt: homeworkSubmissions.createdAt,
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
|
||||
.orderBy(desc(homeworkSubmissions.createdAt))
|
||||
|
||||
// 5. Map back to subject scores per student
|
||||
const studentScores = new Map<string, Record<string, number | null>>()
|
||||
|
||||
// Create reverse map for assignment -> subject
|
||||
const assignmentSubjectMap = new Map<string, string>()
|
||||
for (const [subject, id] of subjectAssignments.entries()) {
|
||||
assignmentSubjectMap.set(id, subject)
|
||||
}
|
||||
|
||||
for (const s of submissions) {
|
||||
const subject = assignmentSubjectMap.get(s.assignmentId)
|
||||
if (!subject) continue
|
||||
|
||||
if (!studentScores.has(s.studentId)) {
|
||||
studentScores.set(s.studentId, {})
|
||||
}
|
||||
|
||||
const scores = studentScores.get(s.studentId)!
|
||||
// Only set if not already set (since we ordered by desc createdAt, first one is latest)
|
||||
if (scores[subject] === undefined) {
|
||||
scores[subject] = s.score
|
||||
}
|
||||
}
|
||||
|
||||
return studentScores
|
||||
}
|
||||
)
|
||||
|
||||
export const getClassStudentSubjectScoresV2 = cache(
|
||||
async (classId: string): Promise<Map<string, Record<string, number | null>>> => {
|
||||
// 1. Get student IDs in the class
|
||||
const enrollments = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(and(
|
||||
eq(classEnrollments.classId, classId),
|
||||
eq(classEnrollments.status, "active")
|
||||
))
|
||||
|
||||
const studentIds = enrollments.map(e => e.studentId)
|
||||
return getStudentsSubjectScores(studentIds)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,6 +7,22 @@ export type TeacherClass = {
|
||||
room?: string | null
|
||||
invitationCode?: string | null
|
||||
studentCount: number
|
||||
recentAssignments?: AssignmentSummary[]
|
||||
schedule?: ClassScheduleItem[]
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export type TeacherOption = {
|
||||
@@ -71,6 +87,7 @@ export type ClassStudent = {
|
||||
className: string
|
||||
status: "active" | "inactive"
|
||||
joinedAt: Date
|
||||
subjectScores?: Record<string, number | null>
|
||||
}
|
||||
|
||||
export type ClassScheduleItem = {
|
||||
@@ -135,6 +152,7 @@ export type ClassHomeworkAssignmentStats = {
|
||||
assignmentId: string
|
||||
title: string
|
||||
status: string
|
||||
subject?: string | null
|
||||
createdAt: string
|
||||
dueAt: string | null
|
||||
isActive: boolean
|
||||
@@ -149,6 +167,8 @@ export type ClassHomeworkAssignmentStats = {
|
||||
export type ClassHomeworkInsights = {
|
||||
class: {
|
||||
id: string
|
||||
schoolName?: string | null
|
||||
schoolId?: string | null
|
||||
name: string
|
||||
grade: string
|
||||
homeroom?: string | null
|
||||
|
||||
Reference in New Issue
Block a user