-
+
Latest Score
-
- {latestGrade ? `${Math.round(latestGrade.percentage)}%` : "-"}
+
+
+ {latestGrade ? `${Math.round(latestGrade.percentage)}%` : "-"}
+
+ {hasGradeTrend ?
: null}
{latestGrade ? (
@@ -65,13 +97,17 @@ export function ChildGradeSummary({
-
+
Class Rank
{ranking ? `${ranking.rank}/${ranking.classSize}` : "-"}
- {ranking ?
Current position
: null}
+ {ranking ? (
+
+ Top {Math.ceil((ranking.rank / ranking.classSize) * 100)}%
+
+ ) : null}
@@ -87,7 +123,7 @@ export function ChildGradeSummary({
activeDotRadius: 5,
},
]}
- xKey="date"
+ xKey="index"
xTickFormatter={null}
heightClassName="h-[160px]"
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
@@ -103,7 +139,7 @@ export function ChildGradeSummary({
{r.assignmentTitle}
diff --git a/src/modules/parent/components/child-schedule-card.tsx b/src/modules/parent/components/child-schedule-card.tsx
index 1720f5a..7731483 100644
--- a/src/modules/parent/components/child-schedule-card.tsx
+++ b/src/modules/parent/components/child-schedule-card.tsx
@@ -3,22 +3,97 @@ import { CalendarDays, CalendarX } from "lucide-react"
import { ScheduleList } from "@/shared/components/schedule/schedule-list"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
-import type { ChildScheduleItem } from "@/modules/parent/types"
+import { cn } from "@/shared/lib/utils"
+import type { ChildScheduleItem, ChildWeeklyScheduleItem } from "@/modules/parent/types"
+
+const WEEKDAY_LABELS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const
+
+const toWeekdayIndex = (w: 1 | 2 | 3 | 4 | 5 | 6 | 7): 0 | 1 | 2 | 3 | 4 | 5 | 6 =>
+ (w - 1) as 0 | 1 | 2 | 3 | 4 | 5 | 6
+
+const isToday = (w: 1 | 2 | 3 | 4 | 5 | 6 | 7): boolean => {
+ const today = new Date().getDay()
+ const normalizedToday = today === 0 ? 7 : today
+ return w === normalizedToday
+}
export function ChildScheduleCard({
items,
childName,
+ weeklyItems,
}: {
items: ChildScheduleItem[]
childName: string
+ /** 完整周课表(可选)。提供时切换为周课表视图。 */
+ weeklyItems?: ChildWeeklyScheduleItem[]
}) {
const hasSchedule = items.length > 0
+ const hasWeekly = weeklyItems && weeklyItems.length > 0
+
+ if (hasWeekly) {
+ const grouped: Record
= {}
+ for (const item of weeklyItems!) {
+ const key = item.weekday
+ if (!grouped[key]) grouped[key] = []
+ grouped[key].push(item)
+ }
+ const weekdays = Object.keys(grouped).map(Number).sort((a, b) => a - b) as Array<1 | 2 | 3 | 4 | 5 | 6 | 7>
+
+ return (
+
+
+
+
+ {childName}'s Weekly Schedule
+
+
+
+ {weekdays.map((w) => {
+ const dayItems = grouped[w]
+ const today = isToday(w)
+ const label = WEEKDAY_LABELS[toWeekdayIndex(w)]
+ return (
+
+
+ {label}
+ {today ? (
+
+ Today
+
+ ) : null}
+
+
({
+ id: d.id,
+ classId: d.classId,
+ className: d.className,
+ course: d.course,
+ startTime: d.startTime,
+ endTime: d.endTime,
+ location: d.location ?? null,
+ }))}
+ variant="separator"
+ spacingClassName="space-y-2"
+ />
+
+ )
+ })}
+
+
+ )
+ }
return (
-
+
{childName}'s Today Schedule
diff --git a/src/modules/parent/components/parent-attention-banner.tsx b/src/modules/parent/components/parent-attention-banner.tsx
new file mode 100644
index 0000000..0d50615
--- /dev/null
+++ b/src/modules/parent/components/parent-attention-banner.tsx
@@ -0,0 +1,135 @@
+import Link from "next/link"
+import { AlertTriangle, Bell, CalendarCheck, PenTool } from "lucide-react"
+
+import { Card } from "@/shared/components/ui/card"
+import { cn } from "@/shared/lib/utils"
+import type { ParentDashboardData } from "@/modules/parent/types"
+
+type AttentionItem = {
+ id: string
+ label: string
+ count: number
+ href: string
+ variant: "danger" | "warning" | "info"
+ icon: typeof AlertTriangle
+}
+
+const buildAttentionItems = (data: ParentDashboardData): AttentionItem[] => {
+ const items: AttentionItem[] = []
+
+ const totalOverdue = data.children.reduce((sum, c) => sum + c.homeworkSummary.overdueCount, 0)
+ if (totalOverdue > 0) {
+ // 找到第一个有逾期作业的子女,直接跳转其详情页 homework tab
+ const childWithOverdue = data.children.find((c) => c.homeworkSummary.overdueCount > 0)
+ items.push({
+ id: "overdue-homework",
+ label: "Overdue homework",
+ count: totalOverdue,
+ href: childWithOverdue
+ ? `/parent/children/${childWithOverdue.basicInfo.id}?tab=homework`
+ : "/parent/dashboard",
+ variant: "danger",
+ icon: PenTool,
+ })
+ }
+
+ const totalPending = data.children.reduce((sum, c) => sum + c.homeworkSummary.pendingCount, 0)
+ if (totalPending > 0) {
+ const childWithPending = data.children.find((c) => c.homeworkSummary.pendingCount > 0)
+ items.push({
+ id: "pending-homework",
+ label: "Pending homework",
+ count: totalPending,
+ href: childWithPending
+ ? `/parent/children/${childWithPending.basicInfo.id}?tab=homework`
+ : "/parent/dashboard",
+ variant: "warning",
+ icon: PenTool,
+ })
+ }
+
+ items.push({
+ id: "attendance",
+ label: "Attendance",
+ count: data.children.length,
+ href: "/parent/attendance",
+ variant: "info",
+ icon: CalendarCheck,
+ })
+
+ items.push({
+ id: "announcements",
+ label: "Announcements",
+ count: 0,
+ href: "/announcements",
+ variant: "info",
+ icon: Bell,
+ })
+
+ return items
+}
+
+const VARIANT_STYLES: Record = {
+ danger: "border-destructive/30 bg-destructive/5 text-destructive",
+ warning: "border-warning/30 bg-warning/5 text-warning-foreground",
+ info: "border-border bg-muted/40 text-foreground",
+}
+
+/**
+ * 仪表盘顶部"需要关注"横幅。
+ * 聚合所有子女的异常项(逾期作业、待办、考勤、公告),让家长一眼定位异常。
+ */
+export function ParentAttentionBanner({ data }: { data: ParentDashboardData }) {
+ const items = buildAttentionItems(data)
+ const hasUrgent = items.some((i) => i.variant === "danger" && i.count > 0)
+
+ if (items.length === 0) return null
+
+ return (
+
+
+
+ {hasUrgent ? (
+
+ ) : (
+
+ )}
+
+ {hasUrgent ? "Needs attention" : "Nothing urgent"}
+
+
+
+ {items.map((item) => {
+ const Icon = item.icon
+ const isUrgent = item.variant === "danger" && item.count > 0
+ return (
+
+
+
+ {item.label}
+
+ {item.count}
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/modules/parent/components/parent-children-data-page.tsx b/src/modules/parent/components/parent-children-data-page.tsx
index 08c7daa..e7ee8d0 100644
--- a/src/modules/parent/components/parent-children-data-page.tsx
+++ b/src/modules/parent/components/parent-children-data-page.tsx
@@ -15,6 +15,7 @@ export function ParentChildrenDataPage({
noRecordsDescription,
items,
renderItem,
+ headerExtra,
}: {
title: string
description: string
@@ -23,6 +24,8 @@ export function ParentChildrenDataPage({
noRecordsDescription: string
items: T[]
renderItem: (item: T) => React.ReactNode
+ /** 渲染在标题与列表之间的额外内容(如预警横幅)。 */
+ headerExtra?: React.ReactNode
}) {
const hasItems = items.length > 0
@@ -33,6 +36,8 @@ export function ParentChildrenDataPage({
{description}
+ {headerExtra}
+
{!hasItems ? (
{
+ setIsExporting(true)
+ const result = await safeActionCall(
+ () => exportGradesAction({ studentId }),
+ {
+ onError: () => toast.error(`Failed to export grades for ${studentName}.`),
+ onFinally: () => setIsExporting(false),
+ }
+ )
+ if (result?.success && result.data) {
+ try {
+ // 将 base64 buffer 转换为 Blob 并触发下载
+ const binaryString = atob(result.data.buffer)
+ const bytes = new Uint8Array(binaryString.length)
+ for (let i = 0; i < binaryString.length; i += 1) {
+ bytes[i] = binaryString.charCodeAt(i)
+ }
+ const blob = new Blob([bytes], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = result.data.filename
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+ toast.success(`Grades exported for ${studentName}.`)
+ } catch {
+ toast.error("Failed to download the export file.")
+ }
+ } else if (result) {
+ toast.error(result.message || `Failed to export grades for ${studentName}.`)
+ }
+ }
+
+ if (isExporting) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/modules/parent/data-access.ts b/src/modules/parent/data-access.ts
index 762da85..0ffedd8 100644
--- a/src/modules/parent/data-access.ts
+++ b/src/modules/parent/data-access.ts
@@ -1,7 +1,7 @@
import "server-only"
import { cache } from "react"
-import { and, asc, eq } from "drizzle-orm"
+import { and, asc, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import { parentStudentRelations } from "@/shared/db/schema"
@@ -267,3 +267,19 @@ export const getChildNameList = cache(
}))
},
)
+
+/**
+ * v4-P1-5: 批量查询多个学生的家长 userId 列表。
+ * 用于通知场景:报告/成绩发布时同步通知所有相关家长。
+ * 返回去重后的 parentId 数组。
+ */
+export const getParentIdsByStudentIds = cache(
+ async (studentIds: string[]): Promise => {
+ if (studentIds.length === 0) return []
+ const rows = await db
+ .select({ parentId: parentStudentRelations.parentId })
+ .from(parentStudentRelations)
+ .where(inArray(parentStudentRelations.studentId, studentIds))
+ return Array.from(new Set(rows.map((r) => r.parentId)))
+ },
+)