fix: patch P0 security vulnerabilities and critical UX issues across 6 modules

Security: Add admin/layout.tsx auth guard; Add requirePermission() to 12 admin pages

Dashboard: Fix StudentStatsGrid rendering; Fix teacher greeting; Add loading/error boundaries; Fix col-span; Add metadata

Announcements: Fix audience filtering; Add user detail page; Trigger notifications on publish; Pass classes data; Add loading.tsx

Messages: Implement soft delete; Add unread badge with polling; Add notification dropdown polling; Add keyword search; Add quiet hours DND

Management: Add loading/error for 9 admin routes; Fix admin-classes-view to use Select for school/grade

Profile/Settings: Add loading/error; Fix parent role routing; Create ParentSettingsView; Integrate AiProviderSettingsCard; Add Tab URL persistence; Add logout confirm; Add avatar; Fix Progress arbitrary class

Schema: Add senderDeletedAt/receiverDeletedAt to messages; Add quietHours to notificationPreferences; Add uniqueIndex import

Docs: Update architecture docs 004/005
This commit is contained in:
SpecialX
2026-06-22 13:57:31 +08:00
parent 5ff7ab9e72
commit a4d096a6fc
81 changed files with 2145 additions and 124 deletions

View File

@@ -39,9 +39,13 @@ import { formatDate } from "@/shared/lib/utils"
export function AdminClassesClient({
classes,
teachers,
schools,
grades,
}: {
classes: AdminClassListItem[]
teachers: TeacherOption[]
schools: { id: string; name: string }[]
grades: { id: string; name: string; school: { id: string; name: string } }[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
@@ -50,18 +54,34 @@ export function AdminClassesClient({
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 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)
useEffect(() => {
if (!createOpen) return
setCreateTeacherId(defaultTeacherId)
}, [createOpen, defaultTeacherId])
setCreateSchoolId(defaultSchoolId)
setCreateGradeId("")
}, [createOpen, defaultTeacherId, defaultSchoolId])
useEffect(() => {
if (!editItem) return
setEditTeacherId(editItem.teacher.id)
setEditSchoolId(editItem.schoolId ?? "")
setEditGradeId(editItem.gradeId ?? "")
setEditSubjectTeachers(
DEFAULT_CLASS_SUBJECTS.map((s) => ({
subject: s,
@@ -227,10 +247,30 @@ export function AdminClassesClient({
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-school-name" className="text-right">
School
</Label>
<Input id="create-school-name" name="schoolName" className="col-span-3" placeholder="e.g. First Primary School" />
<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>
<div className="grid grid-cols-4 items-center gap-4">
@@ -241,10 +281,27 @@ export function AdminClassesClient({
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade" className="text-right">
Grade
</Label>
<Input id="create-grade" name="grade" className="col-span-3" placeholder="e.g. Grade 10" />
<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">
@@ -284,7 +341,7 @@ export function AdminClassesClient({
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking || teachers.length === 0 || !createTeacherId}>
<Button type="submit" disabled={isWorking || teachers.length === 0 || !createTeacherId || !createGradeId}>
Create
</Button>
</DialogFooter>
@@ -306,15 +363,30 @@ export function AdminClassesClient({
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-school-name" className="text-right">
School
</Label>
<Input
id="edit-school-name"
name="schoolName"
className="col-span-3"
defaultValue={editItem.schoolName ?? ""}
/>
<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>
<div className="grid grid-cols-4 items-center gap-4">
@@ -325,10 +397,27 @@ export function AdminClassesClient({
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-grade" className="text-right">
Grade
</Label>
<Input id="edit-grade" name="grade" className="col-span-3" defaultValue={editItem.grade} />
<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">