feat(app): add error/loading boundaries and update dashboard routes
- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes - Add dashboard-error-fallback and dashboard-loading-skeleton components - Add student/learning page, parent/leave routes, teacher textbook components - Update existing app routes across auth, dashboard, and API endpoints - Update proxy middleware and next-auth type declarations
This commit is contained in:
24
src/app/(dashboard)/parent/attendance/error.tsx
Normal file
24
src/app/(dashboard)/parent/attendance/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function ParentAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("attendance")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/app/(dashboard)/parent/attendance/loading.tsx
Normal file
32
src/app/(dashboard)/parent/attendance/loading.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-8 p-6 md:p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, j) => (
|
||||
<Skeleton key={j} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/app/(dashboard)/parent/children/[studentId]/loading.tsx
Normal file
45
src/app/(dashboard)/parent/children/[studentId]/loading.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-16 w-16 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-9 w-full max-w-md" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { verifyParentChildRelation, getChildDashboardData } from "@/modules/parent/data-access"
|
||||
import { getSearchParam } from "@/shared/lib/utils"
|
||||
import {
|
||||
verifyParentChildRelation,
|
||||
getChildDashboardData,
|
||||
getChildNameList,
|
||||
} from "@/modules/parent/data-access"
|
||||
import { ChildDetailHeader } from "@/modules/parent/components/child-detail-header"
|
||||
import { ChildDetailPanel } from "@/modules/parent/components/child-detail-panel"
|
||||
import {
|
||||
ChildDetailPanel,
|
||||
SiblingSwitcher,
|
||||
} from "@/modules/parent/components/child-detail-panel"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ShieldAlert } from "lucide-react"
|
||||
|
||||
@@ -11,10 +19,13 @@ export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ChildDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ studentId: string }>
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||
}) {
|
||||
const { studentId } = await params
|
||||
const sp = await searchParams
|
||||
const ctx = await requireAuth()
|
||||
|
||||
// 校验当前家长与该子女存在关系(同时按 parentId + studentId 过滤,防止跨家庭信息泄露)
|
||||
@@ -38,15 +49,29 @@ export default async function ChildDetailPage({
|
||||
)
|
||||
}
|
||||
|
||||
const child = await getChildDashboardData(studentId, relation)
|
||||
const [child, siblings] = await Promise.all([
|
||||
getChildDashboardData(studentId, relation),
|
||||
getChildNameList(ctx.userId),
|
||||
])
|
||||
if (!child) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const initialTab = getSearchParam(sp, "tab")
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
<ChildDetailHeader child={child} />
|
||||
<ChildDetailPanel child={child} />
|
||||
<ChildDetailPanel
|
||||
child={child}
|
||||
initialTab={initialTab}
|
||||
siblingSwitcher={
|
||||
<SiblingSwitcher
|
||||
current={{ id: child.basicInfo.id, name: child.basicInfo.name }}
|
||||
siblings={siblings}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function ParentDashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("dashboard")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
export default function ParentDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return <DashboardErrorFallback error={error} reset={reset} />
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Stethoscope } from "lucide-react"
|
||||
import { Stethoscope, AlertCircle } from "lucide-react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -9,9 +9,29 @@ import {
|
||||
ParentChildrenDataPage,
|
||||
ParentNoChildrenPage,
|
||||
} from "@/modules/parent/components/parent-children-data-page"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/** v4-P1-9: 单个子女的诊断数据(成功) */
|
||||
interface ChildDiagnosticSuccessItem {
|
||||
studentId: string
|
||||
studentName: string
|
||||
status: "success"
|
||||
summary: Awaited<ReturnType<typeof getStudentMasterySummary>>
|
||||
reports: Awaited<ReturnType<typeof getDiagnosticReports>>["reports"]
|
||||
}
|
||||
|
||||
/** v4-P1-9: 单个子女的诊断数据(失败) */
|
||||
interface ChildDiagnosticErrorItem {
|
||||
studentId: string
|
||||
studentName: string
|
||||
status: "error"
|
||||
}
|
||||
|
||||
type ChildDiagnosticItem = ChildDiagnosticSuccessItem | ChildDiagnosticErrorItem
|
||||
|
||||
export default async function ParentDiagnosticPage() {
|
||||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||
|
||||
@@ -27,25 +47,44 @@ export default async function ParentDiagnosticPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
|
||||
// 预先查询所有子女的姓名,用于错误卡片展示
|
||||
const childrenIds = ctx.dataScope.childrenIds
|
||||
const nameMap = await getUserNamesByIds(childrenIds)
|
||||
|
||||
// v4-P1-9: 使用 allSettled 容错,但保留 rejected 项渲染错误卡片
|
||||
const results = await Promise.allSettled(
|
||||
ctx.dataScope.childrenIds.map(async (id) => {
|
||||
const [summary, reports] = await Promise.all([
|
||||
childrenIds.map(async (id) => {
|
||||
const [summary, reportsResult] = await Promise.all([
|
||||
getStudentMasterySummary(id),
|
||||
getDiagnosticReports({ studentId: id }),
|
||||
// v4-P1-3: 家长仅可见已发布报告,避免草稿泄露
|
||||
getDiagnosticReports(
|
||||
{ studentId: id, status: "published" },
|
||||
ctx.dataScope,
|
||||
),
|
||||
])
|
||||
return { summary, reports, studentId: id }
|
||||
return { summary, reports: reportsResult.reports, studentId: id }
|
||||
}),
|
||||
)
|
||||
const validItems = results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<{
|
||||
summary: Awaited<ReturnType<typeof getStudentMasterySummary>>
|
||||
reports: Awaited<ReturnType<typeof getDiagnosticReports>>
|
||||
studentId: string
|
||||
}> => r.status === "fulfilled",
|
||||
)
|
||||
.map((r) => r.value)
|
||||
|
||||
const items: ChildDiagnosticItem[] = results.map((r, idx) => {
|
||||
const studentId = childrenIds[idx]
|
||||
const studentName = nameMap.get(studentId)?.name ?? "Unknown student"
|
||||
if (r.status === "fulfilled") {
|
||||
return {
|
||||
studentId,
|
||||
studentName,
|
||||
status: "success" as const,
|
||||
summary: r.value.summary,
|
||||
reports: r.value.reports,
|
||||
}
|
||||
}
|
||||
// v4-P1-9: rejected 项不再静默丢弃,渲染错误卡片
|
||||
return {
|
||||
studentId,
|
||||
studentName,
|
||||
status: "error" as const,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<ParentChildrenDataPage
|
||||
@@ -54,15 +93,32 @@ export default async function ParentDiagnosticPage() {
|
||||
icon={Stethoscope}
|
||||
noRecordsTitle="No diagnostic data"
|
||||
noRecordsDescription="Your children don't have any diagnostic data yet."
|
||||
items={validItems}
|
||||
renderItem={({ summary, reports }) => (
|
||||
items={items}
|
||||
renderItem={(item) => (
|
||||
<>
|
||||
<div className="border-b pb-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{summary?.studentName ?? "Unknown student"}
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold">{item.studentName}</h3>
|
||||
</div>
|
||||
<StudentDiagnosticView summary={summary} reports={reports} />
|
||||
{item.status === "success" ? (
|
||||
<StudentDiagnosticView
|
||||
summary={item.summary}
|
||||
reports={item.reports}
|
||||
practiceHrefBase={null}
|
||||
/>
|
||||
) : (
|
||||
// v4-P1-9: 错误卡片,提示家长该子女数据加载失败
|
||||
<Card className="border-destructive/50">
|
||||
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
Failed to load diagnostic data for {item.studentName}.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Please refresh the page or contact the school administrator if the problem persists.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
23
src/app/(dashboard)/parent/error.tsx
Normal file
23
src/app/(dashboard)/parent/error.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
|
||||
export default function ParentError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6 md:p-8">
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title="Something went wrong"
|
||||
description={error.message || "An unexpected error occurred. Please try again."}
|
||||
action={{ label: "Try again", onClick: reset }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/app/(dashboard)/parent/grades/error.tsx
Normal file
27
src/app/(dashboard)/parent/grades/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function ParentGradesError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="子女成绩页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
action={{
|
||||
label: "重试",
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/app/(dashboard)/parent/grades/loading.tsx
Normal file
32
src/app/(dashboard)/parent/grades/loading.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-8 p-6 md:p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, j) => (
|
||||
<Skeleton key={j} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||
import { getClassAverageTrend } from "@/modules/grades/data-access-ranking"
|
||||
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
|
||||
import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card"
|
||||
import {
|
||||
ParentChildrenDataPage,
|
||||
ParentNoChildrenPage,
|
||||
} from "@/modules/parent/components/parent-children-data-page"
|
||||
import { ParentExportButton } from "@/modules/parent/components/parent-export-button"
|
||||
import { GraduationCap } from "lucide-react"
|
||||
import type { ClassAverageTrendResult } from "@/modules/grades/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
interface ChildGradeItem {
|
||||
studentId: string
|
||||
summary: NonNullable<Awaited<ReturnType<typeof getStudentGradeSummary>>>
|
||||
classAverageTrend: ClassAverageTrendResult | null
|
||||
}
|
||||
|
||||
export default async function ParentGradesPage() {
|
||||
const ctx = await getAuthContext()
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
|
||||
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||
return (
|
||||
@@ -26,26 +37,49 @@ export default async function ParentGradesPage() {
|
||||
|
||||
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
|
||||
const results = await Promise.allSettled(
|
||||
ctx.dataScope.childrenIds.map((id) => getStudentGradeSummary(id)),
|
||||
ctx.dataScope.childrenIds.map(async (id) => {
|
||||
const [summary, classAverageTrend] = await Promise.all([
|
||||
getStudentGradeSummary(id, ctx.dataScope),
|
||||
// v3-P2-8:家长页面补齐趋势图,复用班级平均对比线
|
||||
getClassAverageTrend(id, undefined, undefined, ctx.dataScope),
|
||||
])
|
||||
return { summary, classAverageTrend, studentId: id }
|
||||
}),
|
||||
)
|
||||
const validSummaries = results
|
||||
const validItems: ChildGradeItem[] = results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<NonNullable<Awaited<ReturnType<typeof getStudentGradeSummary>>>> =>
|
||||
r.status === "fulfilled" && r.value !== null,
|
||||
(
|
||||
r,
|
||||
): r is PromiseFulfilledResult<{
|
||||
summary: Awaited<ReturnType<typeof getStudentGradeSummary>>
|
||||
classAverageTrend: ClassAverageTrendResult | null
|
||||
studentId: string
|
||||
}> => r.status === "fulfilled" && r.value.summary !== null,
|
||||
)
|
||||
.map((r) => r.value)
|
||||
.map((r) => ({
|
||||
studentId: r.value.studentId,
|
||||
summary: r.value.summary as NonNullable<typeof r.value.summary>,
|
||||
classAverageTrend: r.value.classAverageTrend,
|
||||
}))
|
||||
|
||||
return (
|
||||
<ParentChildrenDataPage
|
||||
title="Children Grades"
|
||||
description="View your children's grade records."
|
||||
description="Compare grades across all your children. For single-child analysis, open the child's detail page."
|
||||
icon={GraduationCap}
|
||||
noRecordsTitle="No grade records"
|
||||
noRecordsDescription="Your children don't have any grade records yet."
|
||||
items={validSummaries}
|
||||
renderItem={(summary) => (
|
||||
items={validItems}
|
||||
renderItem={({ studentId, summary, classAverageTrend }) => (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<h3 className="text-lg font-semibold">{summary.studentName}</h3>
|
||||
{/* v4-P1-12: 接入 exportGradesAction,支持按 studentId 导出 */}
|
||||
<ParentExportButton studentId={studentId} studentName={summary.studentName} />
|
||||
</div>
|
||||
{summary.records.length > 0 && (
|
||||
<GradeTrendCard summary={summary} classAverageData={classAverageTrend} />
|
||||
)}
|
||||
<StudentGradeSummary summary={summary} />
|
||||
</>
|
||||
)}
|
||||
|
||||
24
src/app/(dashboard)/parent/leave/loading.tsx
Normal file
24
src/app/(dashboard)/parent/leave/loading.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
src/app/(dashboard)/parent/leave/page.tsx
Normal file
66
src/app/(dashboard)/parent/leave/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import Link from "next/link"
|
||||
import { CalendarDays, ArrowLeft, Phone, Mail } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ParentLeavePage() {
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Leave Request</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Submit a leave request for your child.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
|
||||
<Link href="/parent/dashboard" aria-label="Back to Dashboard">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
Online Leave Request
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<EmptyState
|
||||
icon={CalendarDays}
|
||||
title="Coming soon"
|
||||
description="The online leave request feature is being developed and will be available soon. For now, please contact the homeroom teacher or school office directly."
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
<div className="rounded-md border bg-muted/30 p-4 space-y-2">
|
||||
<div className="text-sm font-medium">Contact options</div>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4" aria-hidden />
|
||||
<span>Call the school office during working hours</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" aria-hidden />
|
||||
<span>Send a message to the homeroom teacher via the Messages page</span>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/messages"
|
||||
className="inline-flex h-9 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
Go to Messages
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user