Files
NextEdu/src/modules/parent/components/child-card.tsx
SpecialX 1abf58c0b6 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
2026-06-23 17:37:49 +08:00

149 lines
5.7 KiB
TypeScript

import Link from "next/link"
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"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
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}`}
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={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} />
<AvatarFallback>{getInitials(basicInfo.name)}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-1">
<CardTitle className="text-base truncate">{basicInfo.name ?? "Unnamed"}</CardTitle>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{basicInfo.className ? (
<Badge variant="secondary" className="text-xs">
{basicInfo.className}
</Badge>
) : null}
{basicInfo.gradeName ? <span>{basicInfo.gradeName}</span> : null}
{basicInfo.relation ? <span>· {basicInfo.relation}</span> : null}
</div>
</div>
{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" aria-hidden />
Pending
</div>
<div className="text-lg font-semibold tabular-nums">
{homeworkSummary.pendingCount}
</div>
</div>
<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-bold tabular-nums",
hasOverdue && "text-destructive",
)}
>
{homeworkSummary.overdueCount}
</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">
<GraduationCap className="h-3 w-3" aria-hidden />
Avg
</div>
<div className="text-lg font-semibold tabular-nums">
{ranking ? `${Math.round(ranking.percentage)}%` : "-"}
</div>
</div>
</div>
{latestGrade ? (
<div className="text-xs text-muted-foreground flex items-center gap-1">
<span className="shrink-0">Latest:</span>
<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}
</CardContent>
</Card>
</Link>
)
}