parent: - Add parent-student-attendance-detail component auth: - Add actions, data-access, schema, services, types onboarding: - Add parent-children-form and hooks directory files: - Add actions, schema, hooks directory notifications: - Add schema and schema test adaptive-practice: - Add answer-input, answer-result, practice-result-view, practice-starter-with-nav - Add question-card, question-content, lib and services directories ai: - Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
225 lines
7.7 KiB
TypeScript
225 lines
7.7 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState } from "react"
|
|
import {
|
|
BarChart3,
|
|
CalendarDays,
|
|
ClipboardList,
|
|
GraduationCap,
|
|
Mail,
|
|
Stethoscope,
|
|
} from "lucide-react"
|
|
import { useTranslations } from "next-intl"
|
|
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
|
import { cn } from "@/shared/lib/utils"
|
|
import type { ChildDashboardData } from "@/modules/parent/types"
|
|
import { ChildGradeDetail } from "./child-grade-detail"
|
|
import { ChildGradeSummary } from "./child-grade-summary"
|
|
import { ChildHomeworkDetail } from "./child-homework-detail"
|
|
import { ChildHomeworkSummary } from "./child-homework-summary"
|
|
import { ChildScheduleCard } from "./child-schedule-card"
|
|
import { ChildExamDetail } from "./child-exam-detail"
|
|
|
|
export type ChildDetailTab = "overview" | "homework" | "grades" | "exams" | "schedule" | "attendance" | "diagnostic"
|
|
|
|
const VALID_TABS: ChildDetailTab[] = ["overview", "homework", "grades", "exams", "schedule", "attendance", "diagnostic"]
|
|
|
|
const isTab = (v: string | undefined | null): v is ChildDetailTab =>
|
|
typeof v === "string" && VALID_TABS.some((tab) => tab === v)
|
|
|
|
const resolveTab = (v: string | undefined | null): ChildDetailTab =>
|
|
isTab(v) ? v : "overview"
|
|
|
|
export function ChildDetailPanel({
|
|
child,
|
|
initialTab,
|
|
siblingSwitcher,
|
|
}: {
|
|
child: ChildDashboardData
|
|
initialTab?: string
|
|
siblingSwitcher?: React.ReactNode
|
|
}) {
|
|
const t = useTranslations("parent")
|
|
const { basicInfo, todaySchedule, weeklySchedule, homeworkSummary, gradeTrend, examResults } = child
|
|
const childName = basicInfo.name ?? t("childDetail.defaultName")
|
|
|
|
const [tab, setTab] = useState<ChildDetailTab>(resolveTab(initialTab))
|
|
|
|
const tabs = useMemo(
|
|
() => [
|
|
{ id: "overview" as const, label: t("childDetail.tabs.overview"), icon: ClipboardList },
|
|
{ id: "homework" as const, label: t("childDetail.tabs.homework"), icon: ClipboardList },
|
|
{ id: "grades" as const, label: t("childDetail.tabs.grades"), icon: BarChart3 },
|
|
{ id: "exams" as const, label: t("childDetail.tabs.exams"), icon: GraduationCap },
|
|
{ id: "schedule" as const, label: t("childDetail.tabs.schedule"), icon: CalendarDays },
|
|
{ id: "attendance" as const, label: t("childDetail.tabs.attendance"), icon: CalendarDays },
|
|
{ id: "diagnostic" as const, label: t("childDetail.tabs.diagnostic"), icon: Stethoscope },
|
|
],
|
|
[t],
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{siblingSwitcher}
|
|
|
|
<Tabs
|
|
value={tab}
|
|
onValueChange={(v) => {
|
|
if (isTab(v)) setTab(v)
|
|
}}
|
|
className="w-full"
|
|
>
|
|
<div className="overflow-x-auto">
|
|
<TabsList className="w-full justify-start">
|
|
{tabs.map((tab) => (
|
|
<TabsTrigger
|
|
key={tab.id}
|
|
value={tab.id}
|
|
className="gap-1.5"
|
|
aria-label={t("childDetail.tabAriaLabel", { label: tab.label })}
|
|
>
|
|
<tab.icon className="h-3.5 w-3.5" />
|
|
{tab.label}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</div>
|
|
|
|
<TabsContent value="overview" className="mt-6">
|
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
<div className="md:col-span-1 lg:col-span-2 space-y-6">
|
|
<ChildHomeworkSummary
|
|
summary={homeworkSummary}
|
|
childId={basicInfo.id}
|
|
childName={childName}
|
|
/>
|
|
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
|
|
</div>
|
|
<div className="space-y-6">
|
|
<ChildScheduleCard items={todaySchedule} childName={childName} />
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="homework" className="mt-6">
|
|
<ChildHomeworkDetail
|
|
summary={homeworkSummary}
|
|
childId={basicInfo.id}
|
|
childName={childName}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="grades" className="mt-6">
|
|
<div className="space-y-6">
|
|
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
|
|
<div>
|
|
<h3 className="text-sm font-medium uppercase text-muted-foreground mb-3">
|
|
{t("childDetail.subjectAnalysis")}
|
|
</h3>
|
|
<ChildGradeDetail grades={gradeTrend} />
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="exams" className="mt-6">
|
|
<ChildExamDetail
|
|
examResults={examResults}
|
|
childId={basicInfo.id}
|
|
childName={childName}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="schedule" className="mt-6">
|
|
<ChildScheduleCard
|
|
items={todaySchedule}
|
|
childName={childName}
|
|
weeklyItems={weeklySchedule}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="attendance" className="mt-6">
|
|
<div className="rounded-md border bg-muted/30 p-6 text-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t.rich("childDetail.attendanceHint", {
|
|
link: (chunks) => (
|
|
<a
|
|
href="/parent/attendance"
|
|
className="font-medium text-foreground underline-offset-4 hover:underline"
|
|
>
|
|
{chunks}
|
|
</a>
|
|
),
|
|
})}
|
|
</p>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="diagnostic" className="mt-6">
|
|
<div className="rounded-md border bg-muted/30 p-6 text-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("childDetail.diagnosticHint")}
|
|
</p>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<div className="flex justify-end">
|
|
<Button asChild variant="ghost" size="sm" className="gap-2">
|
|
<a
|
|
href={`/messages?studentId=${basicInfo.id}`}
|
|
aria-label={t("childDetail.contactTeacherAria", { name: childName })}
|
|
>
|
|
<Mail className="h-4 w-4" />
|
|
{t("childDetail.contactTeacher")}
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 紧凑的子女切换器(用于详情页头部)。 */
|
|
export function SiblingSwitcher({
|
|
current,
|
|
siblings,
|
|
}: {
|
|
current: { id: string; name: string | null }
|
|
siblings: Array<{ id: string; name: string | null }>
|
|
}) {
|
|
const t = useTranslations("parent")
|
|
if (siblings.length <= 1) return null
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
|
|
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">
|
|
{t("childDetail.switchChild")}
|
|
</span>
|
|
<div className="flex flex-wrap gap-1">
|
|
{siblings.map((s) => {
|
|
const isActive = s.id === current.id
|
|
const label = s.name ?? t("childDetail.defaultName")
|
|
return (
|
|
<a
|
|
key={s.id}
|
|
href={`/parent/children/${s.id}`}
|
|
aria-current={isActive ? "page" : undefined}
|
|
aria-label={t("childDetail.viewDetailsAria", { name: label })}
|
|
className={cn(
|
|
"inline-flex min-h-[40px] items-center rounded-md px-3 text-sm font-medium transition-colors",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
isActive
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-background text-foreground hover:bg-muted",
|
|
)}
|
|
>
|
|
{label}
|
|
</a>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|