feat(homework,classes,course-plans): add scans, student data, take confirm, error boundaries, dialogs, hooks, calendar

homework:

- Add data-access-scans, data-access-student, data-access-utils, data-access-exam-cross

- Add excellent-submissions, homework-take-confirm-dialog, homework-take-sidebar components

classes:

- Add class-delete-dialog, class-error-boundary, class-form-dialog, class-form-utils

- Add class-list-table, class-list-toolbar, class-skeleton

- Add schedule-create-dialog, schedule-delete-dialog, schedule-edit-dialog, schedule-utils

- Add data-access-teacher and hooks directory

course-plans:

- Add course-plan-calendar, sortable-week-row, template-picker-dialog components

- Add lib directory
This commit is contained in:
SpecialX
2026-07-03 10:25:35 +08:00
parent 20023e13fd
commit dfffb61e94
82 changed files with 6100 additions and 3321 deletions

View File

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

View File

@@ -0,0 +1,34 @@
"use client"
import { useTranslations } from "next-intl"
import type { AdminClassListItem } from "../types"
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
export function ClassDeleteDialog({
open,
onOpenChange,
item,
onConfirm,
isWorking,
}: {
open: boolean
onOpenChange: (open: boolean) => void
item: AdminClassListItem | null
onConfirm: () => Promise<void>
isWorking: boolean
}) {
const t = useTranslations("classes")
return (
<ConfirmDeleteDialog
open={open}
onOpenChange={onOpenChange}
title={t("delete.title")}
description={t("delete.description", { name: item?.name || t("delete.thisClass") })}
onConfirm={onConfirm}
isWorking={isWorking}
confirmText={t("delete.confirm")}
cancelText={t("delete.cancel")}
/>
)
}

View File

@@ -1,6 +1,8 @@
"use client"
import Link from "next/link"
import { ChevronRight, FileText } from "lucide-react"
import { useTranslations } from "next-intl"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
@@ -26,20 +28,21 @@ interface ClassAssignmentsWidgetProps {
}
export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignmentsWidgetProps) {
const t = useTranslations("classes")
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>
<CardTitle className="text-base font-semibold">{t("detail.widgets.recentHomework")}</CardTitle>
<CardDescription>
{activeAssignments.length} active assignments
{t("detail.assignments.activeCount", { count: activeAssignments.length })}
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(classId)}`}>
View All
{t("detail.assignments.viewAll")}
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
@@ -51,14 +54,14 @@ export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignment
<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-sm font-medium">{t("detail.empty.noAssignments")}</p>
<p className="text-xs text-muted-foreground">
Create an assignment to get started.
{t("detail.empty.noAssignmentsDescription")}
</p>
</div>
<Button size="sm" asChild>
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
Create Homework
{t("detail.assignments.createHomework")}
</Link>
</Button>
</div>
@@ -78,11 +81,16 @@ export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignment
</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"}
{assignment.dueAt
? t("detail.assignments.due", { date: formatDate(assignment.dueAt) })
: t("detail.assignments.noDueDate")}
</span>
<span></span>
<span>
{assignment.submittedCount}/{assignment.targetCount} Submitted
{t("detail.assignments.submittedCount", {
submitted: assignment.submittedCount,
total: assignment.targetCount,
})}
</span>
</div>
</div>
@@ -95,7 +103,7 @@ export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignment
</Badge>
{typeof assignment.avgScore === "number" && (
<span className="text-xs font-medium tabular-nums">
Avg: {assignment.avgScore.toFixed(0)}%
{t("detail.assignments.avgLabel")}: {assignment.avgScore.toFixed(0)}%
</span>
)}
</div>

View File

@@ -2,6 +2,7 @@
import { useState } from "react"
import { MoreHorizontal, Pencil, Settings, Share2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
@@ -34,6 +35,7 @@ export function ClassHeader({
studentCount,
}: ClassHeaderProps) {
const [showEdit, setShowEdit] = useState(false)
const t = useTranslations("classes")
return (
<>
@@ -56,45 +58,45 @@ export function ClassHeader({
{homeroom && (
<>
<span className="text-muted-foreground/40"></span>
<span>Homeroom {homeroom}</span>
<span>{t("detail.header.homeroom", { name: homeroom })}</span>
</>
)}
{room && (
<>
<span className="text-muted-foreground/40"></span>
<span>Room {room}</span>
<span>{t("detail.header.room", { room })}</span>
</>
)}
<span className="text-muted-foreground/40"></span>
<span>{studentCount} Students</span>
<span>{t("detail.header.students", { count: studentCount })}</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
{t("detail.header.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>
<span className="sr-only">{t("detail.header.moreActions")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setShowEdit(true)}>
<Pencil className="mr-2 h-4 w-4" />
Edit details
{t("detail.header.editDetails")}
</DropdownMenuItem>
<DropdownMenuItem>
<Share2 className="mr-2 h-4 w-4" />
Invite students
{t("detail.header.inviteStudents")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Settings className="mr-2 h-4 w-4" />
Class settings
{t("detail.header.classSettings")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,5 +1,7 @@
"use client"
import { AlertCircle, BarChart3, CheckCircle2, PenTool } from "lucide-react"
import { useTranslations } from "next-intl"
import { StatCard } from "@/shared/components/ui/stat-card"
@@ -16,30 +18,31 @@ export function ClassOverviewStats({
papersToGrade,
overdueCount,
}: ClassOverviewStatsProps) {
const t = useTranslations("classes")
return (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard
title="Class Average"
title={t("detail.overview.averageScore")}
value={averageScore ? `${averageScore.toFixed(1)}%` : "-"}
description="Overall performance"
description={t("detail.overview.overallPerformance")}
icon={BarChart3}
/>
<StatCard
title="Submission Rate"
title={t("detail.overview.submissionRate")}
value={`${submissionRate.toFixed(0)}%`}
description="Average turn-in rate"
description={t("detail.overview.averageTurnInRate")}
icon={CheckCircle2}
/>
<StatCard
title="To Grade"
title={t("detail.overview.papersToGrade")}
value={papersToGrade.toString()}
description="Pending reviews"
description={t("detail.overview.pendingReviews")}
icon={PenTool}
/>
<StatCard
title="Missed Deadlines"
title={t("detail.overview.overdueCount")}
value={overdueCount.toString()}
description="Active assignments past due"
description={t("detail.overview.activeAssignmentsPastDue")}
icon={AlertCircle}
/>
</div>

View File

@@ -1,6 +1,8 @@
"use client"
import Link from "next/link"
import { Calendar, FilePlus, MessageSquare, Settings } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -10,31 +12,32 @@ interface ClassQuickActionsProps {
}
export function ClassQuickActions({ classId }: ClassQuickActionsProps) {
const t = useTranslations("classes")
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Quick Actions</CardTitle>
<CardTitle className="text-base font-semibold">{t("detail.widgets.quickActions")}</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
{t("detail.assignments.createHomework")}
</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
{t("detail.quickActions.manageSchedule")}
</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)
{t("detail.quickActions.messageClassComingSoon")}
</Button>
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
<Settings className="mr-2 h-4 w-4" />
Class Settings
{t("detail.header.classSettings")}
</Button>
</CardContent>
</Card>

View File

@@ -1,6 +1,8 @@
"use client"
import Link from "next/link"
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -12,17 +14,28 @@ interface ClassScheduleWidgetProps {
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 }) {
const t = useTranslations("classes")
const weekdayLabels = [
t("schedule.weekday.1"),
t("schedule.weekday.2"),
t("schedule.weekday.3"),
t("schedule.weekday.4"),
t("schedule.weekday.5"),
]
// 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[]>)
// P2-A: 使用 reduce<T> 泛型参数替代 `{} as Record<...>` 反模式
const groupedSchedule = schedule.reduce<Record<number, ClassScheduleItem[]>>(
(acc, item) => {
const day = item.weekday
if (!acc[day]) acc[day] = []
acc[day].push(item)
return acc
},
{}
)
// Sort items within each day by start time
Object.keys(groupedSchedule).forEach(key => {
@@ -35,15 +48,15 @@ export function ClassScheduleGrid({ schedule, compact = false }: { schedule: Cla
<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>
<p className="text-sm text-muted-foreground">{t("detail.empty.noScheduleDescription")}</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) => (
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
{weekdayLabels.map((day, idx) => (
<div key={WEEKDAY_INDICES[idx]} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
{day}
</div>
))}
@@ -89,13 +102,14 @@ export function ClassScheduleGrid({ schedule, compact = false }: { schedule: Cla
}
export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetProps) {
const t = useTranslations("classes")
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>
<CardTitle className="text-base font-semibold">{t("detail.widgets.weeklySchedule")}</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
Manage
{t("detail.schedule.manage")}
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
@@ -103,7 +117,7 @@ export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetPr
<CardContent className="pt-4">
<ClassScheduleGrid schedule={schedule} />
<div className="mt-2 text-[10px] text-muted-foreground text-center">
* Showing Mon-Fri schedule
* {t("detail.schedule.showingWeekdays")}
</div>
</CardContent>
</Card>

View File

@@ -1,6 +1,8 @@
"use client"
import Link from "next/link"
import { ChevronRight, Users } from "lucide-react"
import { useTranslations } from "next-intl"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Badge } from "@/shared/components/ui/badge"
@@ -22,20 +24,21 @@ interface ClassStudentsWidgetProps {
}
export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetProps) {
const t = useTranslations("classes")
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>
<CardTitle className="text-base font-semibold">{t("detail.widgets.studentList")}</CardTitle>
<CardDescription>
{activeCount} active students
{t("detail.students.activeCount", { count: activeCount })}
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(classId)}`}>
View All
{t("detail.students.viewAll")}
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
@@ -46,7 +49,7 @@ export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetPr
<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>
<p className="text-sm text-muted-foreground">{t("students.empty.description")}</p>
</div>
) : (
<div className="space-y-4">

View File

@@ -3,6 +3,7 @@
import { useState } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChevronDown } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
@@ -35,25 +36,6 @@ interface ClassTrendsWidgetProps {
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,
@@ -71,13 +53,20 @@ export function transformAssignmentsToChartData(assignments: AssignmentSummary[]
return data
}
export function ClassSubmissionTrendChart({
data,
className
}: {
export function ClassSubmissionTrendChart({
data,
className
}: {
data: Record<string, string | number>[]
className?: string
}) {
const t = useTranslations("classes")
const chartConfig = {
submitted: { label: t("detail.trends.submitted"), color: "hsl(var(--primary))" },
target: { label: t("detail.trends.totalStudents"), color: "hsl(var(--muted-foreground))" },
avg: { label: t("detail.trends.averageScore"), color: "hsl(var(--chart-2))" },
median: { label: t("detail.trends.medianScore"), color: "hsl(var(--chart-4))" },
} satisfies ChartConfig
return (
<ChartContainer config={chartConfig} className={className}>
<LineChart accessibilityLayer data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
@@ -120,11 +109,21 @@ export function ClassSubmissionTrendChart({
}
export function ClassTrendsWidget({ assignments, compact, className }: ClassTrendsWidgetProps) {
const t = useTranslations("classes")
const chartConfig = {
submitted: { label: t("detail.trends.submitted"), color: "hsl(var(--primary))" },
target: { label: t("detail.trends.totalStudents"), color: "hsl(var(--muted-foreground))" },
avg: { label: t("detail.trends.averageScore"), color: "hsl(var(--chart-2))" },
median: { label: t("detail.trends.medianScore"), color: "hsl(var(--chart-4))" },
} satisfies ChartConfig
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[]
// P2-A: 使用类型守卫替代 `as string[]`filter(Boolean) 不会收窄类型是 TS 已知陷阱)
const subjects = Array.from(new Set(
assignments.map((a) => a.subject).filter((s): s is string => typeof s === "string")
))
const activeAssignments = assignments.filter((a) => {
if (selectedSubject !== "all" && a.subject !== selectedSubject) return false
@@ -140,7 +139,7 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
const lastAssignment = chartData[chartData.length - 1]
let metricValue = "0%"
const metricLabel = "Latest"
const metricLabel = t("detail.trends.latest")
if (lastAssignment) {
if (chartTab === "submission") {
@@ -159,16 +158,16 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
<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"}
{chartTab === "submission" ? t("detail.trends.submission") : t("detail.trends.score")}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setChartTab("submission")} className="text-xs">
Submission Trends
{t("detail.widgets.submissionTrends")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setChartTab("score")} className="text-xs">
Score Trends
{t("detail.trends.scoreTrends")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -177,13 +176,13 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
<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}
{selectedSubject === "all" ? t("detail.trends.allSubjects") : selectedSubject}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setSelectedSubject("all")} className="text-xs">
All Subjects
{t("detail.trends.allSubjects")}
</DropdownMenuItem>
{subjects.map(s => (
<DropdownMenuItem key={s} onClick={() => setSelectedSubject(s)} className="text-xs">
@@ -275,28 +274,28 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">
{chartTab === "submission" ? "Submission Trends" : "Score Trends"}
{chartTab === "submission" ? t("detail.widgets.submissionTrends") : t("detail.trends.scoreTrends")}
</CardTitle>
<CardDescription>
{chartTab === "submission" ? "Recent assignment turn-in rates" : "Average vs Median performance"}
{chartTab === "submission" ? t("detail.trends.recentTurnInRates") : t("detail.trends.avgVsMedian")}
</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>
<TabsTrigger value="submission" className="text-xs">{t("detail.trends.submission")}</TabsTrigger>
<TabsTrigger value="score" className="text-xs">{t("detail.trends.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"
<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
{t("detail.trends.allSubjects")}
</TabsTrigger>
{subjects.map(s => (
<TabsTrigger
@@ -387,7 +386,7 @@ export function ClassTrendsWidget({ assignments, compact, className }: ClassTren
</ChartContainer>
) : (
<div className="flex h-[250px] items-center justify-center text-sm text-muted-foreground">
No data for this subject
{t("detail.trends.noDataForSubject")}
</div>
)}
</CardContent>

View File

@@ -3,6 +3,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import {
@@ -36,10 +37,11 @@ export function EditClassDialog({
classId,
initialData,
}: EditClassDialogProps) {
const t = useTranslations("classes")
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const handleEdit = async (formData: FormData) => {
const handleEdit = async (formData: FormData): Promise<void> => {
setIsWorking(true)
try {
const res = await updateTeacherClassAction(classId, null, formData)
@@ -48,10 +50,10 @@ export function EditClassDialog({
onOpenChange(false)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
toast.error(res.message || t("list.failedUpdate"))
}
} catch {
toast.error("Failed to update class")
toast.error(t("list.failedUpdate"))
} finally {
setIsWorking(false)
}
@@ -67,26 +69,26 @@ export function EditClassDialog({
>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Edit class</DialogTitle>
<DialogDescription>Update basic class information.</DialogDescription>
<DialogTitle>{t("detail.edit.title")}</DialogTitle>
<DialogDescription>{t("detail.edit.title")}</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
{t("list.column.school")}
</Label>
<Input
id="schoolName"
name="schoolName"
className="col-span-3"
defaultValue={initialData.schoolName ?? ""}
placeholder="Optional"
placeholder={t("form.optional")}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
{t("detail.edit.name")}
</Label>
<Input
id="name"
@@ -98,7 +100,7 @@ export function EditClassDialog({
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade" className="text-right">
Grade
{t("class.grade")}
</Label>
<Input
id="grade"
@@ -110,7 +112,7 @@ export function EditClassDialog({
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="homeroom" className="text-right">
Homeroom
{t("detail.edit.homeroom")}
</Label>
<Input
id="homeroom"
@@ -121,7 +123,7 @@ export function EditClassDialog({
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="room" className="text-right">
Room
{t("detail.edit.room")}
</Label>
<Input
id="room"
@@ -133,7 +135,7 @@ export function EditClassDialog({
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save Changes"}
{isWorking ? t("form.saving") : t("form.saveChanges")}
</Button>
</DialogFooter>
</form>

View File

@@ -0,0 +1,57 @@
"use client"
/**
* 班级模块 Error Boundary。
*
* 薄包装:委托给共享 SectionErrorBoundary通过自定义 fallback 实现
* 重试时调用 router.refresh() 刷新服务端数据。
* 保留同名导出以兼容现有 import。
*/
import type { ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
interface ClassErrorBoundaryProps {
children: ReactNode
fallback?: ReactNode
}
export function ClassErrorBoundary({
children,
fallback,
}: ClassErrorBoundaryProps): ReactNode {
const t = useTranslations("classes")
const router = useRouter()
const customFallback = (_error: Error, reset: () => void): ReactNode => {
const handleRetry = (): void => {
reset()
router.refresh()
}
return (
<div
role="alert"
className="flex min-h-[400px] flex-col items-center justify-center rounded-md border border-dashed p-8 text-center"
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
</div>
<h3 className="mt-4 text-lg font-semibold">{t("errors.boundary.title")}</h3>
<p className="mb-4 mt-2 max-w-md text-sm text-muted-foreground">
{t("errors.boundary.description")}
</p>
<Button onClick={handleRetry}>{t("errors.boundary.retry")}</Button>
</div>
)
}
return (
<SectionErrorBoundary fallback={fallback ? () => fallback : customFallback}>
{children}
</SectionErrorBoundary>
)
}

View File

@@ -0,0 +1,232 @@
"use client"
import { useTranslations } from "next-intl"
import type { AdminClassListItem, TeacherOption } from "../types"
import { DEFAULT_CLASS_SUBJECTS } from "../types"
import type { ClassFormGrade, SubjectTeacherState } from "./class-form-utils"
import { Button } from "@/shared/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
export function ClassFormDialog({
open,
onOpenChange,
mode,
editItem,
teachers,
schools,
grades,
onSubmit,
isWorking,
teacherId,
schoolId,
gradeId,
subjectTeachers,
onTeacherIdChange,
onSchoolIdChange,
onGradeIdChange,
onSubjectTeacherChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
mode: "create" | "edit"
editItem?: AdminClassListItem | null
teachers: TeacherOption[]
schools?: { id: string; name: string }[]
grades: ClassFormGrade[]
onSubmit: (formData: FormData) => Promise<void>
isWorking: boolean
teacherId: string
schoolId: string
gradeId: string
subjectTeachers?: SubjectTeacherState[]
onTeacherIdChange: (id: string) => void
onSchoolIdChange: (id: string) => void
onGradeIdChange: (id: string) => void
onSubjectTeacherChange?: (subject: string, teacherId: string | null) => void
}) {
const t = useTranslations("classes")
const isEdit = mode === "edit"
const selectedSchool = schools?.find((s) => s.id === schoolId)
const selectedGrade = grades.find((g) => g.id === gradeId)
// admin 模式学校来自学校选择grade 模式:学校来自所选年级
const hiddenSchoolId = schools ? schoolId : (selectedGrade?.schoolId ?? "")
const hiddenSchoolName = schools ? (selectedSchool?.name ?? "") : (selectedGrade?.schoolName ?? "")
const showForm = mode === "create" || Boolean(editItem)
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (isWorking) return
onOpenChange(o)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{isEdit ? t("form.editTitle") : t("form.createTitle")}</DialogTitle>
</DialogHeader>
{showForm ? (
<form action={onSubmit} className="space-y-4">
{schools ? (
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("form.school")}</Label>
<div className="col-span-3">
<Select
value={schoolId}
onValueChange={(v) => {
onSchoolIdChange(v)
onGradeIdChange("")
}}
disabled={schools.length === 0}
>
<SelectTrigger>
<SelectValue placeholder={schools.length === 0 ? t("form.noSchools") : t("form.selectSchool")} />
</SelectTrigger>
<SelectContent>
{schools.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : null}
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("form.grade")}</Label>
<div className="col-span-3">
<Select value={gradeId} onValueChange={onGradeIdChange} disabled={grades.length === 0}>
<SelectTrigger>
<SelectValue placeholder={grades.length === 0 ? t("form.noGrades") : t("form.selectGrade")} />
</SelectTrigger>
<SelectContent>
{grades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{schools ? g.name : `${g.name} (${g.schoolName ?? ""})`}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="gradeId" value={gradeId} />
<input type="hidden" name="grade" value={selectedGrade?.name ?? ""} />
<input type="hidden" name="schoolId" value={hiddenSchoolId} />
<input type="hidden" name="schoolName" value={hiddenSchoolName} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="class-name" className="text-right">
{t("form.name")}
</Label>
<Input
id="class-name"
name="name"
className="col-span-3"
placeholder={t("form.namePlaceholder")}
defaultValue={isEdit ? editItem?.name : undefined}
autoFocus={!isEdit}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="class-homeroom" className="text-right">
{t("form.homeroomLabel")}
</Label>
<Input
id="class-homeroom"
name="homeroom"
className="col-span-3"
placeholder={t("form.optional")}
defaultValue={isEdit ? editItem?.homeroom ?? "" : undefined}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="class-room" className="text-right">
{t("form.room")}
</Label>
<Input
id="class-room"
name="room"
className="col-span-3"
placeholder={t("form.optional")}
defaultValue={isEdit ? editItem?.room ?? "" : undefined}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("form.homeroom")}</Label>
<div className="col-span-3">
<Select value={teacherId} onValueChange={onTeacherIdChange} disabled={teachers.length === 0}>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? t("form.noTeachers") : t("form.selectTeacher")} />
</SelectTrigger>
<SelectContent>
{teachers.map((teacher) => (
<SelectItem key={teacher.id} value={teacher.id}>
{teacher.name} ({teacher.email})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={teacherId} />
</div>
</div>
{isEdit && subjectTeachers && onSubjectTeacherChange ? (
<div className="space-y-3 rounded-md border p-4">
<div className="text-sm font-medium">{t("form.subjectTeachers")}</div>
<div className="grid gap-3">
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
const selected = subjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
return (
<div key={subject} className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{subject}</Label>
<div className="col-span-3">
<Select
value={selected ?? ""}
onValueChange={(v) => onSubjectTeacherChange(subject, v ? v : null)}
disabled={teachers.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={teachers.length === 0 ? t("form.noTeachers") : t("form.selectTeacher")}
/>
</SelectTrigger>
<SelectContent>
{teachers.map((teacher) => (
<SelectItem key={teacher.id} value={teacher.id}>
{teacher.name} ({teacher.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
})}
</div>
<input type="hidden" name="subjectTeachers" value={JSON.stringify(subjectTeachers)} />
</div>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isWorking}>
{t("form.cancel")}
</Button>
<Button type="submit" disabled={isWorking || teachers.length === 0 || !teacherId || !gradeId}>
{isEdit ? t("form.save") : t("form.create")}
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,35 @@
import type { ClassSubjectTeacherAssignment } from "../types"
/**
* 表单使用的年级选项(统一 admin / grade 两种来源的年级形状)。
* - admin 模式:由 `{ id, name, school: { id, name } }` 转换而来
* - grade 模式managedGrades 已具备 `{ id, name, schoolId, schoolName }`
*/
export type ClassFormGrade = {
id: string
name: string
schoolId?: string
schoolName?: string | null
}
/**
* 编辑弹窗中科目-教师分配的表单状态。
*/
export type SubjectTeacherState = {
subject: string
teacherId: string | null
}
export type { ClassSubjectTeacherAssignment }
/**
* 格式化班级任课教师列表为可读字符串。
* 统一使用中文标点(冒号无空格、中文逗号分隔),与 K12 中文界面保持一致。
*/
export function formatSubjectTeachers(list: ClassSubjectTeacherAssignment[]): string {
const pairs = list
.filter((x) => x.teacher)
.map((x) => `${x.subject}:${x.teacher?.name ?? ""}`)
.filter((x) => x.length > 0)
return pairs.length > 0 ? pairs.join("") : "-"
}

View File

@@ -69,7 +69,7 @@ export function ClassInvitationManager({
const [revokeTarget, setRevokeTarget] = React.useState<InvitationCodeRecord | null>(null)
const [isSubmitting, setIsSubmitting] = React.useState(false)
const handleCopy = async (code: string) => {
const handleCopy = async (code: string): Promise<void> => {
try {
await navigator.clipboard.writeText(code)
toast.success(t("copied"))
@@ -78,7 +78,7 @@ export function ClassInvitationManager({
}
}
const handleRevoke = async () => {
const handleRevoke = async (): Promise<void> => {
if (!revokeTarget) return
setIsSubmitting(true)
try {
@@ -231,7 +231,7 @@ function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogP
const [note, setNote] = React.useState("")
const [isSubmitting, setIsSubmitting] = React.useState(false)
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
setIsSubmitting(true)
try {

View File

@@ -0,0 +1,106 @@
"use client"
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
import { useTranslations } from "next-intl"
import type { AdminClassListItem } from "../types"
import { formatSubjectTeachers } from "./class-form-utils"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
export function ClassListTable({
classes,
onEdit,
onDelete,
isWorking,
emptyDescription,
}: {
classes: AdminClassListItem[]
onEdit: (item: AdminClassListItem) => void
onDelete: (item: AdminClassListItem) => void
isWorking: boolean
emptyDescription?: string
}) {
const t = useTranslations("classes")
return (
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("list.title")}</CardTitle>
</CardHeader>
<CardContent>
{classes.length === 0 ? (
<EmptyState
title={t("list.empty.title")}
description={emptyDescription ?? t("list.empty.description")}
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("list.column.school")}</TableHead>
<TableHead>{t("list.column.name")}</TableHead>
<TableHead>{t("list.column.grade")}</TableHead>
<TableHead>{t("list.column.homeroomLabel")}</TableHead>
<TableHead>{t("list.column.room")}</TableHead>
<TableHead>{t("list.column.homeroom")}</TableHead>
<TableHead>{t("list.column.subjectTeachers")}</TableHead>
<TableHead className="text-right">{t("list.column.studentCount")}</TableHead>
<TableHead>{t("list.column.updated")}</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{classes.map((c) => (
<TableRow key={c.id}>
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(c)}>
<Pencil className="mr-2 h-4 w-4" />
{t("list.actions.edit")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(c)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("list.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,32 @@
"use client"
import { Plus } from "lucide-react"
import { useTranslations } from "next-intl"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
export function ClassListToolbar({
count,
onNew,
isWorking,
disabled = false,
}: {
count: number
onNew: () => void
isWorking: boolean
disabled?: boolean
}) {
const t = useTranslations("classes")
return (
<div className="flex items-center justify-between">
<Badge variant="secondary" className="tabular-nums">
{count}
</Badge>
<Button onClick={onNew} disabled={isWorking || disabled}>
<Plus className="mr-2 h-4 w-4" />
{t("list.new")}
</Button>
</div>
)
}

View File

@@ -0,0 +1,120 @@
import type { JSX } from "react"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
interface ClassListSkeletonProps {
rows?: number
}
export function ClassListSkeleton({ rows = 5 }: ClassListSkeletonProps): JSX.Element {
return (
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-10 rounded-full" />
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Skeleton className="h-4 w-20" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-16" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-24" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-20" />
</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, index) => (
<TableRow key={index}>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell />
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
export function ClassCardSkeleton(): JSX.Element {
return (
<Card className="shadow-none">
<CardHeader>
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
)
}
export function ClassGridSkeleton({ count = 6 }: { count?: number }): JSX.Element {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: count }).map((_, index) => (
<ClassCardSkeleton key={index} />
))}
</div>
)
}
export function StudentListSkeleton({ rows = 8 }: { rows?: number }): JSX.Element {
return (
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: rows }).map((_, index) => (
<Skeleton key={index} className="h-10 w-full" />
))}
</div>
</div>
)
}
export function ScheduleGridSkeleton({ count = 6 }: { count?: number }): JSX.Element {
return (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: count }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
<div className="mt-6 space-y-3">
<Skeleton className="h-4 w-[70%]" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[60%]" />
</div>
</div>
))}
</div>
)
}

View File

@@ -1,31 +1,17 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { useMemo } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import type { AdminClassListItem, ClassSubjectTeacherAssignment, TeacherOption } from "../types"
import { DEFAULT_CLASS_SUBJECTS } from "../types"
import type { AdminClassListItem, TeacherOption } from "../types"
import { createGradeClassAction, deleteGradeClassAction, updateGradeClassAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { formatDate } from "@/shared/lib/utils"
import { useClassData } from "../hooks/use-class-data"
import { ClassDeleteDialog } from "./class-delete-dialog"
import { ClassFormDialog } from "./class-form-dialog"
import { ClassListTable } from "./class-list-table"
import { ClassListToolbar } from "./class-list-toolbar"
export function GradeClassesClient({
classes,
@@ -36,402 +22,131 @@ export function GradeClassesClient({
teachers: TeacherOption[]
managedGrades: { id: string; name: string; schoolId: string; schoolName: string | null }[]
}) {
const t = useTranslations("classes")
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<AdminClassListItem | null>(null)
const [deleteItem, setDeleteItem] = useState<AdminClassListItem | null>(null)
const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers])
const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId)
const [createGradeId, setCreateGradeId] = useState(managedGrades[0]?.id ?? "")
const defaultGradeId = useMemo(() => managedGrades[0]?.id ?? "", [managedGrades])
const data = useClassData({ defaultTeacherId, defaultGradeId })
const [editTeacherId, setEditTeacherId] = useState("")
const [editGradeId, setEditGradeId] = useState("")
const [editSubjectTeachers, setEditSubjectTeachers] = useState<Array<{ subject: string; teacherId: string | null }>>([])
useEffect(() => {
if (!createOpen) return
setCreateTeacherId(defaultTeacherId)
setCreateGradeId(managedGrades[0]?.id ?? "")
}, [createOpen, defaultTeacherId, managedGrades])
useEffect(() => {
if (!editItem) return
setEditTeacherId(editItem.teacher.id)
setEditGradeId(editItem.gradeId ?? managedGrades[0]?.id ?? "")
setEditSubjectTeachers(
DEFAULT_CLASS_SUBJECTS.map((s) => ({
subject: s,
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
}))
)
}, [editItem, managedGrades])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
const handleCreate = async (formData: FormData): Promise<void> => {
data.setIsWorking(true)
try {
const res = await createGradeClassAction(undefined, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
data.setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create class")
toast.error(res.message || t("list.failedCreate"))
}
} catch {
toast.error("Failed to create class")
toast.error(t("list.failedCreate"))
} finally {
setIsWorking(false)
data.setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
const handleUpdate = async (formData: FormData): Promise<void> => {
if (!data.editItem) return
data.setIsWorking(true)
try {
const res = await updateGradeClassAction(editItem.id, undefined, formData)
const res = await updateGradeClassAction(data.editItem.id, undefined, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
data.setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
toast.error(res.message || t("list.failedUpdate"))
}
} catch {
toast.error("Failed to update class")
toast.error(t("list.failedUpdate"))
} finally {
setIsWorking(false)
data.setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
const handleDelete = async (): Promise<void> => {
if (!data.deleteItem) return
data.setIsWorking(true)
try {
const res = await deleteGradeClassAction(deleteItem.id)
const res = await deleteGradeClassAction(data.deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
data.setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete class")
toast.error(res.message || t("list.failedDelete"))
}
} catch {
toast.error("Failed to delete class")
toast.error(t("list.failedDelete"))
} finally {
setIsWorking(false)
data.setIsWorking(false)
}
}
const setSubjectTeacher = (subject: string, teacherId: string | null) => {
setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p)))
}
const formatSubjectTeachers = (list: ClassSubjectTeacherAssignment[]) => {
const pairs = list
.filter((x) => x.teacher)
.map((x) => `${x.subject}: ${x.teacher?.name ?? ""}`)
.filter((x) => x.length > 0)
return pairs.length > 0 ? pairs.join(", ") : "-"
}
const selectedCreateGrade = managedGrades.find(g => g.id === createGradeId)
const selectedEditGrade = managedGrades.find(g => g.id === editGradeId)
return (
<>
<div className="flex justify-end">
<Button onClick={() => setCreateOpen(true)} disabled={isWorking || managedGrades.length === 0}>
<Plus className="mr-2 h-4 w-4" />
New class
</Button>
</div>
<ClassListToolbar
count={classes.length}
onNew={() => data.setCreateOpen(true)}
isWorking={data.isWorking}
disabled={managedGrades.length === 0}
/>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">All classes</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{classes.length}
</Badge>
</CardHeader>
<CardContent>
{classes.length === 0 ? (
<EmptyState
title="No classes"
description={managedGrades.length === 0 ? "You are not managing any grades yet." : "Create classes to manage students and schedules."}
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>School</TableHead>
<TableHead>Name</TableHead>
<TableHead>Grade</TableHead>
<TableHead>Homeroom</TableHead>
<TableHead>Room</TableHead>
<TableHead>Homeroom Teacher</TableHead>
<TableHead>Subject Teachers</TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{classes.map((c) => (
<TableRow key={c.id}>
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditItem(c)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(c)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<ClassListTable
classes={classes}
onEdit={data.setEditItem}
onDelete={data.setDeleteItem}
isWorking={data.isWorking}
emptyDescription={managedGrades.length === 0 ? t("grade.noManagedGradesDescription") : undefined}
/>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>New class</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Grade</Label>
<div className="col-span-3">
<Select value={createGradeId} onValueChange={setCreateGradeId} disabled={managedGrades.length === 0}>
<SelectTrigger>
<SelectValue placeholder={managedGrades.length === 0 ? "No managed grades" : "Select a grade"} />
</SelectTrigger>
<SelectContent>
{managedGrades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name} ({g.schoolName})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="gradeId" value={createGradeId} />
<input type="hidden" name="grade" value={selectedCreateGrade?.name ?? ""} />
<input type="hidden" name="schoolId" value={selectedCreateGrade?.schoolId ?? ""} />
<input type="hidden" name="schoolName" value={selectedCreateGrade?.schoolName ?? ""} />
</div>
</div>
<ClassFormDialog
open={data.createOpen}
onOpenChange={data.setCreateOpen}
mode="create"
teachers={teachers}
grades={managedGrades}
onSubmit={handleCreate}
isWorking={data.isWorking}
teacherId={data.createTeacherId}
schoolId={data.createSchoolId}
gradeId={data.createGradeId}
onTeacherIdChange={data.setCreateTeacherId}
onSchoolIdChange={data.setCreateSchoolId}
onGradeIdChange={data.setCreateGradeId}
/>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-name" className="text-right">
Name
</Label>
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Class 3" autoFocus />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-homeroom" className="text-right">
Homeroom
</Label>
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-room" className="text-right">
Room
</Label>
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Homeroom Teacher</Label>
<div className="col-span-3">
<Select value={createTeacherId} onValueChange={setCreateTeacherId} disabled={teachers.length === 0}>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={createTeacherId} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking || teachers.length === 0 || !createTeacherId || !createGradeId}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(editItem)}
onOpenChange={(open) => {
if (isWorking) return
if (!open) setEditItem(null)
<ClassFormDialog
open={Boolean(data.editItem)}
onOpenChange={(o) => {
if (!o) data.setEditItem(null)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Edit class</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Grade</Label>
<div className="col-span-3">
<Select value={editGradeId} onValueChange={setEditGradeId} disabled={managedGrades.length === 0}>
<SelectTrigger>
<SelectValue placeholder="Select a grade" />
</SelectTrigger>
<SelectContent>
{managedGrades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name} ({g.schoolName})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="gradeId" value={editGradeId} />
<input type="hidden" name="grade" value={selectedEditGrade?.name ?? ""} />
<input type="hidden" name="schoolId" value={selectedEditGrade?.schoolId ?? ""} />
<input type="hidden" name="schoolName" value={selectedEditGrade?.schoolName ?? ""} />
</div>
</div>
mode="edit"
editItem={data.editItem}
teachers={teachers}
grades={managedGrades}
onSubmit={handleUpdate}
isWorking={data.isWorking}
teacherId={data.editTeacherId}
schoolId={data.editSchoolId}
gradeId={data.editGradeId}
subjectTeachers={data.editSubjectTeachers}
onTeacherIdChange={data.setEditTeacherId}
onSchoolIdChange={data.setEditSchoolId}
onGradeIdChange={data.setEditGradeId}
onSubjectTeacherChange={data.setSubjectTeacher}
/>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-name" className="text-right">
Name
</Label>
<Input id="edit-name" name="name" className="col-span-3" defaultValue={editItem.name} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-homeroom" className="text-right">
Homeroom
</Label>
<Input id="edit-homeroom" name="homeroom" className="col-span-3" defaultValue={editItem.homeroom ?? ""} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-room" className="text-right">
Room
</Label>
<Input id="edit-room" name="room" className="col-span-3" defaultValue={editItem.room ?? ""} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Homeroom Teacher</Label>
<div className="col-span-3">
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={editTeacherId} />
</div>
</div>
<div className="space-y-3 rounded-md border p-4">
<div className="text-sm font-medium">Subject Teachers</div>
<div className="grid gap-3">
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
return (
<div key={subject} className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{subject}</Label>
<div className="col-span-3">
<Select
value={selected ?? ""}
onValueChange={(v) => setSubjectTeacher(subject, v ? v : null)}
disabled={teachers.length === 0}
>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
})}
</div>
<input type="hidden" name="subjectTeachers" value={JSON.stringify(editSubjectTeachers)} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking || !editTeacherId}>
Save
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<ConfirmDeleteDialog
open={Boolean(deleteItem)}
onOpenChange={(open) => {
if (!open) setDeleteItem(null)
<ClassDeleteDialog
open={Boolean(data.deleteItem)}
onOpenChange={(o) => {
if (!o) data.setDeleteItem(null)
}}
title="Delete class"
description={`This will permanently delete ${deleteItem?.name || "this class"}.`}
item={data.deleteItem}
onConfirm={handleDelete}
isWorking={isWorking}
isWorking={data.isWorking}
/>
</>
)
}
}

View File

@@ -3,6 +3,7 @@
import Link from "next/link"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import {
Plus,
RefreshCw,
@@ -54,24 +55,25 @@ export function MyClassesGrid({
subjectOptions: string[]
}) {
const router = useRouter()
const t = useTranslations("classes")
const [isWorking, setIsWorking] = useState(false)
const [joinOpen, setJoinOpen] = useState(false)
const [joinSubject, setJoinSubject] = useState("")
const handleJoin = async (formData: FormData) => {
const handleJoin = async (formData: FormData): Promise<void> => {
setIsWorking(true)
try {
const res = await joinClassByInvitationCodeAction(null, formData)
if (res.success) {
toast.success(res.message || "Joined class successfully")
toast.success(res.message || t("invitation.joinSuccess"))
setJoinOpen(false)
setJoinSubject("")
router.refresh()
} else {
toast.error(res.message || "Failed to join class")
toast.error(res.message || t("invitation.joinFailed"))
}
} catch {
toast.error("Failed to join class")
toast.error(t("invitation.joinFailed"))
} finally {
setIsWorking(false)
}
@@ -98,7 +100,7 @@ export function MyClassesGrid({
<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>
<span className="font-semibold tracking-tight">{t("myClasses.joinNew")}</span>
</Button>
</div>
</DialogTrigger>
@@ -111,10 +113,10 @@ export function MyClassesGrid({
<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
{t("myClasses.joinTitle")}
</DialogTitle>
<DialogDescription className="text-muted-foreground mt-1.5">
Enter the 6-digit invitation code provided by your administrator.
{t("myClasses.joinDescription")}
</DialogDescription>
</DialogHeader>
</div>
@@ -123,14 +125,14 @@ export function MyClassesGrid({
<div className="p-6 space-y-6">
<div className="space-y-3">
<Label htmlFor="join-code" className="text-sm font-medium">
Invitation Code
{t("myClasses.invitationCode")}
</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"
placeholder={t("myClasses.invitationCodePlaceholder")}
required
maxLength={6}
pattern="\d{6}"
@@ -142,16 +144,16 @@ export function MyClassesGrid({
</div>
</div>
<p className="text-xs text-muted-foreground">
Ask your administrator for the code if you don&apos;t have one.
{t("myClasses.invitationCodeHint")}
</p>
</div>
<div className="space-y-3">
<Label htmlFor="join-subject" className="text-sm font-medium">
{t("myClasses.subject")}
</Label>
<Select value={joinSubject} onValueChange={(v) => setJoinSubject(v)}>
<SelectTrigger id="join-subject" className="h-12">
<SelectValue placeholder={subjectOptions.length === 0 ? "暂无可选科目" : "选择教学科目"} />
<SelectValue placeholder={subjectOptions.length === 0 ? t("myClasses.noSubjects") : t("myClasses.selectSubject")} />
</SelectTrigger>
<SelectContent>
{subjectOptions.map((subject) => (
@@ -166,10 +168,10 @@ export function MyClassesGrid({
</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
{t("myClasses.cancel")}
</Button>
<Button type="submit" disabled={isWorking || !joinSubject || subjectOptions.length === 0} className="min-w-[100px]">
{isWorking ? "Joining..." : "Join Class"}
{isWorking ? t("myClasses.joining") : t("myClasses.join")}
</Button>
</DialogFooter>
</form>
@@ -182,10 +184,10 @@ export function MyClassesGrid({
<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."
title={t("myClasses.empty.title")}
description={t("myClasses.empty.description")}
icon={Users}
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
action={{ label: t("myClasses.join"), onClick: () => setJoinOpen(true) }}
className="h-[360px] bg-card border-dashed"
/>
) : (
@@ -211,49 +213,50 @@ function ClassTicket({
onWorkingChange: (v: boolean) => void
}) {
const router = useRouter()
const t = useTranslations("classes")
const handleEnsureCode = async () => {
const handleEnsureCode = async (): Promise<void> => {
onWorkingChange(true)
try {
const res = await ensureClassInvitationCodeAction(c.id)
if (res.success) {
toast.success(res.message || "Invitation code ready")
toast.success(res.message || t("invitation.generateSuccess"))
router.refresh()
} else {
toast.error(res.message || "Failed to generate invitation code")
toast.error(res.message || t("invitation.generateFailed"))
}
} catch {
toast.error("Failed to generate invitation code")
toast.error(t("invitation.generateFailed"))
} finally {
onWorkingChange(false)
}
}
const handleRegenerateCode = async () => {
const handleRegenerateCode = async (): Promise<void> => {
onWorkingChange(true)
try {
const res = await regenerateClassInvitationCodeAction(c.id)
if (res.success) {
toast.success(res.message || "Invitation code updated")
toast.success(res.message || t("invitation.regenerateSuccess"))
router.refresh()
} else {
toast.error(res.message || "Failed to regenerate invitation code")
toast.error(res.message || t("list.failedRegenerate"))
}
} catch {
toast.error("Failed to regenerate invitation code")
toast.error(t("list.failedRegenerate"))
} finally {
onWorkingChange(false)
}
}
const handleCopyCode = async () => {
const handleCopyCode = async (): Promise<void> => {
const code = c.invitationCode ?? ""
if (!code) return
try {
await navigator.clipboard.writeText(code)
toast.success("Copied invitation code")
toast.success(t("myClasses.codeCopied"))
} catch {
toast.error("Failed to copy")
toast.error(t("list.failedCopy"))
}
}
@@ -308,11 +311,11 @@ function ClassTicket({
<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
<span className="font-medium text-foreground/80">{c.studentCount}</span> {t("myClasses.students")}
</div>
<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>
<span className="font-medium text-foreground/80">{c.room || t("myClasses.noRoom")}</span>
</div>
{c.schoolName && (
<div className="flex items-center gap-2">
@@ -331,7 +334,7 @@ function ClassTicket({
<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>
<span className="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider">{t("myClasses.entryPass")}</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>
@@ -352,16 +355,16 @@ function ClassTicket({
{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">
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleCopyCode} title={t("myClasses.copyCode")}>
<Copy className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleRegenerateCode} title="Regenerate">
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleRegenerateCode} title={t("myClasses.regenerateCode")}>
<RefreshCw className="size-3.5" />
</Button>
</div>
) : (
<Button variant="outline" size="sm" className="h-7 text-xs z-10" onClick={handleEnsureCode}>
Generate
{t("invitation.generate")}
</Button>
)}
</div>
@@ -385,14 +388,14 @@ function ClassTicket({
{/* 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>
<h4 className="text-sm font-semibold text-foreground/80">{t("myClasses.submissionTrends")}</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>
{isPositive ? "+" : ""}{Math.round(performanceChange)}% <span className={cn("font-normal opacity-70 hidden sm:inline")}>{t("myClasses.vsLastWeek")}</span>
</span>
</div>

View File

@@ -0,0 +1,164 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { ClassScheduleItem, TeacherClass } from "../types"
import { createClassScheduleItemAction } from "../actions"
import { SCHEDULE_WEEKDAYS } from "./schedule-utils"
/**
* 课表新增对话框P1-13 从 schedule-view.tsx 拆分)。
*
* 自管理 isWorking / createClassId 状态,父组件仅需控制 open 与 weekday。
*/
export function ScheduleCreateDialog({
open,
onOpenChange,
classes,
defaultClassId,
weekday,
}: {
open: boolean
onOpenChange: (open: boolean) => void
classes: TeacherClass[]
defaultClassId: string
weekday: ClassScheduleItem["weekday"]
}) {
const router = useRouter()
const t = useTranslations("classes")
const [isWorking, setIsWorking] = useState(false)
const [createClassId, setCreateClassId] = useState<string>(defaultClassId)
// 打开时重置到默认班级,避免上一次选择残留
const [prevOpen, setPrevOpen] = useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) setCreateClassId(defaultClassId)
}
const handleSubmit = async (formData: FormData): Promise<void> => {
setIsWorking(true)
try {
formData.set("classId", createClassId || defaultClassId)
formData.set("weekday", String(weekday))
const res = await createClassScheduleItemAction(null, formData)
if (res.success) {
toast.success(res.message)
onOpenChange(false)
router.refresh()
} else {
toast.error(res.message || t("list.failedCreate"))
}
} catch {
toast.error(t("list.failedCreate"))
} finally {
setIsWorking(false)
}
}
const weekdayLabel = SCHEDULE_WEEKDAYS.find((w) => w.key === weekday)?.label ?? "schedule.weekday.1"
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (isWorking) return
onOpenChange(v)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{t("schedule.form.createTitle")}</DialogTitle>
<DialogDescription>{t("schedule.form.createDescription")}</DialogDescription>
</DialogHeader>
<form action={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("filters.class")}</Label>
<div className="col-span-3">
<Select value={createClassId} onValueChange={setCreateClassId}>
<SelectTrigger>
<SelectValue placeholder={t("filters.selectClass")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="classId" value={createClassId} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("schedule.column.weekday")}</Label>
<Input value={t(weekdayLabel)} readOnly className="col-span-3" />
<input type="hidden" name="weekday" value={String(weekday)} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-startTime" className="text-right">
{t("schedule.form.startLabel")}
</Label>
<Input id="create-startTime" name="startTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-endTime" className="text-right">
{t("schedule.form.endLabel")}
</Label>
<Input id="create-endTime" name="endTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-course" className="text-right">
{t("schedule.column.subject")}
</Label>
<Input
id="create-course"
name="course"
className="col-span-3"
placeholder={t("schedule.form.subjectPlaceholder")}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-location" className="text-right">
{t("schedule.column.location")}
</Label>
<Input
id="create-location"
name="location"
className="col-span-3"
placeholder={t("schedule.form.locationPlaceholder")}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !createClassId}>
{isWorking ? t("form.creating") : t("form.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,85 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import type { ClassScheduleItem } from "../types"
import { deleteClassScheduleItemAction } from "../actions"
/**
* 课表删除确认对话框P1-13 从 schedule-view.tsx 拆分)。
*
* 自管理 isWorking 状态,父组件仅需传入 deleteItem 与 onClose 回调。
*/
export function ScheduleDeleteDialog({
deleteItem,
onClose,
}: {
deleteItem: ClassScheduleItem | null
onClose: () => void
}) {
const router = useRouter()
const t = useTranslations("classes")
const [isWorking, setIsWorking] = useState(false)
const handleConfirm = async (): Promise<void> => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteClassScheduleItemAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
onClose()
router.refresh()
} else {
toast.error(res.message || t("list.failedDelete"))
}
} catch {
toast.error(t("list.failedDelete"))
} finally {
setIsWorking(false)
}
}
return (
<AlertDialog
open={!!deleteItem}
onOpenChange={(v) => {
if (isWorking) return
if (!v) onClose()
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("schedule.form.deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("schedule.form.deleteDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>{t("form.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
void handleConfirm()
}}
disabled={isWorking}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isWorking ? t("form.deleting") : t("list.actions.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,194 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { ClassScheduleItem, TeacherClass } from "../types"
import { updateClassScheduleItemAction } from "../actions"
import { SCHEDULE_WEEKDAYS } from "./schedule-utils"
/**
* 课表编辑对话框P1-13 从 schedule-view.tsx 拆分)。
*
* 自管理 isWorking / editClassId / editWeekday 状态,父组件仅需传入 editItem。
*/
export function ScheduleEditDialog({
editItem,
classes,
onClose,
}: {
editItem: ClassScheduleItem | null
classes: TeacherClass[]
onClose: () => void
}) {
const router = useRouter()
const t = useTranslations("classes")
const [isWorking, setIsWorking] = useState(false)
const [editClassId, setEditClassId] = useState<string>("")
const [editWeekday, setEditWeekday] = useState<string>("1")
// editItem 变更时同步本地表单状态
const [prevItem, setPrevItem] = useState(editItem)
if (editItem !== prevItem) {
setPrevItem(editItem)
if (editItem) {
setEditClassId(editItem.classId)
setEditWeekday(String(editItem.weekday))
}
}
const handleSubmit = async (formData: FormData): Promise<void> => {
if (!editItem) return
setIsWorking(true)
try {
formData.set("classId", editClassId)
formData.set("weekday", editWeekday)
const res = await updateClassScheduleItemAction(editItem.id, null, formData)
if (res.success) {
toast.success(res.message)
onClose()
router.refresh()
} else {
toast.error(res.message || t("list.failedUpdate"))
}
} catch {
toast.error(t("list.failedUpdate"))
} finally {
setIsWorking(false)
}
}
return (
<Dialog
open={!!editItem}
onOpenChange={(v) => {
if (isWorking) return
if (!v) onClose()
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{t("schedule.form.editTitle")}</DialogTitle>
<DialogDescription>{t("schedule.form.editDescription")}</DialogDescription>
</DialogHeader>
<form action={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("filters.class")}</Label>
<div className="col-span-3">
<Select value={editClassId} onValueChange={setEditClassId}>
<SelectTrigger>
<SelectValue placeholder={t("filters.selectClass")} />
</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 htmlFor="edit-weekday" className="text-right">
{t("schedule.column.weekday")}
</Label>
<div className="col-span-3">
<Select value={editWeekday} onValueChange={setEditWeekday}>
<SelectTrigger>
<SelectValue placeholder={t("filters.selectWeekday")} />
</SelectTrigger>
<SelectContent>
{SCHEDULE_WEEKDAYS.map((w) => (
<SelectItem key={w.key} value={String(w.key)}>
{t(w.label)}
</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">
{t("schedule.form.startLabel")}
</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">
{t("schedule.form.endLabel")}
</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">
{t("schedule.column.subject")}
</Label>
<Input
id="edit-course"
name="course"
className="col-span-3"
defaultValue={editItem?.course}
placeholder={t("schedule.form.subjectPlaceholder")}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-location" className="text-right">
{t("schedule.column.location")}
</Label>
<Input
id="edit-location"
name="location"
className="col-span-3"
defaultValue={editItem?.location ?? ""}
placeholder={t("schedule.form.locationPlaceholder")}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !editClassId}>
{isWorking ? t("form.saving") : t("form.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -2,6 +2,7 @@
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { useQueryState, parseAsString } from "nuqs"
import { Plus } from "lucide-react"
import { toast } from "sonner"
@@ -32,6 +33,7 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all").withOptions({ shallow: false }))
const router = useRouter()
const t = useTranslations("classes")
const [open, setOpen] = useState(false)
const [isWorking, setIsWorking] = useState(false)
@@ -48,7 +50,7 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
}
}
const handleCreate = async (formData: FormData) => {
const handleCreate = async (formData: FormData): Promise<void> => {
setIsWorking(true)
try {
formData.set("classId", createClassId)
@@ -58,27 +60,27 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
setOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create schedule item")
toast.error(res.message || t("list.failedCreate"))
}
} catch {
toast.error("Failed to create schedule item")
toast.error(t("list.failedCreate"))
} finally {
setIsWorking(false)
}
}
const selectedClass = classes.find((c) => c.id === classId)
const title = selectedClass ? selectedClass.name : "All Classes"
const title = selectedClass ? selectedClass.name : t("filters.allClasses")
return (
<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" ? "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" />
<SelectValue placeholder={t("filters.allClasses")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">All Classes</SelectItem>
<SelectItem value="all" className="text-xs">{t("filters.allClasses")}</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id} className="text-xs">
{c.name}
@@ -106,22 +108,22 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
variant="ghost"
>
<Plus className="size-3.5" />
Add Event
{t("filters.addEvent")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Add schedule item</DialogTitle>
<DialogDescription>Create a class schedule entry.</DialogDescription>
<DialogTitle>{t("schedule.form.createTitle")}</DialogTitle>
<DialogDescription>{t("filters.createScheduleEntryDescription")}</DialogDescription>
</DialogHeader>
<form action={handleCreate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<Label className="text-right">{t("filters.class")}</Label>
<div className="col-span-3">
<Select value={createClassId} onValueChange={setCreateClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
<SelectValue placeholder={t("filters.selectClass")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
@@ -136,21 +138,21 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="weekday" className="text-right">
Weekday
{t("schedule.column.weekday")}
</Label>
<div className="col-span-3">
<Select value={weekday} onValueChange={setWeekday}>
<SelectTrigger>
<SelectValue placeholder="Select weekday" />
<SelectValue placeholder={t("filters.selectWeekday")} />
</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>
<SelectItem value="1">{t("schedule.weekday.1")}</SelectItem>
<SelectItem value="2">{t("schedule.weekday.2")}</SelectItem>
<SelectItem value="3">{t("schedule.weekday.3")}</SelectItem>
<SelectItem value="4">{t("schedule.weekday.4")}</SelectItem>
<SelectItem value="5">{t("schedule.weekday.5")}</SelectItem>
<SelectItem value="6">{t("schedule.weekday.6")}</SelectItem>
<SelectItem value="7">{t("schedule.weekday.7")}</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="weekday" value={weekday} />
@@ -159,35 +161,35 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="startTime" className="text-right">
Start
{t("filters.startTime")}
</Label>
<Input id="startTime" name="startTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="endTime" className="text-right">
End
{t("filters.endTime")}
</Label>
<Input id="endTime" name="endTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="course" className="text-right">
Course
{t("schedule.column.subject")}
</Label>
<Input id="course" name="course" className="col-span-3" placeholder="e.g. Math" required />
<Input id="course" name="course" className="col-span-3" placeholder={t("filters.subjectPlaceholderExample")} required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="location" className="text-right">
Location
{t("schedule.column.location")}
</Label>
<Input id="location" name="location" className="col-span-3" placeholder="Optional" />
<Input id="location" name="location" className="col-span-3" placeholder={t("schedule.form.locationPlaceholder")} />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !createClassId}>
{isWorking ? "Creating..." : "Create"}
{isWorking ? t("form.creating") : t("form.create")}
</Button>
</DialogFooter>
</form>

View File

@@ -0,0 +1,100 @@
/**
* 课表纯函数工具集P0-4 审计修复 + P2-4 可测试性抽取)。
*
* 修复点:
* - 原 `getSubjectColor` 使用英文关键词('math'/'english')匹配科目,
* 但 `DEFAULT_CLASS_SUBJECTS` 为中文("数学"/"英语"),导致所有中文科目
* 落到 default 分支,颜色视觉分组完全失效。
* - 现支持中英文双语匹配,并导出为纯函数便于单测。
*/
/** 科目到颜色类的映射表(中英文关键词均匹配)。 */
const SUBJECT_COLOR_MAP: ReadonlyArray<{ keywords: readonly string[]; classes: string }> = [
{
keywords: ["math", "数学"],
classes: "bg-blue-500/10 text-blue-700 border-blue-500/20 hover:bg-blue-500/20",
},
{
keywords: ["physics", "物理", "science", "科学"],
classes: "bg-purple-500/10 text-purple-700 border-purple-500/20 hover:bg-purple-500/20",
},
{
keywords: ["english", "英语", "lit"],
classes: "bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/20",
},
{
keywords: ["history", "历史", "geo", "地理", "社会"],
classes: "bg-orange-500/10 text-orange-700 border-orange-500/20 hover:bg-orange-500/20",
},
{
keywords: ["art", "美术", "music", "音乐"],
classes: "bg-pink-500/10 text-pink-700 border-pink-500/20 hover:bg-pink-500/20",
},
{
keywords: ["sport", "pe", "体育"],
classes: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/20",
},
{
keywords: ["chinese", "语文", "language"],
classes: "bg-rose-500/10 text-rose-700 border-rose-500/20 hover:bg-rose-500/20",
},
] as const
const DEFAULT_SUBJECT_COLOR = "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20"
/**
* 根据科目名称返回对应的颜色类名(支持中英文)。
* 匹配规则:科目名转为小写后,检查是否包含映射表中的任一关键词。
*/
export function getSubjectColor(subject: string): string {
const s = subject.toLowerCase()
for (const entry of SUBJECT_COLOR_MAP) {
if (entry.keywords.some((kw) => s.includes(kw.toLowerCase()))) {
return entry.classes
}
}
return DEFAULT_SUBJECT_COLOR
}
/** 课表时间范围8:00 - 18:00。 */
const MIN_TIME_MINUTES = 8 * 60
const MAX_TIME_MINUTES = 18 * 60
const TOTAL_DURATION_MINUTES = MAX_TIME_MINUTES - MIN_TIME_MINUTES
/**
* 将 "HH:MM" 时间字符串转换为当天从 0 点起的分钟数。
*/
export function timeToMinutes(time: string): number {
const parts = time.split(":").map(Number)
const hours = parts[0] ?? 0
const minutes = parts[1] ?? 0
return hours * 60 + minutes
}
/**
* 根据课程的开始/结束时间计算课表块在时间轴上的定位样式top% / height%)。
* 时间轴范围8:00 - 18:00。
*/
export function getPositionStyle(startTime: string, endTime: string): { top: string; height: string } {
const startMinutes = timeToMinutes(startTime)
const endMinutes = timeToMinutes(endTime)
const top = Math.max(0, ((startMinutes - MIN_TIME_MINUTES) / TOTAL_DURATION_MINUTES) * 100)
const height = Math.min(100 - top, ((endMinutes - startMinutes) / TOTAL_DURATION_MINUTES) * 100)
return {
top: `${top}%`,
height: `${height}%`,
}
}
/** 课表周常量(周一~周日label 为 i18n 翻译键。 */
export const SCHEDULE_WEEKDAYS: ReadonlyArray<{ key: 1 | 2 | 3 | 4 | 5 | 6 | 7; label: string }> = [
{ key: 1, label: "schedule.weekday.1" },
{ key: 2, label: "schedule.weekday.2" },
{ key: 3, label: "schedule.weekday.3" },
{ key: 4, label: "schedule.weekday.4" },
{ key: 5, label: "schedule.weekday.5" },
{ key: 6, label: "schedule.weekday.6" },
{ key: 7, label: "schedule.weekday.7" },
]

View File

@@ -1,9 +1,8 @@
"use client"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
@@ -14,44 +13,20 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { ClassScheduleItem, TeacherClass } from "../types"
import {
createClassScheduleItemAction,
deleteClassScheduleItemAction,
updateClassScheduleItemAction,
} from "../actions"
const WEEKDAYS: Array<{ key: ClassScheduleItem["weekday"]; label: string }> = [
{ key: 1, label: "Mon" },
{ key: 2, label: "Tue" },
{ key: 3, label: "Wed" },
{ key: 4, label: "Thu" },
{ key: 5, label: "Fri" },
{ key: 6, label: "Sat" },
{ key: 7, label: "Sun" },
]
import { getPositionStyle, getSubjectColor, SCHEDULE_WEEKDAYS } from "./schedule-utils"
import { ScheduleCreateDialog } from "./schedule-create-dialog"
import { ScheduleEditDialog } from "./schedule-edit-dialog"
import { ScheduleDeleteDialog } from "./schedule-delete-dialog"
/**
* 课表周视图P1-13已将创建/编辑/删除对话框拆分为独立组件)。
*
* 本组件仅负责:
* - 渲染时间轴与周列布局
* - 渲染课表块及其 hover 操作菜单
* - 维护当前打开的对话框状态createOpen / editItem / deleteItem
*/
export function ScheduleView({
schedule,
classes,
@@ -59,139 +34,21 @@ export function ScheduleView({
schedule: ClassScheduleItem[]
classes: TeacherClass[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const t = useTranslations("classes")
const [editItem, setEditItem] = useState<ClassScheduleItem | null>(null)
const [deleteItem, setDeleteItem] = useState<ClassScheduleItem | null>(null)
const [createWeekday, setCreateWeekday] = useState<ClassScheduleItem["weekday"]>(1)
const [createOpen, setCreateOpen] = useState(false)
const [createClassId, setCreateClassId] = useState<string>("")
const [editClassId, setEditClassId] = useState<string>("")
const [editWeekday, setEditWeekday] = useState<string>("1")
const [createWeekday, setCreateWeekday] = useState<ClassScheduleItem["weekday"]>(1)
const classNameById = useMemo(() => new Map(classes.map((c) => [c.id, c.name] as const)), [classes])
const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes])
const [prevEditItem, setPrevEditItem] = useState(editItem)
if (editItem !== prevEditItem) {
setPrevEditItem(editItem)
if (editItem) {
setEditClassId(editItem.classId)
setEditWeekday(String(editItem.weekday))
}
}
const [prevCreateOpen, setPrevCreateOpen] = useState(createOpen)
if (createOpen !== prevCreateOpen) {
setPrevCreateOpen(createOpen)
if (createOpen) {
setCreateClassId(defaultClassId)
}
}
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
for (const d of WEEKDAYS) byDay.set(d.key, [])
for (const d of SCHEDULE_WEEKDAYS) byDay.set(d.key, [])
for (const item of schedule) byDay.get(item.weekday)?.push(item)
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("classId", createClassId || defaultClassId)
formData.set("weekday", String(createWeekday))
const res = await createClassScheduleItemAction(null, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create schedule item")
}
} catch {
toast.error("Failed to create schedule item")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
formData.set("classId", editClassId)
formData.set("weekday", editWeekday)
const res = await updateClassScheduleItemAction(editItem.id, null, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update schedule item")
}
} catch {
toast.error("Failed to update schedule item")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteClassScheduleItemAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete schedule item")
}
} catch {
toast.error("Failed to delete schedule item")
} finally {
setIsWorking(false)
}
}
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="h-[600px] flex flex-col">
<div className="flex h-full">
@@ -200,8 +57,8 @@ export function ScheduleView({
<div className="h-10" /> {/* Header spacer */}
<div className="flex-1 relative">
{HOURS.map((h, i) => (
<div
key={h}
<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}%` }}
>
@@ -213,19 +70,19 @@ export function ScheduleView({
{/* Days Columns */}
<div className="flex-1 grid grid-cols-5">
{WEEKDAYS.slice(0, 5).map((d) => (
{SCHEDULE_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>
<span className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors uppercase tracking-wider">{t(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}
<div
key={item.id}
className="group absolute w-full px-1 z-10"
style={getPositionStyle(item.startTime, item.endTime)}
>
@@ -243,18 +100,18 @@ export function ScheduleView({
{classNameById.get(item.classId)}
</div>
</div>
<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-5 w-5 hover:bg-background/20 p-0" disabled={isWorking}>
<Button variant="ghost" size="icon" className="h-5 w-5 hover:bg-background/20 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={() => setEditItem(item)} className="text-xs">
<Pencil className="mr-2 h-3 w-3" />
Edit
{t("list.actions.edit")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -262,7 +119,7 @@ export function ScheduleView({
onClick={() => setDeleteItem(item)}
>
<Trash2 className="mr-2 h-3 w-3" />
Delete
{t("list.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -271,23 +128,23 @@ export function ScheduleView({
</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 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>
</div>
</div>
@@ -295,231 +152,24 @@ export function ScheduleView({
</div>
</div>
<Dialog
<ScheduleCreateDialog
open={createOpen}
onOpenChange={(v) => {
if (isWorking) return
setCreateOpen(v)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Add schedule item</DialogTitle>
<DialogDescription>Create a class schedule entry.</DialogDescription>
</DialogHeader>
<form action={handleCreate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={createClassId} onValueChange={setCreateClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="classId" value={createClassId} />
</div>
</div>
onOpenChange={setCreateOpen}
classes={classes}
defaultClassId={defaultClassId}
weekday={createWeekday}
/>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Weekday</Label>
<Input value={WEEKDAYS.find((w) => w.key === createWeekday)?.label ?? ""} readOnly className="col-span-3" />
<input type="hidden" name="weekday" value={String(createWeekday)} />
</div>
<ScheduleEditDialog
editItem={editItem}
classes={classes}
onClose={() => setEditItem(null)}
/>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-startTime" className="text-right">
Start
</Label>
<Input id="create-startTime" name="startTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-endTime" className="text-right">
End
</Label>
<Input id="create-endTime" name="endTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-course" className="text-right">
Course
</Label>
<Input id="create-course" name="course" className="col-span-3" placeholder="e.g. Math" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-location" className="text-right">
Location
</Label>
<Input id="create-location" name="location" className="col-span-3" placeholder="Optional" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !createClassId}>
{isWorking ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={!!editItem}
onOpenChange={(v) => {
if (isWorking) return
if (!v) setEditItem(null)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Edit schedule item</DialogTitle>
<DialogDescription>Update class schedule entry.</DialogDescription>
</DialogHeader>
<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 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={!!deleteItem}
onOpenChange={(v) => {
if (isWorking) return
if (!v) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this schedule item.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleDelete()
}}
disabled={isWorking}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isWorking ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ScheduleDeleteDialog
deleteItem={deleteItem}
onClose={() => setDeleteItem(null)}
/>
</div>
)
}

View File

@@ -2,6 +2,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { useQueryState, parseAsString } from "nuqs"
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
import { toast } from "sonner"
@@ -42,6 +43,7 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all").withOptions({ shallow: false }))
const router = useRouter()
const t = useTranslations("classes")
const [open, setOpen] = useState(false)
const [isWorking, setIsWorking] = useState(false)
@@ -57,7 +59,7 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
}
}
const handleEnroll = async (formData: FormData) => {
const handleEnroll = async (formData: FormData): Promise<void> => {
setIsWorking(true)
try {
const res = await enrollStudentByEmailAction(enrollClassId, null, formData)
@@ -66,19 +68,19 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
setOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to add student")
toast.error(res.message || t("list.failedCreate"))
}
} catch {
toast.error("Failed to add student")
toast.error(t("list.failedCreate"))
} finally {
setIsWorking(false)
}
}
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 classLabel = classId === "all" ? t("filters.allClasses") : (selectedClass?.name || t("filters.unknownClass"))
const statusLabel = status === "all" ? t("filters.allStatuses") : (status === "active" ? t("students.status.active") : t("students.status.inactive"))
return (
<div className="flex items-center justify-between py-2">
@@ -87,7 +89,7 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
<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..."
placeholder={t("students.searchPlaceholder")}
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)}
@@ -110,7 +112,7 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
onClick={() => setClassId("all")}
className="text-xs flex items-center justify-between"
>
All Classes
{t("filters.allClasses")}
{classId === "all" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -136,17 +138,17 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Status</DropdownMenuLabel>
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">{t("filters.filterByStatus")}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setStatus(null)} className="text-xs flex items-center justify-between">
All Status
{t("filters.allStatuses")}
{status === "all" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatus("active")} className="text-xs flex items-center justify-between">
Active
{t("students.status.active")}
{status === "active" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatus("inactive")} className="text-xs flex items-center justify-between">
Inactive
{t("students.status.inactive")}
{status === "inactive" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -163,22 +165,22 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
<DialogTrigger asChild>
<Button size="sm" className="h-8 gap-1.5 text-xs px-3" disabled={classes.length === 0}>
<UserPlus className="size-3.5" />
Add student
{t("filters.addStudent")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Add student</DialogTitle>
<DialogDescription>Enroll a student by email to a class.</DialogDescription>
<DialogTitle>{t("filters.addStudent")}</DialogTitle>
<DialogDescription>{t("filters.enrollStudentDescription")}</DialogDescription>
</DialogHeader>
<form action={handleEnroll}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<Label className="text-right">{t("filters.class")}</Label>
<div className="col-span-3">
<Select value={enrollClassId} onValueChange={setEnrollClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
<SelectValue placeholder={t("filters.selectClass")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
@@ -192,21 +194,21 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="student-email" className="text-right">
Email
{t("filters.email")}
</Label>
<Input
id="student-email"
name="email"
type="email"
className="col-span-3"
placeholder="student@example.com"
placeholder={t("filters.emailPlaceholder")}
required
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !enrollClassId}>
{isWorking ? "Adding..." : "Add"}
{isWorking ? t("form.adding") : t("form.add")}
</Button>
</DialogFooter>
</form>

View File

@@ -2,6 +2,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
import { toast } from "sonner"
@@ -22,6 +23,7 @@ import { setStudentEnrollmentStatusAction } from "../actions"
export function StudentsTable({ students }: { students: ClassStudent[] }) {
const router = useRouter()
const t = useTranslations("classes")
const [workingKey, setWorkingKey] = useState<string | null>(null)
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
@@ -37,7 +39,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
toast.error(res.message)
}
} catch {
toast.error("Failed to update status")
toast.error(t("list.failedStatus"))
} finally {
setWorkingKey(null)
}
@@ -124,12 +126,12 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
{s.status !== "active" ? (
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
<UserCheck className="mr-2 size-4" />
Set active
{t("students.actions.setActive")}
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
<UserX className="mr-2 size-4" />
Set inactive
{t("students.actions.setInactive")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -139,7 +141,7 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
disabled={s.status === "inactive" || workingKey !== null}
>
<UserX className="mr-2 size-4" />
Remove from class
{t("students.actions.remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -154,15 +156,10 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
if (workingKey !== null) return
if (!open) setRemoveTarget(null)
}}
title="Remove student from class?"
confirmText="Remove"
title={t("students.removeTitle")}
confirmText={t("students.actions.remove")}
description={
removeTarget ? (
<>
This will set <span className="font-medium text-foreground">{removeTarget.name}</span> to inactive in{" "}
<span className="font-medium text-foreground">{removeTarget.className}</span>.
</>
) : null
removeTarget ? t("students.removeDescription", { name: removeTarget.name }) : null
}
onConfirm={() => {
if (!removeTarget) return