feat(admin-portal): 完成参考项目差距闭环 - 4 批次补齐 + Vitest 测试
差距分析闭环(workline §7-§8): - P0:审计子模块 3 页 + 学校组织 7 页 - P1:系统设置 4 Card + 公告管理 + 邀请码管理 - P2:文件管理 + AI 配置 - P6:5 测试文件 / 69 用例全过 共享基础设施: - permissions +11 权限点 +14 路由 - view-models +20 接口 - i18n +100 key - graphql-client +25 operation - fixtures/handlers +15 mock +20 handler 质量:typecheck + lint 零错误,vitest 69/69 通过,arch.db 已更新
This commit is contained in:
106
apps/admin-portal/src/app/admin/school/academic-year/page.tsx
Normal file
106
apps/admin-portal/src/app/admin/school/academic-year/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { useAcademicYears } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { AcademicYearViewModel } from "@/types/view-models";
|
||||
|
||||
function CurrentBadge(): ReactNode {
|
||||
const color = "var(--color-success)";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
fontSize: 11,
|
||||
borderRadius: 10,
|
||||
background: `${color}20`,
|
||||
color,
|
||||
border: `1px solid ${color}40`,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{t("admin.school.currentYear")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AcademicYearPage(): ReactNode {
|
||||
const { data, loading, error } = useAcademicYears();
|
||||
|
||||
return (
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{data.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
"学年",
|
||||
t("admin.school.startDate"),
|
||||
t("admin.school.endDate"),
|
||||
"第一学期",
|
||||
"第二学期",
|
||||
t("admin.common.status"),
|
||||
]}
|
||||
>
|
||||
{data.map((year: AcademicYearViewModel) => (
|
||||
<TableRow key={year.id}>
|
||||
<TableCell
|
||||
style={{
|
||||
fontWeight: year.isCurrent ? 600 : 500,
|
||||
color: year.isCurrent
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
{year.name}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{year.startDate}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{year.endDate}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{year.firstSemesterStart} ~ {year.firstSemesterEnd}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{year.secondSemesterStart} ~ {year.secondSemesterEnd}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{year.isCurrent ? <CurrentBadge /> : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
);
|
||||
}
|
||||
77
apps/admin-portal/src/app/admin/school/departments/page.tsx
Normal file
77
apps/admin-portal/src/app/admin/school/departments/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useDepartments, useSchools } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export default function DepartmentsPage(): ReactNode {
|
||||
const [schoolId, setSchoolId] = useState<string>("");
|
||||
const { data: schools, loading: schoolsLoading } = useSchools();
|
||||
const { data, loading, error } = useDepartments(schoolId || undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Select
|
||||
value={schoolId}
|
||||
onChange={(e) => setSchoolId(e.target.value)}
|
||||
disabled={schoolsLoading}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.common.all")}
|
||||
{t("admin.common.school")}
|
||||
</option>
|
||||
{schools.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{data.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
"部门",
|
||||
t("admin.common.school"),
|
||||
t("admin.school.leader"),
|
||||
t("admin.school.memberCount"),
|
||||
t("admin.school.subject"),
|
||||
]}
|
||||
>
|
||||
{data.map((dept) => (
|
||||
<TableRow key={dept.id}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{dept.name}
|
||||
</TableCell>
|
||||
<TableCell>{dept.schoolName}</TableCell>
|
||||
<TableCell>{dept.leaderName ?? "—"}</TableCell>
|
||||
<TableCell>{dept.memberCount}</TableCell>
|
||||
<TableCell>{dept.subject ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
250
apps/admin-portal/src/app/admin/school/grades/insights/page.tsx
Normal file
250
apps/admin-portal/src/app/admin/school/grades/insights/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useMemo } from "react";
|
||||
import { useGrades, useGradeInsights } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface SummaryCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, suffix }: SummaryCardProps): ReactNode {
|
||||
return (
|
||||
<PaperCard className="p-4">
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-ink-muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 28,
|
||||
color: "var(--color-ink)",
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
{suffix && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: "var(--color-ink-muted)",
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</PaperCard>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPercent(rate: number): string {
|
||||
return `${(rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export default function GradeInsightsPage(): ReactNode {
|
||||
const { data: grades, loading: gradesLoading } = useGrades();
|
||||
const [gradeId, setGradeId] = useState<string>("");
|
||||
|
||||
const selectedGradeId = gradeId || (grades[0]?.id ?? null);
|
||||
const { data, loading, error } = useGradeInsights(selectedGradeId);
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return [...data.classRankings].sort((a, b) => a.rank - b.rank);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Select
|
||||
value={selectedGradeId ?? ""}
|
||||
onChange={(e) => setGradeId(e.target.value)}
|
||||
disabled={gradesLoading || grades.length === 0}
|
||||
>
|
||||
{grades.length === 0 && <option value="">暂无年级</option>}
|
||||
{grades.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}({g.schoolName})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</PaperCard>
|
||||
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && !data && (
|
||||
<PaperCard className="p-4">
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
</PaperCard>
|
||||
)}
|
||||
{data && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 全校概览 */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.school.insights.summary")} — {data.gradeName}
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<SummaryCard
|
||||
label={t("admin.dashboard.totalStudents")}
|
||||
value={data.schoolWideSummary.totalStudents}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("admin.dashboard.totalClasses")}
|
||||
value={data.schoolWideSummary.totalClasses}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("admin.school.insights.avgScore")}
|
||||
value={data.schoolWideSummary.avgScore.toFixed(1)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label={t("admin.school.insights.passingRate")}
|
||||
value={formatPercent(data.schoolWideSummary.passingRate)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 作业时间线 */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.school.insights.homework")}
|
||||
</h3>
|
||||
{data.homeworkTimeline.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
"作业",
|
||||
t("admin.common.class"),
|
||||
t("admin.school.insights.avgScore"),
|
||||
"提交情况",
|
||||
t("admin.school.endDate"),
|
||||
]}
|
||||
>
|
||||
{data.homeworkTimeline.map((hw) => (
|
||||
<TableRow key={hw.homeworkId}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{hw.title}
|
||||
</TableCell>
|
||||
<TableCell>{hw.className}</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{hw.avgScore.toFixed(1)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||
>
|
||||
{hw.submittedCount}/{hw.totalCount}
|
||||
</TableCell>
|
||||
<TableCell style={{ whiteSpace: "nowrap" }}>
|
||||
{hw.dueDate}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</PaperCard>
|
||||
|
||||
{/* 班级排名 */}
|
||||
<PaperCard className="p-4">
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 16,
|
||||
color: "var(--color-ink)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{t("admin.school.insights.rankings")}
|
||||
</h3>
|
||||
{ranked.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.school.insights.rank"),
|
||||
t("admin.common.class"),
|
||||
t("admin.school.insights.avgScore"),
|
||||
t("admin.school.insights.delta"),
|
||||
]}
|
||||
>
|
||||
{ranked.map((cls) => {
|
||||
const deltaColor =
|
||||
cls.delta > 0
|
||||
? "var(--color-success)"
|
||||
: cls.delta < 0
|
||||
? "var(--color-danger)"
|
||||
: "var(--color-ink-muted)";
|
||||
const deltaSign = cls.delta > 0 ? "+" : "";
|
||||
return (
|
||||
<TableRow key={cls.classId}>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-serif)",
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{cls.rank}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{cls.className}
|
||||
</TableCell>
|
||||
<TableCell style={{ fontFamily: "var(--font-mono)" }}>
|
||||
{cls.avgScore.toFixed(1)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
color: deltaColor,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{deltaSign}
|
||||
{cls.delta.toFixed(1)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
)}
|
||||
</PaperCard>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
apps/admin-portal/src/app/admin/school/grades/page.tsx
Normal file
85
apps/admin-portal/src/app/admin/school/grades/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useMemo } from "react";
|
||||
import { useGrades, useSchools } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
Select,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { GradeViewModel } from "@/types/view-models";
|
||||
|
||||
export default function GradesPage(): ReactNode {
|
||||
const [schoolId, setSchoolId] = useState<string>("");
|
||||
const { data: schools, loading: schoolsLoading } = useSchools();
|
||||
const { data, loading, error } = useGrades(schoolId || undefined);
|
||||
|
||||
const sorted = useMemo<GradeViewModel[]>(() => {
|
||||
if (!data) return [];
|
||||
return [...data].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Select
|
||||
value={schoolId}
|
||||
onChange={(e) => setSchoolId(e.target.value)}
|
||||
disabled={schoolsLoading}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.common.all")}
|
||||
{t("admin.common.school")}
|
||||
</option>
|
||||
{schools.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.common.grade"),
|
||||
t("admin.common.school"),
|
||||
t("admin.school.director"),
|
||||
t("admin.common.class"),
|
||||
t("admin.dashboard.totalStudents"),
|
||||
t("admin.viewports.sortOrder"),
|
||||
]}
|
||||
>
|
||||
{sorted.map((grade) => (
|
||||
<TableRow key={grade.id}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{grade.name}
|
||||
</TableCell>
|
||||
<TableCell>{grade.schoolName}</TableCell>
|
||||
<TableCell>{grade.directorName ?? "—"}</TableCell>
|
||||
<TableCell>{grade.classCount}</TableCell>
|
||||
<TableCell>{grade.studentCount}</TableCell>
|
||||
<TableCell>{grade.sortOrder}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
apps/admin-portal/src/app/admin/school/layout.tsx
Normal file
87
apps/admin-portal/src/app/admin/school/layout.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { PageHeader } from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface TabItem {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TABS: TabItem[] = [
|
||||
{ href: "/admin/school/schools", label: t("admin.school.schools") },
|
||||
{ href: "/admin/school/grades", label: t("admin.school.grades") },
|
||||
{
|
||||
href: "/admin/school/grades/insights",
|
||||
label: t("admin.school.gradeInsights"),
|
||||
},
|
||||
{ href: "/admin/school/departments", label: t("admin.school.departments") },
|
||||
{
|
||||
href: "/admin/school/academic-year",
|
||||
label: t("admin.school.academicYear"),
|
||||
},
|
||||
];
|
||||
|
||||
export default function SchoolLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
|
||||
<PageHeader
|
||||
title={t("admin.school.title")}
|
||||
description="学校、年级、部门与学年学期管理"
|
||||
/>
|
||||
|
||||
<nav
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
borderBottom: "1px solid var(--color-rule)",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const isActive =
|
||||
pathname === tab.href ||
|
||||
(tab.href !== "/admin/school/grades" &&
|
||||
pathname.startsWith(tab.href));
|
||||
// 年级管理 / 年级洞察 都在 /admin/school/grades 下,需要精确区分
|
||||
const exactActive =
|
||||
tab.href === "/admin/school/grades"
|
||||
? pathname === "/admin/school/grades"
|
||||
: isActive;
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
fontSize: 13,
|
||||
color: exactActive
|
||||
? "var(--color-ink)"
|
||||
: "var(--color-ink-muted)",
|
||||
textDecoration: "none",
|
||||
borderBottom: exactActive
|
||||
? "2px solid var(--color-accent)"
|
||||
: "2px solid transparent",
|
||||
marginBottom: "-1px",
|
||||
fontWeight: exactActive ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
apps/admin-portal/src/app/admin/school/page.tsx
Normal file
5
apps/admin-portal/src/app/admin/school/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SchoolPage(): never {
|
||||
redirect("/admin/school/schools");
|
||||
}
|
||||
103
apps/admin-portal/src/app/admin/school/schools/page.tsx
Normal file
103
apps/admin-portal/src/app/admin/school/schools/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState, useMemo } from "react";
|
||||
import { useSchools } from "@/hooks/use-school";
|
||||
import {
|
||||
PaperCard,
|
||||
Input,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { SchoolViewModel } from "@/types/view-models";
|
||||
|
||||
export default function SchoolsPage(): ReactNode {
|
||||
const { data, loading, error } = useSchools();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo<SchoolViewModel[]>(() => {
|
||||
if (!data) return [];
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return data;
|
||||
return data.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.code.toLowerCase().includes(q) ||
|
||||
(s.principalName ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [data, search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaperCard className="p-4 mb-4">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.common.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</PaperCard>
|
||||
|
||||
<PaperCard className="p-4">
|
||||
{loading && <LoadingState />}
|
||||
{error && <ErrorState message={error.message} />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState message={t("admin.common.empty")} />
|
||||
) : (
|
||||
<Table
|
||||
headers={[
|
||||
t("admin.common.school"),
|
||||
t("admin.system.schoolCode"),
|
||||
t("admin.system.address"),
|
||||
t("admin.system.contactPhone"),
|
||||
t("admin.system.contactEmail"),
|
||||
t("admin.school.principal"),
|
||||
t("admin.common.grade"),
|
||||
t("admin.common.class"),
|
||||
t("admin.dashboard.totalStudents"),
|
||||
t("admin.dashboard.totalTeachers"),
|
||||
]}
|
||||
>
|
||||
{filtered.map((school) => (
|
||||
<TableRow key={school.id}>
|
||||
<TableCell style={{ fontWeight: 500 }}>
|
||||
{school.name}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{school.code}
|
||||
</TableCell>
|
||||
<TableCell>{school.address}</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{school.contactPhone}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
{school.contactEmail}
|
||||
</TableCell>
|
||||
<TableCell>{school.principalName ?? "—"}</TableCell>
|
||||
<TableCell>{school.gradeCount}</TableCell>
|
||||
<TableCell>{school.classCount}</TableCell>
|
||||
<TableCell>{school.studentCount}</TableCell>
|
||||
<TableCell>{school.teacherCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PaperCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user