feat(parent): add attention banner, export button, and grade detail
- Add parent-attention-banner for highlighting items needing attention - Add parent-export-button for data export capability - Add child-grade-detail component for detailed grade viewing - Update existing child-card, child-detail-header, child-grade-summary, child-schedule-card - Update parent-children-data-page and data-access
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
import Link from "next/link"
|
||||
import { ChevronRight, GraduationCap, PenTool, TriangleAlert } from "lucide-react"
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
GraduationCap,
|
||||
PenTool,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Minus,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
@@ -7,19 +15,51 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
|
||||
import { cn, getInitials } from "@/shared/lib/utils"
|
||||
import type { ChildDashboardData } from "@/modules/parent/types"
|
||||
|
||||
type TrendDirection = "up" | "down" | "flat" | "unknown"
|
||||
|
||||
const computeTrend = (trend: { percentage: number }[]): TrendDirection => {
|
||||
if (trend.length < 2) return "unknown"
|
||||
const latest = trend[trend.length - 1].percentage
|
||||
const prev = trend[trend.length - 2].percentage
|
||||
if (latest > prev) return "up"
|
||||
if (latest < prev) return "down"
|
||||
return "flat"
|
||||
}
|
||||
|
||||
const TrendIcon = ({ direction }: { direction: TrendDirection }) => {
|
||||
if (direction === "up") {
|
||||
return <TrendingUp className="h-3 w-3 text-emerald-600" aria-label="improving" />
|
||||
}
|
||||
if (direction === "down") {
|
||||
return <TrendingDown className="h-3 w-3 text-destructive" aria-label="declining" />
|
||||
}
|
||||
if (direction === "flat") {
|
||||
return <Minus className="h-3 w-3 text-muted-foreground" aria-label="stable" />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function ChildCard({ child }: { child: ChildDashboardData }) {
|
||||
const { basicInfo, homeworkSummary, gradeTrend } = child
|
||||
const ranking = gradeTrend.ranking
|
||||
const latestGrade = gradeTrend.recent[0]
|
||||
const childName = basicInfo.name ?? "Child"
|
||||
const hasOverdue = homeworkSummary.overdueCount > 0
|
||||
const trendDirection = computeTrend(gradeTrend.trend)
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/parent/children/${basicInfo.id}`}
|
||||
aria-label={`View ${childName}'s details`}
|
||||
prefetch
|
||||
aria-label={`View ${childName}'s details${hasOverdue ? `, ${homeworkSummary.overdueCount} overdue homework` : ""}`}
|
||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md"
|
||||
>
|
||||
<Card className="hover:bg-muted/50 transition-colors h-full">
|
||||
<Card
|
||||
className={cn(
|
||||
"hover:bg-muted/50 transition-colors h-full",
|
||||
hasOverdue && "border-destructive/40 bg-destructive/5",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center gap-4 space-y-0">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={basicInfo.image ?? undefined} alt={childName} />
|
||||
@@ -37,28 +77,45 @@ export function ChildCard({ child }: { child: ChildDashboardData }) {
|
||||
{basicInfo.relation ? <span>· {basicInfo.relation}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
{hasOverdue ? (
|
||||
<AlertTriangle
|
||||
className="h-5 w-5 text-destructive shrink-0"
|
||||
aria-label={`${homeworkSummary.overdueCount} overdue`}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<PenTool className="h-3 w-3" />
|
||||
<PenTool className="h-3 w-3" aria-hidden />
|
||||
Pending
|
||||
</div>
|
||||
<div className="text-lg font-semibold tabular-nums">
|
||||
{homeworkSummary.pendingCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<TriangleAlert className="h-3 w-3" />
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md p-2",
|
||||
hasOverdue ? "bg-destructive/10" : "bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs flex items-center justify-center gap-1",
|
||||
hasOverdue ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden />
|
||||
Overdue
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-lg font-semibold tabular-nums",
|
||||
homeworkSummary.overdueCount > 0 && "text-destructive",
|
||||
"text-lg font-bold tabular-nums",
|
||||
hasOverdue && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{homeworkSummary.overdueCount}
|
||||
@@ -66,7 +123,7 @@ export function ChildCard({ child }: { child: ChildDashboardData }) {
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<GraduationCap className="h-3 w-3" />
|
||||
<GraduationCap className="h-3 w-3" aria-hidden />
|
||||
Avg
|
||||
</div>
|
||||
<div className="text-lg font-semibold tabular-nums">
|
||||
@@ -80,6 +137,7 @@ export function ChildCard({ child }: { child: ChildDashboardData }) {
|
||||
<span className="font-medium text-foreground tabular-nums shrink-0">
|
||||
{latestGrade.score}/{latestGrade.maxScore}
|
||||
</span>
|
||||
<TrendIcon direction={trendDirection} />
|
||||
<span className="truncate">({latestGrade.assignmentTitle})</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -4,6 +4,14 @@ import { ArrowLeft, GraduationCap } from "lucide-react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/shared/components/ui/breadcrumb"
|
||||
import { getInitials } from "@/shared/lib/utils"
|
||||
import type { ChildDashboardData } from "@/modules/parent/types"
|
||||
|
||||
@@ -21,12 +29,27 @@ export function ChildDetailHeader({ child }: { child: ChildDashboardData }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
|
||||
<Link href="/parent/dashboard">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/parent/dashboard">Parent Dashboard</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{childName}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2 md:ml-0">
|
||||
<Link href="/parent/dashboard" aria-label="Back to Dashboard">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={basicInfo.image ?? undefined} alt={childName} />
|
||||
@@ -40,7 +63,7 @@ export function ChildDetailHeader({ child }: { child: ChildDashboardData }) {
|
||||
) : null}
|
||||
{basicInfo.gradeName ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<GraduationCap className="h-3 w-3" />
|
||||
<GraduationCap className="h-3 w-3" aria-hidden />
|
||||
{basicInfo.gradeName}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
168
src/modules/parent/components/child-grade-detail.tsx
Normal file
168
src/modules/parent/components/child-grade-detail.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { BarChart3, TrendingDown, TrendingUp, Minus } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
||||
|
||||
type TrendDirection = "up" | "down" | "flat" | "unknown"
|
||||
|
||||
const computeTrend = (scores: number[]): TrendDirection => {
|
||||
if (scores.length < 2) return "unknown"
|
||||
const latest = scores[scores.length - 1]
|
||||
const prev = scores[scores.length - 2]
|
||||
if (latest > prev) return "up"
|
||||
if (latest < prev) return "down"
|
||||
return "flat"
|
||||
}
|
||||
|
||||
const TrendIcon = ({ direction }: { direction: TrendDirection }) => {
|
||||
if (direction === "up") {
|
||||
return <TrendingUp className="h-3 w-3 text-emerald-600" aria-label="improving" />
|
||||
}
|
||||
if (direction === "down") {
|
||||
return <TrendingDown className="h-3 w-3 text-destructive" aria-label="declining" />
|
||||
}
|
||||
if (direction === "flat") {
|
||||
return <Minus className="h-3 w-3 text-muted-foreground" aria-label="stable" />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type SubjectGroup = {
|
||||
subject: string
|
||||
items: StudentDashboardGradeProps["trend"]
|
||||
average: number
|
||||
count: number
|
||||
}
|
||||
|
||||
const groupBySubject = (
|
||||
trend: StudentDashboardGradeProps["trend"],
|
||||
): SubjectGroup[] => {
|
||||
const map = new Map<string, StudentDashboardGradeProps["trend"]>()
|
||||
for (const item of trend) {
|
||||
const subject = item.assignmentTitle.split(" - ")[0]?.trim() || "General"
|
||||
const list = map.get(subject) ?? []
|
||||
list.push(item)
|
||||
map.set(subject, list)
|
||||
}
|
||||
const groups: SubjectGroup[] = []
|
||||
for (const [subject, items] of map.entries()) {
|
||||
const sum = items.reduce((s, i) => s + i.percentage, 0)
|
||||
groups.push({
|
||||
subject,
|
||||
items: items.sort((a, b) => a.submittedAt.localeCompare(b.submittedAt)),
|
||||
average: items.length > 0 ? sum / items.length : 0,
|
||||
count: items.length,
|
||||
})
|
||||
}
|
||||
return groups.sort((a, b) => b.average - a.average)
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩详情视图:按科目分组展示,每个科目显示平均分、趋势、最近成绩。
|
||||
* 用于详情页 grades tab,提供单科分析能力。
|
||||
*/
|
||||
export function ChildGradeDetail({ grades }: { grades: StudentDashboardGradeProps }) {
|
||||
if (grades.trend.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="No graded work yet"
|
||||
description="Finish and submit assignments to see score analysis."
|
||||
className="border-none shadow-none h-48"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const groups = groupBySubject(grades.trend)
|
||||
const overallAvg =
|
||||
grades.trend.reduce((s, i) => s + i.percentage, 0) / grades.trend.length
|
||||
|
||||
return (
|
||||
<div className="space-y-4" aria-label="Grade analysis by subject">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
Overall Average
|
||||
</span>
|
||||
<span className="tabular-nums">{Math.round(overallAvg)}%</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{groups.length} {groups.length === 1 ? "subject" : "subjects"} · {grades.trend.length}{" "}
|
||||
graded {grades.trend.length === 1 ? "assignment" : "assignments"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{groups.map((group) => {
|
||||
const percentages = group.items.map((i) => i.percentage)
|
||||
const trend = computeTrend(percentages)
|
||||
const latest = group.items[group.items.length - 1]
|
||||
const best = Math.max(...percentages)
|
||||
return (
|
||||
<Card key={group.subject}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>{group.subject}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
Avg {Math.round(group.average)}%
|
||||
</Badge>
|
||||
<TrendIcon direction={trend} />
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="text-muted-foreground">Latest</div>
|
||||
<div className="font-semibold tabular-nums">
|
||||
{latest ? `${Math.round(latest.percentage)}%` : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="text-muted-foreground">Best</div>
|
||||
<div className="font-semibold tabular-nums">{Math.round(best)}%</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="text-muted-foreground">Count</div>
|
||||
<div className="font-semibold tabular-nums">{group.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium uppercase text-muted-foreground">
|
||||
Recent Grades
|
||||
</div>
|
||||
{group.items.slice(-3).reverse().map((item) => (
|
||||
<div
|
||||
key={item.assignmentId}
|
||||
className="flex min-h-[36px] items-center justify-between rounded-md border bg-card p-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{item.assignmentTitle}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(item.submittedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="tabular-nums shrink-0 ml-2">
|
||||
{item.score}/{item.maxScore}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo } from "react"
|
||||
import Link from "next/link"
|
||||
import { BarChart3, Trophy } from "lucide-react"
|
||||
import { BarChart3, TrendingDown, TrendingUp, Minus, Trophy } from "lucide-react"
|
||||
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
||||
@@ -10,6 +10,30 @@ import { Badge } from "@/shared/components/ui/badge"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
||||
|
||||
type TrendDirection = "up" | "down" | "flat" | "unknown"
|
||||
|
||||
const computeTrend = (trend: { percentage: number }[]): TrendDirection => {
|
||||
if (trend.length < 2) return "unknown"
|
||||
const latest = trend[trend.length - 1].percentage
|
||||
const prev = trend[trend.length - 2].percentage
|
||||
if (latest > prev) return "up"
|
||||
if (latest < prev) return "down"
|
||||
return "flat"
|
||||
}
|
||||
|
||||
const TrendIcon = ({ direction }: { direction: TrendDirection }) => {
|
||||
if (direction === "up") {
|
||||
return <TrendingUp className="h-4 w-4 text-emerald-600" aria-label="improving" />
|
||||
}
|
||||
if (direction === "down") {
|
||||
return <TrendingDown className="h-4 w-4 text-destructive" aria-label="declining" />
|
||||
}
|
||||
if (direction === "flat") {
|
||||
return <Minus className="h-4 w-4 text-muted-foreground" aria-label="stable" />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function ChildGradeSummary({
|
||||
grades,
|
||||
childId,
|
||||
@@ -22,14 +46,16 @@ export function ChildGradeSummary({
|
||||
const hasGradeTrend = grades.trend.length > 0
|
||||
const ranking = grades.ranking
|
||||
const latestGrade = grades.trend[grades.trend.length - 1]
|
||||
const trendDirection = computeTrend(grades.trend)
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
grades.trend.map((item) => ({
|
||||
grades.trend.map((item, idx) => ({
|
||||
title: item.assignmentTitle,
|
||||
score: Math.round(item.percentage),
|
||||
fullTitle: item.assignmentTitle,
|
||||
date: formatDate(item.submittedAt),
|
||||
index: idx + 1,
|
||||
rawScore: item.score,
|
||||
maxScore: item.maxScore,
|
||||
})),
|
||||
@@ -47,15 +73,21 @@ export function ChildGradeSummary({
|
||||
emptyDescription="Finish and submit assignments to see score trend."
|
||||
emptyClassName="h-48"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="space-y-4"
|
||||
aria-label={`Grade trend chart for ${childName}, ${grades.trend.length} records`}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
<BarChart3 className="h-3 w-3" aria-hidden />
|
||||
Latest Score
|
||||
</div>
|
||||
<div className="text-xl font-semibold tabular-nums">
|
||||
{latestGrade ? `${Math.round(latestGrade.percentage)}%` : "-"}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-xl font-semibold tabular-nums">
|
||||
{latestGrade ? `${Math.round(latestGrade.percentage)}%` : "-"}
|
||||
</div>
|
||||
{hasGradeTrend ? <TrendIcon direction={trendDirection} /> : null}
|
||||
</div>
|
||||
{latestGrade ? (
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
@@ -65,13 +97,17 @@ export function ChildGradeSummary({
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Trophy className="h-3 w-3" />
|
||||
<Trophy className="h-3 w-3" aria-hidden />
|
||||
Class Rank
|
||||
</div>
|
||||
<div className="text-xl font-semibold tabular-nums">
|
||||
{ranking ? `${ranking.rank}/${ranking.classSize}` : "-"}
|
||||
</div>
|
||||
{ranking ? <div className="text-xs text-muted-foreground">Current position</div> : null}
|
||||
{ranking ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Top {Math.ceil((ranking.rank / ranking.classSize) * 100)}%
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<Link
|
||||
key={r.assignmentId}
|
||||
href={`/parent/children/${childId}?tab=grades`}
|
||||
className="flex items-center justify-between rounded-md border bg-card p-2 hover:bg-muted/50 transition-colors"
|
||||
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-2 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm truncate">{r.assignmentTitle}</div>
|
||||
|
||||
@@ -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<number, ChildWeeklyScheduleItem[]> = {}
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
{childName}'s Weekly Schedule
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{weekdays.map((w) => {
|
||||
const dayItems = grouped[w]
|
||||
const today = isToday(w)
|
||||
const label = WEEKDAY_LABELS[toWeekdayIndex(w)]
|
||||
return (
|
||||
<div
|
||||
key={w}
|
||||
className={cn(
|
||||
"rounded-md border p-3",
|
||||
today && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
{today ? (
|
||||
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
Today
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ScheduleList
|
||||
items={dayItems.map((d) => ({
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
{childName}'s Today Schedule
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
135
src/modules/parent/components/parent-attention-banner.tsx
Normal file
135
src/modules/parent/components/parent-attention-banner.tsx
Normal file
@@ -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<AttentionItem["variant"], string> = {
|
||||
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 (
|
||||
<Card
|
||||
className={cn(
|
||||
"p-4",
|
||||
hasUrgent && "border-destructive/40 bg-destructive/5",
|
||||
)}
|
||||
aria-label="Items needing attention"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasUrgent ? (
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" aria-hidden />
|
||||
) : (
|
||||
<Bell className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{hasUrgent ? "Needs attention" : "Nothing urgent"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isUrgent = item.variant === "danger" && item.count > 0
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"inline-flex h-9 min-w-[120px] items-center justify-between gap-2 rounded-md border px-3 text-xs font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
VARIANT_STYLES[item.variant],
|
||||
isUrgent && "font-semibold",
|
||||
)}
|
||||
aria-label={`${item.label}: ${item.count}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="tabular-nums">{item.count}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export function ParentChildrenDataPage<T>({
|
||||
noRecordsDescription,
|
||||
items,
|
||||
renderItem,
|
||||
headerExtra,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
@@ -23,6 +24,8 @@ export function ParentChildrenDataPage<T>({
|
||||
noRecordsDescription: string
|
||||
items: T[]
|
||||
renderItem: (item: T) => React.ReactNode
|
||||
/** 渲染在标题与列表之间的额外内容(如预警横幅)。 */
|
||||
headerExtra?: React.ReactNode
|
||||
}) {
|
||||
const hasItems = items.length > 0
|
||||
|
||||
@@ -33,6 +36,8 @@ export function ParentChildrenDataPage<T>({
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
{headerExtra}
|
||||
|
||||
{!hasItems ? (
|
||||
<EmptyState
|
||||
title={noRecordsTitle}
|
||||
|
||||
85
src/modules/parent/components/parent-export-button.tsx
Normal file
85
src/modules/parent/components/parent-export-button.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Download, Loader2 } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { safeActionCall } from "@/shared/lib/action-utils"
|
||||
import { exportGradesAction } from "@/modules/grades/actions"
|
||||
|
||||
/**
|
||||
* 家长视角的成绩导出按钮。
|
||||
* v4-P1-12: 接入 exportGradesAction,支持按 studentId 导出子女成绩。
|
||||
*/
|
||||
export function ParentExportButton({
|
||||
studentId,
|
||||
studentName,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
}: {
|
||||
studentId: string
|
||||
studentName: string
|
||||
variant?: "default" | "outline" | "secondary" | "ghost"
|
||||
size?: "default" | "sm" | "lg" | "icon"
|
||||
}) {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
const handleExport = async () => {
|
||||
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 (
|
||||
<Button variant={variant} size={size} disabled>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Exporting...
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={handleExport}
|
||||
aria-label={`Export ${studentName}'s grades`}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -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<string[]> => {
|
||||
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)))
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user