Compare commits

..

9 Commits

Author SHA1 Message Date
SpecialX
bb4555f611 feat: enhance textbook reader with anchor text support and improve knowledge point management 2026-01-16 10:22:16 +08:00
SpecialX
9bfc621d3f feat(classes): optimize teacher dashboard ui and implement grade management 2026-01-14 13:59:11 +08:00
SpecialX
ade8d4346c feat(dashboard): optimize teacher dashboard ui and layout
- Refactor layout: move Needs Grading to main column, Homework to sidebar
- Enhance TeacherStats: replace static counts with actionable metrics (Needs Grading, Active Assignments, Avg Score, Submission Rate)
- Update RecentSubmissions: table view with quick grade actions and late status
- Update TeacherSchedule: vertical timeline view with scroll hints
- Update TeacherHomeworkCard: compact list view
- Integrate Recharts: add TeacherGradeTrends chart and shared chart component
- Update documentation
2026-01-12 11:38:27 +08:00
SpecialX
8577280ab2 feat: 首次登录引导与注册修复
All checks were successful
CI / build-and-test (push) Successful in 20m12s
CI / deploy (push) Successful in 1m18s
2026-01-12 10:49:30 +08:00
SpecialX
15fcf2bc78 BUG FIX && 权限验证
All checks were successful
CI / build-and-test (push) Successful in 18m11s
CI / deploy (push) Successful in 1m19s
2026-01-09 14:10:04 +08:00
SpecialX
15d9ea9cb8 fix: login suspense + migrate middleware to proxy 2026-01-08 11:35:15 +08:00
SpecialX
f513cf5399 Fix Bug
All checks were successful
CI / build-and-test (push) Successful in 22m3s
CI / deploy (push) Successful in 1m29s
Fix Not Suspense for Login Form
2026-01-08 11:29:23 +08:00
SpecialX
57807def37 完整性更新
Some checks failed
CI / build-and-test (push) Failing after 3m50s
CI / deploy (push) Has been skipped
现在已经实现了大部分基础功能
2026-01-08 11:14:03 +08:00
SpecialX
0da2eac0b4 强制buildSSR
All checks were successful
CI / build-and-test (push) Successful in 16m13s
CI / deploy (push) Successful in 1m47s
2025-12-31 14:01:12 +08:00
213 changed files with 44214 additions and 2246 deletions

View File

@@ -13,6 +13,9 @@ jobs:
build-and-test:
runs-on: CDCD
container: dockerreg.eazygame.cn/node:22-bookworm
env:
SKIP_ENV_VALIDATION: "1"
NEXT_TELEMETRY_DISABLED: "1"
steps:
- name: Checkout
@@ -121,5 +124,8 @@ jobs:
-p 8015:3000 \
--restart unless-stopped \
--name nextjs-app \
-e NODE_ENV=production \
-e DATABASE_URL=${{ secrets.DATABASE_URL }} \
-e NEXT_TELEMETRY_DISABLED=1 \
nextjs-app

View File

@@ -330,3 +330,31 @@ jobs:
# 构建时跳过 ESLint/TS 检查 (因为已经在 quality-check job 做过了,加速构建)
NEXT_TELEMETRY_DISABLED: 1
```
## 工作记录2026-01-12
### 注册与首次登录引导
- 注册流程调整为“仅创建账户并跳转登录”,首次登录后通过全局弹窗分步骤完成资料配置
- 全局引导弹窗包含:选择角色 → 通用信息(姓名/电话/住址)→ 角色信息(可跳过,后续在设置中补全)→ 完成
- 新增/补齐用户扩展字段与迁移phone、address、gender、age、gradeId、departmentId、onboardedAt
- 新增引导状态与提交接口:`/api/onboarding/status``/api/onboarding/complete`
相关文件:
- src/shared/components/onboarding-gate.tsx
- src/app/api/onboarding/status/route.ts
- src/app/api/onboarding/complete/route.ts
- src/shared/db/schema.ts
- drizzle/0008_add_user_profile_fields.sql
### 注册失败排查与错误提示
- 注册 server action 增强错误信息(可识别重复邮箱、未迁移、权限错误、连接失败等),开发环境可返回更具体的底层错误消息
- 本地排查曾出现 `ECONNREFUSED`,属于数据库连接不可达问题(需检查 MySQL 服务状态与 DATABASE_URL 配置)
相关文件:
- src/app/(auth)/register/page.tsx
### 顶部头像信息修复
- 修复右上角头像/下拉信息写死为 admin 的问题,改为从 NextAuth session 动态读取当前用户 name/email 并生成头像 fallback
相关文件:
- src/modules/layout/components/site-header.tsx

View File

@@ -0,0 +1,438 @@
# Frontend Engineering Standards (Next_Edu)
**Status**: ACTIVE
**Owner**: Frontend Team
**Scope**: Next.js App Router 前端工程规范(编码、目录、交互、样式、数据流、质量门禁)
**Applies To**: `src/app/*`, `src/modules/*`, `src/shared/*`, `docs/design/*`
---
## 0. 目标与非目标
### 0.1 目标
- 让新加入的前端工程师在 30 分钟内完成对齐并开始稳定迭代
- 保证 UI 一致性Design Token + Shadcn/UI 复用)
- 充分利用 App Router + RSCServer-First降低 bundle、提升性能
- 保证类型安全与可维护性Vertical Slice、数据访问边界清晰
- 形成可执行的质量门禁lint/typecheck/build 与评审清单)
### 0.2 非目标
- 不规定具体业务模块的需求细节(业务规则以 `docs/design/*` 与 PRD 为准)
- 不引入与当前仓库技术栈不一致的新框架/库(新增依赖需明确收益与替代方案)
---
## 1. 接手流程Onboarding Checklist
### 1.1 先读什么(按顺序)
- 设计系统与 UI 规范:[docs/design/design_system.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/design_system.md)
- 角色路由与目录规范:[docs/architecture/002_role_based_routing.md](file:///c:/Users/xiner/Desktop/CICD/docs/architecture/002_role_based_routing.md)
- 项目架构总览:[ARCHITECTURE.md](file:///c:/Users/xiner/Desktop/CICD/ARCHITECTURE.md)
- 你将要改动的模块实现文档:`docs/design/00*_*.md`
### 1.2 开发前对齐(必须)
- 核对 Design Tokens 与暗色模式变量:
- Tailwind 语义色映射:[tailwind.config.ts](file:///c:/Users/xiner/Desktop/CICD/tailwind.config.ts)
- CSS 变量定义:[src/app/globals.css](file:///c:/Users/xiner/Desktop/CICD/src/app/globals.css)
- 盘点可复用 UI 组件:`src/shared/components/ui/*`
- 盘点通用工具(`cn` 等):[src/shared/lib/utils.ts](file:///c:/Users/xiner/Desktop/CICD/src/shared/lib/utils.ts)
### 1.3 环境变量与配置校验(必须)
- 统一使用 `@t3-oss/env-nextjs``env` 入口读取环境变量,禁止在业务代码中散落 `process.env.*`
- Schema 定义与校验入口:[src/env.mjs](file:///c:/Users/xiner/Desktop/CICD/src/env.mjs)
- 任何新增环境变量:
- 必须先在 `src/env.mjs` 增加 schema
- 必须在 docs 中更新部署/运行说明(就近更新对应模块文档或全局架构文档)
### 1.3 本地跑通(推荐顺序)
- 安装依赖:`npm install`
- 启动开发:`npm run dev`
- 质量检查:
- `npm run lint`
- `npm run typecheck`
- `npm run build`
---
## 2. 核心工程原则(必须遵守)
### 2.1 Vertical Slice按业务功能组织
- 业务必须放在 `src/modules/<feature>/*`
- `src/app/*` 是路由层,只负责:
- 布局组合layout
- 读取 `searchParams` / `params`
- 调用模块的数据访问函数(`data-access.ts`
- 组合模块组件渲染
- 通用能力放在 `src/shared/*`
- 通用 UI`src/shared/components/ui/*`
- 通用工具:`src/shared/lib/*`
- DB 与 schema`src/shared/db/*`
### 2.2 Server-First默认 Server Component
- 默认写 Server Component
- 只有在需要以下能力时,才把“最小子组件”标记为 Client Component
- `useState/useEffect/useMemo`(与交互/浏览器相关)
- DOM 事件(`onClick/onChange` 等)
- `useRouter/usePathname` 等客户端导航 hooks
- Radix/Portal 类组件需要客户端Dialog/Dropdown 等通常在 client 内组合使用)
### 2.3 不重复造轮子Shadcn/UI 优先)
- 禁止手写 Modal/Dropdown/Tooltip 等基础交互容器
- 优先组合 `src/shared/components/ui/*`Button/Card/Dialog/DropdownMenu/AlertDialog/Skeleton/EmptyState 等)
- 若现有基础组件无法满足需求:
1. 优先通过 Composition 在业务模块里封装“业务组件”
2. 仅在存在 bug 或需要全局一致性调整时,才考虑改动 `src/shared/components/ui/*`(并在 PR 中明确影响面)
- **图表库**:统一使用 `Recharts`禁止引入其他图表库Chart.js / ECharts 等)。
- 使用 `src/shared/components/ui/chart.tsx` 进行封装。
- 遵循 Shadcn/UI Chart 规范。
### 2.4 Client Component 引用边界(强制)
- 禁止在 Client Component 中导入任何“服务端实现”代码(例如 DB 实例、data-access、server-only 模块)
- Client Component 允许导入:
- `src/shared/components/ui/*`(基础 UI
- `src/shared/lib/*`(纯前端工具函数)
- Server Actions`"use server"` 导出的 action 函数)
- 类型定义必须使用 `import type`(避免把服务端依赖带入 client bundle
- 所有 `data-access.ts` 必须包含 `import "server-only"`,并将其视为强制安全边界(不是可选优化)
---
## 3. 目录与路由规范
### 3.1 路由目录App Router
- 认证域:`src/app/(auth)/*`
- 控制台域(共享 App Shell`src/app/(dashboard)/*`
- 角色域:`src/app/(dashboard)/teacher|student|admin/*`
- `/dashboard` 作为入口页(重定向/分发到具体角色 dashboard参考 [002_role_based_routing.md](file:///c:/Users/xiner/Desktop/CICD/docs/architecture/002_role_based_routing.md)
### 3.2 页面文件职责
- `page.tsx`页面组装RSC不承载复杂交互
- `loading.tsx`路由级加载态Skeleton
- `error.tsx`:路由级错误边界(友好 UI
- `not-found.tsx`:路由级 404
### 3.3 错误处理与用户反馈Error Handling & Feedback
- 路由级错误404/500/未捕获异常):
- 交由 `not-found.tsx` / `error.tsx` 处理
- 禁止在 `error.tsx` 里弹 Toast 堆栈刷屏,错误页输出必须友好且可恢复(例如提供 retry / 返回入口)
- 业务操作反馈(表单提交/按钮操作/行级动作):
- 统一由 Client Component 在调用 Server Action 后触发 `sonner` toast
- 只在“成功”或“明确失败(业务/校验错误)”时触发 toast未知异常由 action 归一为失败 message
### 3.4 异步组件与 SuspenseStreaming
- 对于数据加载超过 300ms 的非核心 UI 区块(例如:仪表盘某张统计卡片/图表/第三方数据块):
- 必须用 `<Suspense fallback={<Skeleton />}>` 包裹,以避免全页阻塞
- 禁止在 `page.tsx` 顶层用多个串行 `await` 造成瀑布请求:
- 多个独立请求必须使用 `Promise.all`
- 或拆分为多个 async 子组件并行流式渲染(用 `Suspense` 分段展示)
### 3.3 动态渲染策略(避免 build 阶段查库)
当页面在渲染时会查询数据库或依赖 request-time 数据,且无法安全静态化时:
- 在页面入口显式声明:
- `export const dynamic = "force-dynamic"`
- 该策略已用于教师端班级与作业相关页面,见相应 design 文档(例如教师班级模块更新记录)
---
## 4. 模块内文件结构(强制)
每个业务模块使用统一结构(可按复杂度增减,但命名必须一致):
```
src/modules/<feature>/
├── components/ # 仅该模块使用的 UI 组件(可含 client 组件)
├── actions.ts # Server Actions写入/变更 + revalidatePath
├── data-access.ts # 数据查询与聚合server-only + cache
├── schema.ts # Zod schema若需要
└── types.ts # 类型定义(与 DB/DTO 对齐)
```
约束:
- `actions.ts` 必须包含 `"use server"`
- `data-access.ts` 必须包含 `import "server-only"`(防止误导入到 client bundle
- 复杂页面组件必须下沉到 `src/modules/<feature>/components/*`,路由层只做组装
---
## 5. Server / Client 边界与拆分策略
### 5.1 最小化 Client Component 的落地方式
- 页面保持 RSC
- 把需要交互的部分抽成独立 `components/*` 子组件并标记 `"use client"`
- Client 组件向上暴露“数据变化事件”,由 Server Action 完成写入并 `revalidatePath`
### 5.4 Hydration 一致性(必须)
- 所有 Client Component 的首屏渲染必须保证与 SSR 产出的 HTML 一致
- 禁止在 render 分支中使用:
- `typeof window !== "undefined"` 之类的 server/client 分支
- `Date.now()` / `Math.random()` 等不稳定输入
- 依赖用户 locale 的时间格式化(除非服务端与客户端完全一致并带 snapshot
- 对于 Radix 等组件生成的动态 aria/id 导致的属性差异:
- 优先通过组件封装确保首屏稳定
- 若确认差异不可避免且不影响交互,可在最小范围使用 `suppressHydrationWarning`
### 5.2 页面必须只做“拼装”,功能模块必须独立
- 任何功能模块都必须在 `src/modules/<feature>/components/*` 内独立实现
- `page.tsx` 只负责:
- 读取 `params/searchParams`
- 调用 `data-access.ts` 获取数据
- 以组合方式拼装模块组件(不在 page 内实现具体交互与复杂 UI
- 行数不是拆分依据,只是“路由层变厚”的信号;一旦出现成块的功能 UI应立即下沉到模块组件
### 5.3 什么时候允许在 Client 中做“局部工作台”
当交互复杂到“页面需要类似 SPA 的局部体验”,允许将工作台容器作为 Client
- 典型场景:三栏工作台、拖拽排序编辑器、复杂筛选器组合、富交互表格
- 但仍要求:
- 初始数据由 RSC 获取并传入 Client
- 写操作通过 Server Actions
- UI 状态尽量 URL 化(能分享/回溯)
---
## 6. 样式与 UI 一致性Design System 强制项)
### 6.1 Token 优先(语义化颜色/圆角)
- 颜色必须使用语义 token
- `bg-background`, `bg-card`, `bg-muted`, `text-foreground`, `text-muted-foreground`, `border-border`
- 禁止硬编码颜色值(`#fff`/`rgb()`)与随意引入灰度(如 `bg-gray-100`
- 圆角、边框、阴影遵循设计系统:
- 常规组件使用 `rounded-md` 等语义半径(由 `--radius` 映射)
### 6.2 className 规范
- 所有条件样式必须使用 `className={cn(...)}`
- `cn` 入口为 `@/shared/lib/utils`
### 6.3 禁止 Arbitrary Values默认
- 默认禁止 `w-[123px]` 等任意值
- 只有在设计系统或现有实现明确允许、并且无法用 token/栅格解决时,才可使用,并在 PR 描述说明原因
### 6.4 微交互与状态(必须有)
- 按钮 hover必须有 transition现有 Button 组件已内置)
- 列表项 hover使用 `hover:bg-muted/50` 等轻量反馈
- Loading必须使用 `Skeleton`(路由级 `loading.tsx` 或组件内 skeleton
- Empty必须使用 `EmptyState`
- Toast统一使用 `sonner`
---
## 7. 图标规范lucide-react
- 统一使用 `lucide-react`
- 图标尺寸统一:默认 `h-4 w-4`,需要强调时 `h-5 w-5`
- 颜色使用语义化:例如 `text-muted-foreground`
---
## 8. 数据流规范(查询、写入、状态)
### 8.1 查询data-access.ts
- 所有查询放在 `src/modules/<feature>/data-access.ts`
- 需要复用/去重的查询优先用 `cache` 包裹React cache
- 查询函数返回“UI 直接可消费的 DTO”避免页面层再做复杂映射
### 8.2 写入actions.ts
- 所有写操作必须通过 Server Actions
- 每个 action
- 校验输入Zod 或手写 guard
- 执行 DB 写入
- 必须 `revalidatePath`(以页面为单位)
### 8.3 Server Action 返回结构(统一反馈协议)
- 所有 Server Action 必须返回统一结构,用于前端统一处理 toast 与表单错误
- 统一使用类型:[src/shared/types/action-state.ts](file:///c:/Users/xiner/Desktop/CICD/src/shared/types/action-state.ts)
```ts
export type ActionState<T = void> = {
success: boolean
message?: string
errors?: Record<string, string[]>
data?: T
}
```
约束:
- `errors` 必须对齐 `zod``error.flatten().fieldErrors` 结构
- 禁止在各模块内重复定义自有的 ActionState 类型
### 8.4 Toast 触发时机(强制)
- Client Component 在调用 Server Action 后:
- `success: true`:触发 `toast.success(message)`(或使用模块内约定的成功文案)
- `success: false`
- 存在 `errors`:优先渲染表单字段错误;可选触发 `toast.error(message)`
- 不存在 `errors`:触发 `toast.error(message || "Action failed")`
- 对于路由级异常与边界错误,禁止用 toast 替代 `error.tsx`
### 8.5 URL Statenuqs 优先)
- 列表页筛选/分页/Tab/排序等“可分享状态”必须放 URL
- 使用 `nuqs` 做类型安全的 query state 管理
### 8.6 Data Access 权限边界Security / IDOR 防护)
- `data-access.ts` 不是纯 DTO 映射层,必须承担数据归属权校验
- 允许两种合规方式(二选一,但模块内必须统一):
- **方式 A强制传参**:所有 data-access 函数显式接收 `actor`userId/role并在查询条件中约束归属例如 teacherId
- **方式 B函数内获取**data-access 函数首行获取 session/user 并校验 role/归属,再执行查询
- 禁止把权限校验放在 page.tsx 或 client 组件中作为唯一屏障
---
## 9. 数据完整性与 Seed 规则(禁止 Mock
项目默认不使用 Mock 数据。
当某功能缺失实际数据,开发者必须把数据补齐到数据库与种子数据中,而不是在前端临时模拟。
执行规范:
- 若缺失的是“表结构/字段/关系”:
- 修改 `src/shared/db/schema.ts``src/shared/db/relations.ts`(按既有模式)
- 生成并提交 Drizzle migration`drizzle/*.sql`
- 若缺失的是“可演示的业务数据”:
- 更新 `scripts/seed.ts`,确保 `npm run db:seed` 可一键生成可用数据
- 文档同步(必须):
- 在 [schema-changelog.md](file:///c:/Users/xiner/Desktop/CICD/docs/db/schema-changelog.md) 记录本次新增/变更的数据表、字段、索引与外键
- 在对应模块的 `docs/design/00*_*.md` 中补充“新增了哪些数据/为什么需要/如何验证db:seed + 页面路径)”
### 9.1 Seed 分层(降低阻塞)
- Seed 分为两类:
- **Baseline Seed**:全项目必备的最小集合(核心用户/角色/基础字典数据等),保证任何页面都不因“数据空”而无法进入流程
- **Scenario Seed按模块**:面向具体模块的可演示数据包(例如:班级/题库/试卷/作业),用于复现与验证该模块交互
- 任何模块新增数据依赖,必须以 “Scenario Seed” 的形式落到 `scripts/seed.ts`,而不是把数据要求隐含在前端逻辑里
### 9.2 Seed 可复现与数据锚点(保证跨模块联动)
- Seed 必须可重复执行idempotent避免开发环境多次执行后产生脏数据与重复数据
- 对跨模块联动依赖的关键实体,必须提供可稳定引用的数据锚点:
- 固定标识(如固定 email/slug/title 组合)或可预测 ID按现有 seed 约定)
- 文档必须写明锚点是什么、依赖它的模块有哪些、如何验证
- 禁止在 UI 里依赖“随机生成数据顺序”来定位实体(例如 “取第一条记录作为 demo 用户” 这类逻辑应退化为明确锚点)
### 9.3 外部服务的例外(仅限 Adapter Mock
- 内部业务数据严格遵守“DB + Migration + Seed”不允许 Mock
- 仅当对接外部不可控服务(支付/短信/第三方 AI 流式等)且无法用本地 seed 复现时:
- 允许在 `src/shared/lib/mock-adapters/*` 建立 mock 适配器
- 必须先定义 Adapter 接口,再提供真实实现与 mock 实现(业务模块只能依赖接口,不可直接依赖某个具体实现)
- 该 mock 仅用于外部服务交互层,禁止承载内部业务数据
---
## 10. 表单规范react-hook-form + zod
- 表单统一使用 `react-hook-form` + `@hookform/resolvers` + `zod`
- 错误提示放在输入框下方:
- 字号 `text-xs`
- 颜色 `text-destructive`
- 破坏性操作必须二次确认(`AlertDialog`
- 提交中按钮禁用并展示 loading可使用 `useFormStatus` 或本地 state
---
## 11. 质量门禁与评审清单PR 必须过)
### 11.1 本地必须通过
- `npm run lint`
- `npm run typecheck`
- `npm run build`
### 11.2 代码评审清单Reviewer 逐项检查)
- 目录结构是否符合 Vertical Slice路由层是否保持“薄”
- 页面是否只做拼装(功能 UI 是否全部下沉到模块组件)
- Server/Client 边界是否最小化(是否把整页误标 client
- 是否复用 `src/shared/components/ui/*`,是否重复实现基础交互
- 是否使用语义化 token颜色/圆角/间距),是否引入硬编码颜色与大量 arbitrary values
- Loading/Empty/Error 是否齐全Skeleton/EmptyState/error.tsx
- 列表页筛选是否 URL 化nuqs是否支持刷新/分享
- 写操作是否通过 Server Action 且正确 `revalidatePath`
- 是否避免 Mock数据是否通过迁移 + seed 补齐,且 docs/db 与模块文档已同步)
- 是否引入不必要的依赖与重型客户端逻辑
### 11.3 Commit 规范Git History
- 推荐遵循 Conventional Commits
- `feat:` 新功能
- `fix:` 修复 bug
- `docs:` 文档更新
- `refactor:` 重构(无功能变化)
- `chore:` 工程杂项
- 约束:
- 单次提交必须聚焦一个意图,避免把大范围格式化与功能修改混在一起
- 涉及 DB 迁移与 seed 变更时commit message 必须包含模块/领域关键词,便于追溯
---
## 12. 文档同步规则Docs Sync
以下情况必须同步更新文档(就近放在 `docs/design/*``docs/architecture/*`
- 新增“全局交互模式”(例如:新的工作台/拖拽范式/跨模块复用交互)
- 新增“全局组件”或改变基础 UI 行为(影响 `src/shared/components/ui/*`
- 新增关键路由结构或权限/角色策略
### 12.1 业务组件可发现性(可选但推荐)
-`src/modules/<feature>/components` 内的复杂业务组件(例如:试卷编辑器、排课表、工作台):
- 推荐在对应的 `docs/design/00*_*.md` 增加“用法示例 + 关键 props + 截图”
- 若团队资源允许,可引入 Storybook 作为可视化组件目录(不作为硬性门禁)
---
## 13. Performance Essentials必须遵守
- 图片:
- 强制使用 `next/image` 替代 `<img>`SVG 或已明确无需优化的极小图标除外)
- 头像等外部域名资源必须配置并明确缓存策略
- 字体:
- 强制使用 `next/font` 管理字体加载
- 禁止在 CSS 中 `@import` 外部字体 URL避免 CLS 与阻塞渲染)
- 依赖:
- 禁止引入重型动画库作为默认方案;复杂动效需按需加载并解释收益
- **图表**:标准图表库统一使用 `recharts`(通过 `src/shared/components/ui/chart.tsx` 封装),禁止引入其他图表库(如 Chart.js / Highcharts
- 大体积 Client 组件必须拆分与动态加载,并通过 `Suspense` 提供 skeleton fallback
---
## 14. 参考实现(从现有代码学习的路径)
- 设计系统与 UI 组件清单:[docs/design/design_system.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/design_system.md)
- Auth路由层 RSC + 表单 client 拆分模式:[docs/design/001_auth_ui_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/001_auth_ui_implementation.md)
- 教师端班级模块URL state + client 交互组件 + server actions 的组合:[docs/design/002_teacher_dashboard_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/002_teacher_dashboard_implementation.md)
- 教材工作台RSC 拉初始数据 + client 工作台容器接管交互:[docs/design/003_textbooks_module_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/003_textbooks_module_implementation.md)
- 题库nuqs 驱动筛选 + TanStack Table + CRUD actions[docs/design/004_question_bank_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/004_question_bank_implementation.md)
- 考试组卷:拖拽编辑器(@dnd-kit+ structure JSON 模型:[docs/design/005_exam_module_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/005_exam_module_implementation.md)
- 作业:冻结 exam → assignment 的域模型 + 学生作答/教师批改闭环:[docs/design/006_homework_module_implementation.md](file:///c:/Users/xiner/Desktop/CICD/docs/design/006_homework_module_implementation.md)

View File

@@ -75,3 +75,128 @@ This release introduces homework-related tables and hardens foreign key names to
### 4. Impact Analysis
* **Performance**: Minimal. New indexes are scoped to common homework access patterns.
* **Data Integrity**: High. Foreign keys enforce referential integrity for homework workflow.
## v1.3.0 - Classes Domain (Teacher Class Management)
**Date:** 2025-12-31
**Migration ID:** `0003_petite_newton_destine`
**Author:** Principal Database Architect
### 1. Summary
This release introduces the core schema for teacher class management: classes, enrollments, and schedules.
### 2. Changes
#### 2.1 Tables: Classes Domain
* **Action**: `CREATE TABLE`
* **Tables**:
* `classes`
* `class_enrollments`
* `class_schedule`
* **Reason**: Support teacher-owned classes, student enrollment lists, and weekly schedules.
#### 2.2 Enum: Enrollment Status
* **Action**: `ADD ENUM`
* **Enum**: `class_enrollment_status` = (`active`, `inactive`)
* **Reason**: Provide a stable status field for filtering active enrollments.
#### 2.3 Foreign Keys & Indexes
* **Action**: `ADD FOREIGN KEY`, `CREATE INDEX`
* **Key Relationships**:
* `classes.teacher_id` -> `users.id` (cascade delete)
* `class_enrollments.class_id` -> `classes.id` (cascade delete)
* `class_enrollments.student_id` -> `users.id` (cascade delete)
* `class_schedule.class_id` -> `classes.id` (cascade delete)
* **Indexes**:
* `classes_teacher_idx`, `classes_grade_idx`
* `class_enrollments_class_idx`, `class_enrollments_student_idx`
* `class_schedule_class_idx`, `class_schedule_class_day_idx`
### 3. Migration Strategy
* **Up**: Run standard Drizzle migration. Ensure `DATABASE_URL` points to the intended schema (e.g., `next_edu`).
* **Down**: Not provided. Dropping these tables is destructive and should be handled explicitly per environment.
### 4. Impact Analysis
* **Performance**: Indexes align with common query patterns (teacher listing, enrollment filtering, per-class schedule).
* **Data Integrity**: High. Foreign keys enforce ownership and membership integrity across teacher/classes/students.
## v1.4.0 - Classes Domain Enhancements (School Name & Subject Teachers)
**Date:** 2026-01-07
**Migration ID:** `0005_add_class_school_subject_teachers`
**Author:** Frontend/Fullstack Engineering
### 1. Summary
This release extends the Classes domain to support school-level sorting and per-subject teacher assignment defaults.
### 2. Changes
#### 2.1 Table: `classes`
* **Action**: `ADD COLUMN`
* **Field**: `school_name` (varchar(255), nullable)
* **Reason**: Enable sorting/grouping by school name, then grade, then class name.
#### 2.2 Table: `class_subject_teachers`
* **Action**: `CREATE TABLE`
* **Primary Key**: (`class_id`, `subject`)
* **Columns**:
* `class_id` (varchar(128), FK -> `classes.id`, cascade delete)
* `subject` (enum: `语文/数学/英语/美术/体育/科学/社会/音乐`)
* `teacher_id` (varchar(128), FK -> `users.id`, set null on delete)
* `created_at`, `updated_at`
* **Reason**: Maintain a stable default “subject list” per class while allowing admin/teacher to assign the actual teacher per subject.
### 3. Migration Strategy
* **Up**: Run standard Drizzle migration.
* **Down**: Not provided. Dropping assignment history is destructive.
### 4. Impact Analysis
* **Performance**: Minimal. Table is small (8 rows per class) and indexed by class/teacher.
* **Data Integrity**: High. Composite PK prevents duplicates per class/subject; FKs enforce referential integrity.
## v1.4.1 - Classes Domain Enhancements (School/Grade Normalization)
**Date:** 2026-01-07
**Migration ID:** `0006_faithful_king_bedlam`
**Author:** Frontend/Fullstack Engineering
### 1. Summary
This release extends the `classes` table to support normalized school and grade references.
### 2. Changes
#### 2.1 Table: `classes`
* **Action**: `ADD COLUMN`
* **Fields**:
* `school_id` (varchar(128), nullable)
* `grade_id` (varchar(128), nullable)
* **Reason**: Enable filtering and sorting by canonical school/grade entities instead of relying on free-text fields.
### 3. Migration Strategy
* **Up**: Run standard Drizzle migration.
* **Down**: Not provided. Dropping columns is destructive.
### 4. Impact Analysis
* **Performance**: Minimal. Indexing and joins can be added as usage evolves.
* **Data Integrity**: Medium. Existing rows remain valid (nullable fields); application-level validation can enforce consistency.
## v1.5.0 - Classes Domain Feature (Invitation Code)
**Date:** 2026-01-08
**Migration ID:** `0007_add_class_invitation_code`
**Author:** Frontend/Fullstack Engineering
### 1. Summary
This release introduces a 6-digit invitation code on `classes` to support join-by-code enrollment.
### 2. Changes
#### 2.1 Table: `classes`
* **Action**: `ADD COLUMN` + `ADD UNIQUE CONSTRAINT`
* **Field**: `invitation_code` (varchar(6), nullable, unique)
* **Reason**: Allow students to enroll into a class using a short code, while ensuring uniqueness across all classes.
### 3. Migration Strategy
* **Up**: Run standard Drizzle migration.
* **Backfill**: Optional. Existing classes can keep `NULL` or be populated via application-level actions.
* **Down**: Not provided. Removing a unique constraint/column is destructive.
### 4. Impact Analysis
* **Performance**: Minimal. Uniqueness is enforced via an index.
* **Data Integrity**: High. Unique constraint prevents code collisions and simplifies server-side enrollment checks.

View File

@@ -59,6 +59,12 @@ Demonstrates the new **JSON Structure** field (`exams.structure`).
]
```
### 2.5 Classes / Enrollment / Schedule
Seeds the teacher class management domain.
* **Classes**: Creates at least one class owned by a teacher user.
* **Enrollments**: Links students to classes via `class_enrollments` (default status: `active`).
* **Schedule**: Populates `class_schedule` with weekday + start/end times for timetable validation.
## 3. How to Run
### Prerequisites

View File

@@ -94,7 +94,149 @@ React 的 hydration 过程对 HTML 有效性要求极高。将 `div` 放入 `p`
2. `teacher-stats.tsx`
3. `teacher-schedule.tsx`
## 5. 下一步计划
- Mock Data 对接到真实的 API 端点 (React Server Actions)
- 实现 "Quick Actions" (快捷操作) 的具体功能
- Submissions 和 Schedule 添"View All" (查看全部) 跳转导航
## 5. 更新记录2026-01-04
- 教师仪表盘从 Mock Data 切换为真实数据查询:`/teacher/dashboard` 组合 `getTeacherClasses``getClassSchedule``getHomeworkSubmissions({ creatorId })` 渲染 KPI / 今日课表 / 最近提交
- Quick Actions 落地为真实路由跳转(创建作业、查看列表等)
- Schedule / Submissions View All” 跳转到对应列表页(并携带筛选参数)
---
## 6. 教师端班级管理模块(真实数据接入记录)
**日期**: 2025-12-31
**范围**: 教师端「我的班级 / 学生 / 课表」页面与 MySQL(Drizzle) 真数据对接
### 6.1 页面入口与路由
班级管理相关页面位于:
- `src/app/(dashboard)/teacher/classes/my/page.tsx`
- `src/app/(dashboard)/teacher/classes/students/page.tsx`
- `src/app/(dashboard)/teacher/classes/schedule/page.tsx`
为避免构建期/预渲染阶段访问数据库导致失败,以上页面显式启用动态渲染:
- `export const dynamic = "force-dynamic"`
### 6.2 模块结构Vertical Slice
班级模块采用垂直切片架构,代码位于 `src/modules/classes/`
```
src/modules/classes/
├── components/
│ ├── my-classes-grid.tsx
│ ├── students-filters.tsx
│ ├── students-table.tsx
│ ├── schedule-filters.tsx
│ └── schedule-view.tsx
├── data-access.ts
└── types.ts
```
其中 `data-access.ts` 负责班级、学生、课表三类查询的服务端数据读取,并作为页面层唯一的数据入口。
### 6.3 数据库表与迁移
新增班级领域表:
- `classes`
- `class_enrollments`
- `class_schedule`
对应 Drizzle Schema
- `src/shared/db/schema.ts`
- `src/shared/db/relations.ts`
对应迁移文件:
- `drizzle/0003_petite_newton_destine.sql`
外键关系(核心):
- `classes.teacher_id` -> `users.id`
- `class_enrollments.class_id` -> `classes.id`
- `class_enrollments.student_id` -> `users.id`
- `class_schedule.class_id` -> `classes.id`
索引(核心):
- `classes_teacher_idx`, `classes_grade_idx`
- `class_enrollments_class_idx`, `class_enrollments_student_idx`
- `class_schedule_class_idx`, `class_schedule_class_day_idx`
### 6.4 Seed 数据
Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面渲染与关联关系:
- `scripts/seed.ts`
- 运行命令:`npm run db:seed`
### 6.5 开发过程中的问题与处理
- 端口占用EADDRINUSE开发服务器端口被占用时通过更换端口启动规避例如 `next dev -p <port>`)。
- Next dev 锁文件:出现 `.next/dev/lock` 无法获取锁时,需要确保只有一个 dev 实例在运行,并清理残留 lock。
- 头像资源 404移除 Header 中硬编码的本地头像资源引用,避免 `public/avatars/...` 不存在导致的 404 噪音(见 `src/modules/layout/components/site-header.tsx`)。
- 班级人数统计查询失败:`class_enrollments` 表实际列名为 `class_enrollment_status`,修复查询中引用的列名以恢复教师端班级列表渲染。
- Students 页面 key 冲突:学生列表跨班级汇总时,`<TableRow key={studentId}>` 会重复,改为使用 `classId:studentId` 作为 key。
- Build 预渲染失败(/login`LoginForm` 使用 `useSearchParams()` 获取回跳地址,需在 `/login` 页面用 `Suspense` 包裹以避免 CSR bailout 报错。
- 构建警告middlewareNext.js 16 将文件约定从 `middleware.ts` 改为 `proxy.ts`,已迁移以消除警告。
### 6.6 班级详情页(聚合视图 + Schedule Builder + Homework 统计)
**日期**: 2026-01-04
**入口**: `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx`
聚合数据在单次 RSC 请求内并发获取:
- 学生:`getClassStudents({ classId })`
- 课表:`getClassSchedule({ classId })`
- 作业统计:`getClassHomeworkInsights({ classId, limit })`(包含 latest、历史列表、overallScores、以及每次作业的 scoreStatsavg/median
页面呈现:
- 顶部 KPI 卡片:学生数、课表条目数、作业数、整体 avg/median
- Latest homework目标人数、提交数、批改数、avg/median直达作业与提交列表
- Students / Schedule 预览:提供 View all 跳转到完整列表页
- Homework history 表格:支持通过 URL query `?hw=all|active|overdue` 过滤作业记录,并展示每条作业的 avg/median
课表编辑能力复用既有 Builder
- 组件:`src/modules/classes/components/schedule-view.tsx`(新增/编辑/删除课表项)
- 数据变更:`src/modules/classes/actions.ts`
### 6.7 班级邀请码6 位码)加入与管理
**日期**: 2026-01-08
**范围**: 为班级新增 6 位邀请码,支持学生通过输入邀请码加入班级;教师可查看与刷新邀请码
---
## 7. 班级管理重构与角色分离 (2026-01-14)
**日期**: 2026-01-14
**范围**: 班级创建权限收归管理端,教师端仅保留查看与加入
### 7.1 职责分离 (Role Separation)
- **管理端 (Management)**:
- 新增 `src/app/(dashboard)/management/grade/classes/page.tsx`
- 供年级组长 (Grade Head) 与管理员创建、编辑、删除班级
- 引入 `GradeClassesView` 组件,支持按年级管理班级
- **教师端 (Teacher)**:
- 移除创建班级入口
- 新增「通过邀请码加入班级」功能 (`JoinClassDialog`)
- `MyClassesGrid` 样式优化,移除硬编码渐变,使用标准 `bg-card`
### 7.2 数据访问与权限
- 新增 `getGradeManagedClasses`: 仅返回用户作为 Grade Head 或 Teaching Head 管理的年级下的班级
- Server Actions (`createGradeClassAction` 等) 增加严格的 RBAC 校验,确保操作者对目标年级有管理权限
## 8. 课表模块视觉升级与架构优化 (2026-01-15)
**日期**: 2026-01-15
**范围**: 课表视图 (Schedule View) 视觉重构、Insights 模块移除
### 8.1 课表视图重构 (Schedule Optimization)
- **视觉对齐**: 重构 `ScheduleView` (`src/modules/classes/components/schedule-view.tsx`) 以完全匹配 `ClassScheduleGrid` 组件的视觉风格。
- **无边框设计**: 移除网格线与外边框,采用更现代的洁净布局。
- **时间轴定位**: 废弃 Grid 布局,改用基于时间的绝对定位 (`top`, `height` 百分比计算),支持 8:00 - 18:00 时间段。
- **语义化配色**: 新增 `getSubjectColor` 工具函数,根据课程名称 (Math, Physics, etc.) 自动映射语义化背景色与边框色。
- **过滤器优化**: `ScheduleFilters` 移除边框与阴影,居中显示当前选中的班级名称 (`{Class Name} Schedule`),移除冗余的 Reset 按钮。
### 8.2 架构精简 (Insights Removal)
- **移除 Insights**: 经评估,`src/app/(dashboard)/teacher/classes/insights` 模块功能冗余,已全量移除。
- **保留核心数据**: 保留 `data-access.ts` 中的 `getClassHomeworkInsights` 函数,继续服务于班级详情页的统计卡片与图表。
- **导航更新**: 从 `NAV_CONFIG` 中移除 Insights 入口。

View File

@@ -1,6 +1,7 @@
# Textbooks Module Implementation Details
**Date**: 2025-12-23
**Updated**: 2026-01-13
**Author**: DevOps Architect
**Module**: Textbooks (`src/modules/textbooks`)
@@ -27,6 +28,13 @@ src/
│ ├── page.tsx
│ └── loading.tsx
│ └── student/
│ └── learning/
│ └── textbooks/ # 学生端只读阅读Server Components
│ ├── page.tsx # 列表页(复用筛选与卡片)
│ └── [id]/ # 详情页(阅读器)
│ └── page.tsx
├── modules/
│ └── textbooks/ # 业务模块
│ ├── actions.ts # Server Actions (增删改)
@@ -34,6 +42,7 @@ src/
│ ├── types.ts # 类型定义 (Schema-aligned)
│ └── components/ # 模块私有组件
│ ├── textbook-content-layout.tsx # [核心] 三栏布局工作台
│ ├── textbook-reader.tsx # [新增] 学生端只读阅读器URL state
│ ├── chapter-sidebar-list.tsx # 递归章节树
│ ├── knowledge-point-panel.tsx # 知识点管理面板
│ ├── create-chapter-dialog.tsx # 章节创建弹窗
@@ -64,6 +73,11 @@ src/
* **Optimistic UI**: 虽然使用 Server Actions但通过本地状态 (`useState`) 实现了操作的即时反馈(如保存正文后立即退出编辑模式)。
* **Feedback**: 使用 `sonner` (`toast`) 提供操作成功或失败的提示。
### 3.3 学生端阅读体验Read-Only Reader
* **两栏阅读**左侧章节树右侧正文渲染Markdown
* **URL State**:选中章节通过 `?chapterId=` 写入 URL支持刷新/分享后保持定位nuqs
* **只读边界**:学生端不暴露创建/删除/编辑/知识点管理入口,避免误用教师工作台能力。
## 4. 数据流与逻辑 (Data Flow)
### 4.1 Server Actions
@@ -71,18 +85,22 @@ src/
* `createChapterAction`: 创建章节(支持嵌套)。
* `updateChapterContentAction`: 更新正文内容。
* `createKnowledgePointAction`: 创建知识点并自动关联当前章节。
* `deleteKnowledgePointAction`: 删除知识点并刷新详情页数据。
* `updateTextbookAction`: 更新教材元数据Title, Subject, Grade, Publisher
* `deleteTextbookAction`: 删除教材及其关联数据。
* `delete...Action`: 处理删除逻辑。
### 4.2 数据访问层 (Data Access)
* **Mock Implementation**: 目前在 `data-access.ts` 使用内存数组模拟数据库操作,并人为增加了延迟 (`setTimeout`) 以测试 Loading 状态
* **Type Safety**: 定义了严格的 TypeScript 类型 (`Chapter`, `KnowledgePoint`, `UpdateTextbookInput`),确保前后端数据契约一致
* **DB Implementation**: 教材模块已接入真实数据库访问,`data-access.ts` 使用 `drizzle-orm` 直接查询并返回教材、章节、知识点数据
* **章节树构建**: 章节采用父子关系存储,通过一次性拉取后在内存中构建嵌套树结构,避免 N+1 查询
* **级联删除**: 删除章节时会同时删除其子章节以及关联的知识点,确保数据一致性。
* **Type Safety**: 定义严格的 TypeScript 类型(如 `Chapter`, `KnowledgePoint`, `UpdateTextbookInput`),保证数据契约与 UI 组件一致。
## 5. 组件复用
* 使用了 `src/shared/components/ui` 中的 Shadcn 组件:
* `Dialog`, `ScrollArea`, `Card`, `Button`, `Input`, `Textarea`, `Select`.
* `Collapsible` 用于实现递归章节树。
* `AlertDialog` 用于危险操作的二次确认(删除章节/删除知识点)。
* 图标库统一使用 `lucide-react`.
## 6. Settings 功能实现 (New)
@@ -92,7 +110,84 @@ src/
* **Edit**: 修改教材的基本信息。
* **Delete**: 提供红色删除按钮,二次确认后执行删除并跳转回列表页。
## 7. 后续计划 (Next Steps)
* [ ] **富文本编辑器**: 集成 Tiptap 替换现有的 Markdown Textarea支持更丰富的格式。
* [ ] **拖拽排序**: 实现章节树的拖拽排序 (`dnd-kit`)。
* [ ] **数据库对接**: 将 `data-access.ts` 中的 Mock 逻辑替换为真实`drizzle-orm` 数据库调用
## 7. 关键更新记录 (Changelog)
### 7.1 数据与页面
* 教材模块从 Mock 换为真实 DB新增教材/章节/知识点的数据访问与 Server Actions 刷新策略
* 列表页支持过滤/搜索:通过 query 参数驱动,统一空状态反馈。
### 7.2 章节侧边栏与弹窗
* 修复子章节创建弹窗“闪现后消失”:改为受控 Dialog 状态管理。
* 修复移动端操作按钮不可见/被遮挡:调整布局与可见性策略,确保小屏可点。
* 删除章节使用确认弹窗并提供删除中状态。
### 7.3 Markdown 阅读体验
* 阅读模式使用 `react-markdown` 渲染章节内容,支持 GFM表格/任务列表等)。
* 启用 Typography`prose`)排版样式,使 `h1/h2/...` 在视觉上有明显层级差异。
* 修复阅读模式内容区无法滚动:为 flex 容器补齐 `min-h-0` 等必要约束。
### 7.4 知识点删除交互
* 删除知识点从浏览器 `confirm()` 升级为 `AlertDialog`
* 显示目标名称、危险样式按钮
* 删除中禁用交互并显示 loading 文案
* 删除成功后刷新页面数据
### 7.5 学生端 Textbooks 列表与阅读页New
* 新增学生端路由:
* `/student/learning/textbooks`教材列表页RSC复用筛选组件nuqs与卡片布局。
* `/student/learning/textbooks/[id]`教材阅读页RSC + client 阅读器容器),章节选择与阅读不跳页。
* 复用与适配:
* `TextbookCard` 增加可配置跳转基地址,避免学生端卡片误跳到教师端详情页。
* 新增 `TextbookReader`client用于只读阅读体验左侧章节树 + 右侧正文渲染,章节定位 URL 化(`chapterId`)。
* 质量门禁:
* 通过 `npm run lint / typecheck / build`
## 8. 后续计划 (Next Steps)
* [x] **富文本编辑器**: 已集成 Tiptap 富文本编辑器,支持 Markdown 读写、即时预览与工具栏操作。
* [x] **拖拽排序**: 实现章节树拖拽排序与持久化。
* [ ] **知识点能力增强**: 支持编辑、排序、分层(如需要)。
---
## 9. 界面与交互优化 (2026-01-12)
**目标**: 提升教师端教材管理的视觉质感与操作体验,对齐 "International Typographic Style" 设计语言。
### 9.1 卡片与列表 (Textbook Card & Filters)
* **Dynamic Covers**: 卡片封面采用动态渐变色,根据科目 (Subject) 自动映射不同色系(如数学蓝、物理紫、生物绿),提升识别度。
* **Information Density**: 增加元数据展示Grade, Publisher, Chapter Count并优化排版层级。
* **Quick Actions**: 在卡片底部增加 "Edit Content" / "Delete" 快捷下拉菜单。
* **Filters**: 简化筛选栏设计,移除厚重的容器背景,使其更轻量融入页面。
### 9.2 详情页工作台 (Detail Workbench)
* **Immersive Layout**:
* **Full Height**: 采用 `h-[calc(100vh-8rem)]` 撑满剩余空间,移除多余滚动条。
* **Sticky Header**: 章节标题与操作栏吸顶,内容区独立滚动。
* **Typography**: 引入 `prose-zinc` 与优化的字体排版,提升阅读舒适度。
* **Sidebar Refinement**:
* **Chapter Tree**: 增加左侧边框线与层级缩进,选中态更明显;操作按钮(添加/删除)仅在 Hover 时显示,减少视觉干扰。
* **Knowledge Points**: 改为卡片式列表Hover 显示删除按钮;增加空状态引导。
* **Drag & Drop**: 集成 `@dnd-kit` 实现章节拖拽排序,支持同级拖动并实时持久化到数据库。
### 9.3 富文本编辑器 (Rich Text Editor)
* **Tiptap Integration**: 引入 `@tiptap/react` 替换原有的 Textarea。
* **Markdown Support**: 支持 Markdown 源码读写,保持数据格式兼容性。
* **Toolbar**: 实现悬浮工具栏,支持 Bold, Italic, Headings, Lists, Blockquote 等常用格式。
* **SSR Fix**: 解决 Tiptap 在 Next.js 中的 Hydration Mismatch 问题 (`immediatelyRender: false`)。
### 9.4 系统组件优化 (UI Components)
* **Dialog**:
* 优化遮罩层 (`backdrop-blur`) 与弹窗阴影,提升通透感。
* 调整动画时长 (`duration-200`) 与缓动,移除位移动画,改为纯净的 Fade + Zoom 效果。
* 增加内部间距 (`gap-6`) 与圆角 (`rounded-xl`),使排版更现代。
* **Create Chapter Dialog**: 优化触发按钮样式,增加 `sr-only` 辅助文本,修复点击区域过小的问题。
---
## 10. 近期改进 (2026-01-13)
### 10.1 导航体验 (Navigation)
* **Dynamic Breadcrumbs**: 接入全局动态面包屑系统。
* 支持从路由路径e.g., `/teacher/textbooks/123`)自动生成层级导航。
* 解决了深层嵌套页面(如教材详情页)缺乏上下文回退路径的问题。

View File

@@ -153,7 +153,37 @@ type ExamNode = {
- `getExams(params)`: 支持按 `q/status` 在数据库侧过滤;`difficulty` 因当前存储在 `description` JSON 中,采用内存过滤
- `getExamById(id)`: 查询 exam 及其 `exam_questions`,并返回 `structure` 以用于构建器 Hydrate
## 7. 变更记录(合并 Homework
### 6.5 `getExamPreviewAction` (新增)
- **入参**: `examId` (string)
- **行为**:
- 查询指定 exam 及其关联的 questions (通过 `exam_questions` 关系)。
- 返回完整的 `structure` (JSON 树) 和扁平化的 `questions` 列表。
- 用于预览弹窗的数据加载。
## 7. 变更记录
**日期**2026-01-12 (当前)
- **列表页优化 (`/teacher/exams/all`)**:
- 移除了冗余的 "All Exams" 页面标题和描述。
- 重构了表格列 (`ExamColumns`)
- 合并标题、标签、科目、年级为 "Exam Info" 列。
- 合并题目数、总分、时长为 "Stats" 列。
- 合并创建时间和预定时间为 "Date" 列。
- 优化了状态 (Status) 和难度 (Difficulty) 的视觉样式 (Badge, Progress bar)。
- 优化了表格分页和布局 (`ExamDataTable`)。
- **预览功能增强**:
- 新增直接预览功能:在操作列添加了 "View" (眼睛图标) 按钮。
- 点击 "View" 触发 `getExamPreviewAction` 获取完整试卷数据。
- 弹窗 (`Dialog`) 直接展示试卷内容 (`ExamPaperPreview`),移除了冗余的头部描述,优化了滚动体验。
- 修复了可访问性问题 (DialogTitle)。
- **组卷页面升级 (`/teacher/exams/[id]/build`)**:
- **布局重构**: 扩展工作区高度,调整左右面板比例 (2:1),优化头部信息展示和进度可视化。
- **题库增强**: 实现了基于 Server Action (`getQuestionsAction`) 的分页加载和服务器端筛选,提升大数据量下的性能;优化了搜索和筛选器 UI。
- **预览优化**: 移除了内联预览,改为通过 "Preview" 按钮触发弹窗预览,避免干扰编辑流。
- **视觉降噪**: 移除了页面顶部冗余的标题和描述。
**日期**2025-12-31

View File

@@ -81,7 +81,7 @@
- `getHomeworkAssignments`:作业列表(可按 creatorId/ids
- `getHomeworkAssignmentById`:作业详情(含目标人数、提交数统计)
- `getHomeworkSubmissions`:提交列表(可按 assignmentId
- `getHomeworkSubmissions`:提交列表(可按 assignmentId/classId/creatorId
- `getHomeworkSubmissionDetails`:提交详情(题目内容 + 学生答案 + 分值/顺序)
### 4.2 学生侧查询
@@ -151,3 +151,156 @@
- `npm run typecheck`: 通过
- `npm run lint`: 0 errors仓库其他位置存在 warnings与本模块新增功能无直接关联
---
## 9. 部署与环境变量CI/CD
### 9.1 本地开发
- 本地开发使用项目根目录的 `.env` 提供 `DATABASE_URL`
- `.env` 仅用于本机开发,不应写入真实生产库凭据
### 9.2 CI 构建与部署Gitea
工作流位于:[ci.yml](file:///c:/Users/xiner/Desktop/CICD/.gitea/workflows/ci.yml)
- 构建阶段(`npm run build`)不依赖数据库连接:作业相关页面在构建时不会静态预渲染执行查库
- 部署阶段通过 `docker run -e DATABASE_URL=...` 在运行时注入数据库连接串
- 需要在 Gitea 仓库 Secrets 配置 `DATABASE_URL`(生产环境 MySQL 连接串)
- CI 中关闭 Next.js telemetry设置 `NEXT_TELEMETRY_DISABLED=1`
### 9.3 Next.js 渲染策略(避免 build 阶段查库)
作业模块相关页面在渲染时会进行数据库查询,因此显式标记为动态渲染以避免构建期预渲染触发数据库连接:
- 教师端作业列表:[assignments/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/page.tsx)
---
## 10. 实现更新2026-01-05
### 10.1 教师端作业详情页组件化(按 Vertical Slice 拆分)
`/teacher/homework/assignments/[id]` 页面调整为“只负责组装”,把可复用展示逻辑下沉到模块内组件:
- 页面组装:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/teacher/homework/assignments/%5Bid%5D/page.tsx)
- 题目错误概览卡片overview[homework-assignment-question-error-overview-card.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-question-error-overview-card.tsx)
- 题目错误明细卡片details[homework-assignment-question-error-details-card.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-question-error-details-card.tsx)
- 试卷预览/错题工作台容器卡片:[homework-assignment-exam-content-card.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-content-card.tsx)
### 10.2 题目点击联动:试卷预览 ↔ 错题详情
在“试卷预览”中点击题目后,右侧联动展示该题的统计与错答列表(按学生逐条展示,不做合并):
- 工作台(选择题目、拼装左右面板):[homework-assignment-exam-error-explorer.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-error-explorer.tsx)
- 试卷预览面板(可选中题目):[homework-assignment-exam-preview-pane.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-preview-pane.tsx)
- 错题详情面板(错误人数/错误率/错答列表):[homework-assignment-question-error-detail-panel.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-question-error-detail-panel.tsx)
### 10.3 统计数据增强:返回逐学生错答
为满足“错答列表逐条展示学生姓名 + 答案”的需求,作业统计查询返回每题的错答明细(包含学生信息):
- 数据访问:[getHomeworkAssignmentAnalytics](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts)
- 类型定义:[types.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/types.ts)
### 10.4 加载优化Client Wrapper 动态分包
由于 `next/dynamic({ ssr: false })` 不能在 Server Component 内使用,工作台动态加载通过 Client wrapper 进行隔离:
- Client wrapper[homework-assignment-exam-error-explorer-lazy.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-error-explorer-lazy.tsx)
- 入口卡片Server Component渲染 wrapper[homework-assignment-exam-content-card.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/components/homework-assignment-exam-content-card.tsx)
### 10.5 校验
- `npm run lint`: 通过
- `npm run typecheck`: 通过
- `npm run build`: 通过
---
## 11. 学生成绩图表与排名2026-01-06
### 11.1 目标
在学生主页Dashboard展示
- 最近已批改作业的成绩趋势(百分比折线)
- 最近若干次已批改作业明细(标题、得分、时间)
- 班级排名(基于班级内作业总体得分百分比)
### 11.2 数据访问与计算口径
数据由 Homework 模块统一提供聚合查询,避免页面层拼 SQL
- 新增查询:[getStudentDashboardGrades](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/data-access.ts)
- `trend`:取该学生所有 `graded` 提交中“每个 assignment 最新一次”的集合,按时间升序取最近 10 个
- `recent`:对 `trend` 再按时间降序取最近 5 条,用于表格展示
- `maxScore`:通过 `homework_assignment_questions` 汇总每个 assignment 的总分SUM(score)
- `percentage``score / maxScore * 100`
- `ranking`
- 班级选择:取该学生最早创建的一条 active enrollment 作为当前班级
- 班级作业集合:班级内所有学生的 targets 合并得到 assignment 集合
- 计分口径:班级内“每个学生 × 每个 assignment”取最新一次 graded 提交,累加得分与满分,得到总体百分比
- 排名:按总体百分比降序排序(百分比相同按 studentId 作为稳定排序因子)
### 11.3 类型定义
为 Dashboard 聚合数据提供显式类型:
- [types.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/homework/types.ts)
- `StudentHomeworkScoreAnalytics`
- `StudentRanking`
- `StudentDashboardGradeProps`
### 11.4 页面与组件接入
- 学生主页页面负责“取数 + 计算基础计数 + 传参”:
- [student/dashboard/page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/student/dashboard/page.tsx)
- 取数:`getStudentDashboardGrades(student.id)`
- 传入:`<StudentDashboard grades={grades} />`
- 展示组件负责渲染卡片:
- [student-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/dashboard/components/student-view.tsx)
- 趋势图:使用内联 `svg polyline` 渲染折线,避免引入额外图表依赖
### 11.5 校验
- `npm run lint`: 通过
- `npm run typecheck`: 通过
- `npm run build`: 通过
---
## 12. UI/UX 优化更新2026-01-12
### 12.1 教师端作业列表 (`/teacher/homework/assignments`)
- **表格重构**: 从简单的卡片列表升级为功能丰富的数据表格Table
- **信息增强**: 合并展示标题/时间,使用 Badge 区分状态,清晰展示截止日期(含 Late 标记),可视化提交进度。
- **操作便捷**: 每行增加操作菜单Actions支持快速跳转详情或提交列表。
### 12.2 作业详情页 (`/teacher/homework/assignments/[id]`)
- **布局重构**:
- **Sticky Header**: 头部信息栏(标题、状态、面包屑)随滚动吸顶,但后续优化为随页面滚动(移除 Sticky以节省空间。
- **关键指标**: 将截止日期、目标数、提交数、已批改数整合到头部下方,使用图标增强可读性。
- **双栏布局**: 主体内容分为“Performance Analytics”分析和“Assignment Content”内容两部分。
- **图表升级**:
- 重构 `HomeworkAssignmentQuestionErrorOverviewCard`,废弃 SVG改用 **Recharts** 实现柱状图BarChart
- 增强交互:支持 Tooltip 悬停查看具体题目错误率和人数。
- **详情面板优化**:
- 移除了冗余的 `HomeworkAssignmentQuestionErrorDetailsCard`
- 深度优化 `HomeworkAssignmentQuestionErrorDetailPanel`:
- 增加饼图展示单题错误率。
- 错误答案列表卡片化,清晰展示每个错误答案的内容及选择人数。
- 整合预览面板与详情面板,提供更连贯的“左侧选题-右侧分析”体验。
---
## 13. Bug 修复与完善 (2026-01-13)
### 13.1 批改视图 (Grading View)
- **Type Safety Fix**: 修复了 `HomeworkGradingView` 组件中的 TypeScript 类型错误。
- 问题:`isCorrect` 字段可能为 `boolean | null`,直接用于 JSX 渲染导致 "Type 'unknown' is not assignable to type 'ReactNode'" 错误。
- 修复:增加显式布尔值检查 `opt.isCorrect === true`,确保 React 条件渲染接收到合法的 boolean 值。

View File

@@ -0,0 +1,164 @@
# 学校基础数据模块School实现文档与更新记录
**日期**: 2026-01-07
**作者**: Frontend Team
**状态**: 已实现
## 1. 范围
本文档覆盖管理端「School」域的基础数据维护页面Schools / Departments / Academic Year / Grades并记录相关实现约束与关键更新遵循 [003_frontend_engineering_standards.md](file:///c:/Users/xiner/Desktop/CICD/docs/architecture/003_frontend_engineering_standards.md) 的工程规范Vertical Slice、Server/Client 边界、质量门禁)。
## 2. 路由入口Admin
School 域路由位于 `src/app/(dashboard)/admin/school/*`,均显式声明 `export const dynamic = "force-dynamic"` 以避免构建期预渲染触发数据库访问。
- `/admin/school`:入口重定向到 Classes当前落点不在 `school` 模块内)
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/page.tsx)
- `/admin/school/schools`:学校维护(增删改)
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/schools/page.tsx)
- `/admin/school/departments`:部门维护(增删改)
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/departments/page.tsx)
- `/admin/school/academic-year`:学年维护(增删改 + 设为当前学年)
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/academic-year/page.tsx)
- `/admin/school/grades`:年级维护(增删改 + 指派年级组长/教研组长)
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/grades/page.tsx)
- `/admin/school/grades/insights`:年级维度作业统计(跨班级聚合)
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/grades/insights/page.tsx)
## 3. 模块结构Vertical Slice
School 模块位于 `src/modules/school`
```
src/modules/school/
├── components/
│ ├── schools-view.tsx
│ ├── departments-view.tsx
│ ├── academic-year-view.tsx
│ └── grades-view.tsx
├── actions.ts
├── data-access.ts
├── schema.ts
└── types.ts
```
边界约束:
- `data-access.ts` 包含 `import "server-only"`,仅用于服务端查询与 DTO 组装。
- `actions.ts` 包含 `"use server"`,写操作统一通过 Server Actions 并 `revalidatePath`
- `components/*` 为 Client 交互层表单、Dialog、筛选、行级操作调用 Server Actions 并用 `sonner` toast 反馈。
## 4. 数据访问data-access.ts
实现:[data-access.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/data-access.ts)
- `getSchools(): Promise<SchoolListItem[]>`
- `getDepartments(): Promise<DepartmentListItem[]>`
- `getAcademicYears(): Promise<AcademicYearListItem[]>`
- `getGrades(): Promise<GradeListItem[]>`
- join `schools` 获取 `school.name`
- 收集 `gradeHeadId/teachingHeadId` 并批量查询 `users` 以组装 `StaffOption`
- `getStaffOptions(): Promise<StaffOption[]>`
- 角色过滤 `teacher/admin`
- 排序 `name/email`,用于 Select 列表可用性
- `getGradesForStaff(staffId: string): Promise<GradeListItem[]>`
- 用于按负责人(年级组长/教研组长)反查关联年级
返回 DTO 类型定义位于:[types.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/types.ts)
## 5. 写操作actions.ts
实现:[actions.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/actions.ts)
通用约束:
- 输入校验:统一使用 [schema.ts](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/schema.ts) 的 Zod schema
- 返回结构:统一使用 [ActionState](file:///c:/Users/xiner/Desktop/CICD/src/shared/types/action-state.ts)
- 刷新策略:对目标页面路径执行 `revalidatePath`
Departments
- `createDepartmentAction(formData)`
- `updateDepartmentAction(departmentId, formData)`
- `deleteDepartmentAction(departmentId)`
Academic Year
- `createAcademicYearAction(formData)`
- `updateAcademicYearAction(academicYearId, formData)`
- `deleteAcademicYearAction(academicYearId)`
-`isActive=true` 时,通过事务把其它学年置为非激活,保证唯一激活学年
Schools
- `createSchoolAction(formData)`
- `updateSchoolAction(schoolId, formData)`
- `deleteSchoolAction(schoolId)`
- 删除后会同时刷新 `/admin/school/schools``/admin/school/grades`
Grades
- `createGradeAction(formData)`
- `updateGradeAction(gradeId, formData)`
- `deleteGradeAction(gradeId)`
## 6. UI 组件components/*
Schools
- 实现:[schools-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/schools-view.tsx)
- 交互:列表 + Dialog 表单(新增/编辑)+ 删除确认AlertDialog
Departments
- 实现:[departments-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/departments-view.tsx)
- 交互:列表 + Dialog 表单(新增/编辑)+ 删除确认AlertDialog
Academic Year
- 实现:[academic-year-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/academic-year-view.tsx)
- 交互:列表 + Dialog 表单(新增/编辑)+ 删除确认AlertDialog+ 设为当前学年isActive
Grades
- 实现:[grades-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/grades-view.tsx)
- 交互:列表展示 + URL 驱动筛选(搜索/学校/负责人/排序)+ Dialog 表单(新增/编辑)+ 删除确认AlertDialog
- 负责人指派:
- 年级组长gradeHeadId
- 教研组长teachingHeadId
## 7. 关键交互与规则Grades
页面入口RSC 组装)在服务端并发拉取三类数据:
- 年级列表:`getGrades()`
- 学校选项:`getSchools()`
- 负责人候选:`getStaffOptions()`
实现:[page.tsx](file:///c:/Users/xiner/Desktop/CICD/src/app/(dashboard)/admin/school/grades/page.tsx)
### 7.1 URL State筛选/排序)
Grades 列表页的筛选状态 URL 化(`nuqs`
- `q`:关键字(匹配 grade/school
- `school`:学校过滤(`all` 或具体 schoolId
- `head`:负责人过滤(全部 / 两者缺失 / 缺年级组长 / 缺教研组长)
- `sort`:排序(默认/名称/更新时间等)
实现:[grades-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/grades-view.tsx)
### 7.2 表单校验
Grades 的新增/编辑表单在客户端做轻量校验:
- 必填:`schoolId``name`
- `order`:非负整数
- 去重:同一学校下年级名称不允许重复(忽略大小写 + 规范化空格)
说明:
- 服务端写入前仍会经过 `UpsertGradeSchema` 校验schema.ts避免仅依赖客户端校验。
### 7.3 负责人选择Radix Select
Radix Select 约束:`SelectItem``value` 不能为 `""`(空字符串),否则会触发运行时错误。
当前实现策略:
- UI 中 “未设置” 选项使用占位值 `__none__`
-`onValueChange` 中将 `__none__` 映射回 `""` 存入本地表单 state
- 提交时依旧传递空字符串,由 `UpsertGradeSchema` 将其归一为 `null`
实现:[grades-view.tsx](file:///c:/Users/xiner/Desktop/CICD/src/modules/school/components/grades-view.tsx)
## 2. 更新记录2026-01-07
- 修复 Add Grades 弹窗报错:将 4 处 `<SelectItem value=\"\">` 替换为占位值 `__none__`,并在 `onValueChange` 中映射回 `\"\"`,保持“可清空选择/显示 placeholder”的行为不变。
- 修复新建年级按钮不可用:创建/编辑表单在状态变化时触发实时校验更新,避免校验状态滞后导致提交被禁用。
- 质量门禁:本地通过 `npm run lint``npm run typecheck`

View File

@@ -114,7 +114,10 @@ Next_Edu 旨在对抗教育系统常见的信息过载。我们的设计风格
* **Height**: `64px` (h-16).
* **Layout**: `flex items-center justify-between px-6 border-b`.
* **Components**:
1. **Breadcrumb**: 显示当前路径,层级清晰。
1. **Breadcrumb**: 动态路径导航 (Dynamic Breadcrumb).
* **Implementation**: 基于 `usePathname()` 自动解析路由段。
* **Mapping**: 通过 `NAV_CONFIG``BREADCRUMB_MAP` 映射路径到友好标题 (e.g., `/teacher/textbooks` -> "Textbooks").
* **Filtering**: 自动过滤根角色路径 (e.g., `/teacher`) 以保持简洁。
2. **Global Search**: `Cmd+K` 触发,居中或靠右。
3. **User Nav**: 头像 + 下拉菜单。

107
docs/work_log.md Normal file
View File

@@ -0,0 +1,107 @@
# Work Log
## 2026-01-15
### 1. Schedule Module Optimization
* **Visual Overhaul (`schedule-view.tsx`)**:
* Refactored the schedule grid to match the exact design of the `ClassScheduleGrid` widget.
* Implemented a clean, borderless layout with no grid lines for a modern look.
* **Time-Based Positioning**: Replaced grid-row logic with absolute positioning based on time (8:00 - 18:00 range) using percentage calculations (`getPositionStyle`).
* **Color Coding**: Added `getSubjectColor` to auto-assign thematic colors (blue for Math, purple for Physics, etc.) based on course names.
* **Card Design**: Refined course cards with vertical centering, better spacing, and removed unnecessary UI elements (like the "+" button in headers).
* **Filter Bar Refinement (`schedule-filters.tsx`)**:
* **Minimalist Design**: Removed borders and shadows from the class selector and buttons to integrate seamlessly with the background.
* **Center Label**: Added a dynamic, absolute-centered text label that updates based on selection:
* Shows "All Classes" when no filter is active.
* Shows "{Class Name}" when a specific class is selected.
* **Simplified Controls**: Removed the "Reset" button (X icon) entirely for a cleaner interface.
* **Ghost Buttons**: Styled the "Add Event" button as a ghost variant with muted colors.
### 2. Architecture & Cleanup
* **Insights Module Removal**:
* Deleted the entire `src/app/(dashboard)/teacher/classes/insights` directory as the feature was deemed redundant.
* Removed `insights-filters.tsx` component.
* Updated `navigation.ts` to remove the "Insights" link from the sidebar.
* *Note*: Preserved `getClassHomeworkInsights` in `data-access.ts` as it's still used by the Class Detail dashboard widgets.
### 3. Verification
* **Type Safety**: Ran `npm run typecheck` multiple times during refactoring to ensure no regressions (Passed).
* **Build**: Attempted to clear build cache to resolve a chunk loading error (Windows permission issue encountered but workaround applied).
## 2026-01-14
### 1. Class Management Refactoring (Role Separation)
* **Separation of Duties**:
* Moved class creation and management responsibilities from the generic Teacher view to a dedicated Management view.
* Created **Grade Management Page** at `src/app/(dashboard)/management/grade/classes/page.tsx` for Grade Directors and Admins.
* Teachers can now only **Join Classes** (via code) or view their assigned classes in "My Classes".
* **New Components & Pages**:
* `GradeClassesView` (`src/modules/classes/components/grade-classes-view.tsx`): A comprehensive table view for managing classes within specific grades, supporting creation, editing, and deletion.
* `GradeClassesPage`: Server Component that fetches managed grades and classes using strict RBAC (Role-Based Access Control).
* **Teacher "My Classes" Update (`my-classes-grid.tsx`)**:
* Removed the "Create Class" button/dialog.
* Added a **"Join Class"** dialog that accepts a 6-digit invitation code.
* Updated styling to use standard design system colors (`bg-card`, `border-border`) instead of hardcoded gradients.
### 2. Backend & Logic Updates
* **Data Access (`data-access.ts`)**:
* Implemented `getGradeManagedClasses`: Fetches classes for grades where the user is either a Grade Head or Teaching Head.
* Implemented `getManagedGrades`: Fetches the list of grades managed by the user for the creation dropdown.
* Updated `getTeacherClasses`: Now returns both **owned classes** (assigned by admin) and **enrolled classes** (joined via code).
* Fixed a SQL syntax error in `getGradeManagedClasses` (unescaped backticks in template literal).
* **Server Actions (`actions.ts`)**:
* Added `createGradeClassAction`, `updateGradeClassAction`, `deleteGradeClassAction`: These actions enforce that the user manages the target grade before performing operations.
* Updated `joinClassByInvitationCodeAction`: Expanded to allow Teachers (role `teacher`) to join classes, not just Students.
### 3. Verification
* **RBAC**: Verified that users can only manage classes for grades they are assigned to.
* **Flow**: Verified Teacher "Join Class" flow correctly redirects and updates the list.
* **Syntax**: Fixed TypeScript/SQL syntax errors in the new data access functions.
### 4. Class UI/UX Optimization
* **Students Management Interface (`students-table.tsx`, `students-filters.tsx`)**:
* **Enhanced Table**: Added student avatars, gender display, and join date.
* **Pagination**: Implemented client-side pagination (10 items per page) to handle larger classes gracefully.
* **Status Filtering**: Added "Active/Inactive" filter with visual status badges (Emerald for active, muted for inactive).
* **Data Access**: Updated `getClassStudents` to fetch extended user profile data and support server-side status filtering.
* **Class Detail Dashboard (`/teacher/classes/my/[id]/page.tsx`)**:
* **Dashboard Layout**: Refactored into a responsive two-column layout (Main Content + Sidebar).
* **Key Metrics**: Added a 4-card stats grid at the top displaying critical insights:
* Total Students (Active/Inactive breakdown)
* Schedule Items (Weekly sessions)
* Active Assignments (Overdue count)
* Class Average (Based on graded submissions)
* **Sidebar Widgets**: Added "Class Schedule" and "Homework History" widgets for quick access to temporal data.
* **Visual Polish**: Integrated `lucide-react` icons throughout for better information scanning.
## 2026-01-13
### 1. Navigation & Layout Improvements
* **Dynamic Breadcrumbs (`site-header.tsx`)**:
* Replaced hardcoded "Dashboard > Overview" breadcrumbs with a dynamic system.
* Implemented a path-to-title lookup using `NAV_CONFIG` from `src/modules/layout/config/navigation.ts`.
* Added logic to filter out root role segments (admin/teacher/student/parent) for cleaner paths.
* Added fallback capitalization for segments not found in the config.
* Refactored `SiteHeader` to use `usePathname` for real-time route updates.
### 2. Code Quality & Bug Fixes
* **Type Safety (`homework-grading-view.tsx`)**:
* Fixed a TypeScript error where a boolean expression was returning `boolean | undefined` which is not a valid React node (implicit `true` check added).
* Resolved "Calling setState synchronously within an effect" React warning by initializing state lazily instead of using `useEffect`.
* Fixed implicit `any` type errors in map functions.
* **Linting**:
* Cleaned up unused imports across multiple files (`exam-actions.tsx`, `exam-assembly.tsx`, `textbook-reader.tsx`, etc.).
* Fixed unescaped HTML entities in `student-dashboard-header.tsx` and others.
* Removed unused variables to clear ESLint warnings.
* **Refactoring**:
* Updated `TextbookCard` to support `hideActions` prop for better reuse in student views.
* Added missing `Progress` component to `src/shared/components/ui/progress.tsx`.
### 3. Verification
* Ran `npm run typecheck`: **Passed** (0 errors).
* Ran `npm run lint`: **Passed** (0 errors, 28 warnings remaining for unused vars/components that may be needed later).

View File

@@ -0,0 +1,43 @@
CREATE TABLE `class_enrollments` (
`class_id` varchar(128) NOT NULL,
`student_id` varchar(128) NOT NULL,
`class_enrollment_status` enum('active','inactive') NOT NULL DEFAULT 'active',
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `class_enrollments_class_id_student_id_pk` PRIMARY KEY(`class_id`,`student_id`)
);
--> statement-breakpoint
CREATE TABLE `class_schedule` (
`id` varchar(128) NOT NULL,
`class_id` varchar(128) NOT NULL,
`weekday` int NOT NULL,
`start_time` varchar(5) NOT NULL,
`end_time` varchar(5) NOT NULL,
`course` varchar(255) NOT NULL,
`location` varchar(100),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `class_schedule_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `classes` (
`id` varchar(128) NOT NULL,
`name` varchar(255) NOT NULL,
`grade` varchar(50) NOT NULL,
`homeroom` varchar(50),
`room` varchar(50),
`teacher_id` varchar(128) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `classes_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `class_enrollments` ADD CONSTRAINT `ce_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `class_enrollments` ADD CONSTRAINT `ce_s_fk` FOREIGN KEY (`student_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `class_schedule` ADD CONSTRAINT `cs_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `classes` ADD CONSTRAINT `classes_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `class_enrollments_class_idx` ON `class_enrollments` (`class_id`);--> statement-breakpoint
CREATE INDEX `class_enrollments_student_idx` ON `class_enrollments` (`student_id`);--> statement-breakpoint
CREATE INDEX `class_schedule_class_idx` ON `class_schedule` (`class_id`);--> statement-breakpoint
CREATE INDEX `class_schedule_class_day_idx` ON `class_schedule` (`class_id`,`weekday`);--> statement-breakpoint
CREATE INDEX `classes_teacher_idx` ON `classes` (`teacher_id`);--> statement-breakpoint
CREATE INDEX `classes_grade_idx` ON `classes` (`grade`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `chapters` ADD `content` text;--> statement-breakpoint
ALTER TABLE `knowledge_points` ADD `chapter_id` varchar(128);--> statement-breakpoint
CREATE INDEX `kp_chapter_id_idx` ON `knowledge_points` (`chapter_id`);

View File

@@ -0,0 +1,52 @@
CREATE TABLE `academic_years` (
`id` varchar(128) NOT NULL,
`name` varchar(100) NOT NULL,
`start_date` timestamp NOT NULL,
`end_date` timestamp NOT NULL,
`is_active` boolean NOT NULL DEFAULT false,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `academic_years_id` PRIMARY KEY(`id`),
CONSTRAINT `academic_years_name_unique` UNIQUE(`name`)
);
--> statement-breakpoint
CREATE TABLE `class_subject_teachers` (
`class_id` varchar(128) NOT NULL,
`subject` enum('语文','数学','英语','美术','体育','科学','社会','音乐') NOT NULL,
`teacher_id` varchar(128),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `class_subject_teachers_class_id_subject_pk` PRIMARY KEY(`class_id`,`subject`)
);
--> statement-breakpoint
CREATE TABLE `classrooms` (
`id` varchar(128) NOT NULL,
`name` varchar(255) NOT NULL,
`building` varchar(100),
`floor` int,
`capacity` int,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `classrooms_id` PRIMARY KEY(`id`),
CONSTRAINT `classrooms_name_unique` UNIQUE(`name`)
);
--> statement-breakpoint
CREATE TABLE `departments` (
`id` varchar(128) NOT NULL,
`name` varchar(255) NOT NULL,
`description` text,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `departments_id` PRIMARY KEY(`id`),
CONSTRAINT `departments_name_unique` UNIQUE(`name`)
);
--> statement-breakpoint
ALTER TABLE `classes` ADD `school_name` varchar(255);--> statement-breakpoint
ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `class_subject_teachers_teacher_id_users_id_fk` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `class_subject_teachers` ADD CONSTRAINT `cst_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `academic_years_name_idx` ON `academic_years` (`name`);--> statement-breakpoint
CREATE INDEX `academic_years_active_idx` ON `academic_years` (`is_active`);--> statement-breakpoint
CREATE INDEX `class_subject_teachers_class_idx` ON `class_subject_teachers` (`class_id`);--> statement-breakpoint
CREATE INDEX `class_subject_teachers_teacher_idx` ON `class_subject_teachers` (`teacher_id`);--> statement-breakpoint
CREATE INDEX `classrooms_name_idx` ON `classrooms` (`name`);--> statement-breakpoint
CREATE INDEX `departments_name_idx` ON `departments` (`name`);

View File

@@ -0,0 +1,38 @@
CREATE TABLE `grades` (
`id` varchar(128) NOT NULL,
`school_id` varchar(128) NOT NULL,
`name` varchar(100) NOT NULL,
`order` int NOT NULL DEFAULT 0,
`grade_head_id` varchar(128),
`teaching_head_id` varchar(128),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `grades_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `schools` (
`id` varchar(128) NOT NULL,
`name` varchar(255) NOT NULL,
`code` varchar(50),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `schools_id` PRIMARY KEY(`id`),
CONSTRAINT `schools_name_unique` UNIQUE(`name`),
CONSTRAINT `schools_code_unique` UNIQUE(`code`)
);
--> statement-breakpoint
ALTER TABLE `classes` ADD `school_id` varchar(128);--> statement-breakpoint
ALTER TABLE `classes` ADD `grade_id` varchar(128);--> statement-breakpoint
ALTER TABLE `grades` ADD CONSTRAINT `g_s_fk` FOREIGN KEY (`school_id`) REFERENCES `schools`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `grades` ADD CONSTRAINT `g_gh_fk` FOREIGN KEY (`grade_head_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `grades` ADD CONSTRAINT `g_th_fk` FOREIGN KEY (`teaching_head_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `grades_school_idx` ON `grades` (`school_id`);--> statement-breakpoint
CREATE INDEX `grades_school_name_uniq` ON `grades` (`school_id`,`name`);--> statement-breakpoint
CREATE INDEX `grades_grade_head_idx` ON `grades` (`grade_head_id`);--> statement-breakpoint
CREATE INDEX `grades_teaching_head_idx` ON `grades` (`teaching_head_id`);--> statement-breakpoint
CREATE INDEX `schools_name_idx` ON `schools` (`name`);--> statement-breakpoint
CREATE INDEX `schools_code_idx` ON `schools` (`code`);--> statement-breakpoint
ALTER TABLE `classes` ADD CONSTRAINT `c_s_fk` FOREIGN KEY (`school_id`) REFERENCES `schools`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `classes` ADD CONSTRAINT `c_g_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `classes_school_idx` ON `classes` (`school_id`);--> statement-breakpoint
CREATE INDEX `classes_grade_id_idx` ON `classes` (`grade_id`);

View File

@@ -0,0 +1,6 @@
ALTER TABLE `exams` ADD `subject_id` varchar(128);--> statement-breakpoint
ALTER TABLE `exams` ADD `grade_id` varchar(128);--> statement-breakpoint
ALTER TABLE `exams` ADD CONSTRAINT `exams_subject_id_subjects_id_fk` FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `exams` ADD CONSTRAINT `exams_grade_id_grades_id_fk` FOREIGN KEY (`grade_id`) REFERENCES `grades`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `exams_subject_idx` ON `exams` (`subject_id`);--> statement-breakpoint
CREATE INDEX `exams_grade_idx` ON `exams` (`grade_id`);

View File

@@ -0,0 +1 @@
ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,48 @@
"when": 1767145757594,
"tag": "0002_equal_wolfpack",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1767166769676,
"tag": "0003_petite_newton_destine",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1767169003334,
"tag": "0004_add_chapter_content_and_kp_chapter",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1767751916045,
"tag": "0005_add_class_school_subject_teachers",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1767760693171,
"tag": "0006_faithful_king_bedlam",
"breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1768205524480,
"tag": "0007_talented_bromley",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1768470966367,
"tag": "0008_thin_madrox",
"breakpoints": true
}
]
}

3032
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,9 @@
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"db:seed": "npx tsx scripts/seed.ts"
"db:seed": "npx tsx scripts/seed.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -20,8 +22,12 @@
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
@@ -31,6 +37,10 @@
"@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-placeholder": "^3.15.3",
"@tiptap/pm": "^3.15.3",
"@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
@@ -43,15 +53,21 @@
"react": "19.2.1",
"react-dom": "19.2.1",
"react-hook-form": "^7.69.0",
"react-markdown": "^10.1.0",
"recharts": "^3.6.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tiptap-markdown": "^0.9.0",
"zod": "^4.2.1",
"zustand": "^5.0.9"
},
"devDependencies": {
"@faker-js/faker": "^10.1.0",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View File

@@ -3,8 +3,17 @@ import { db } from "../src/shared/db";
import {
users, roles, usersToRoles,
questions, knowledgePoints, questionsToKnowledgePoints,
exams, examQuestions, examSubmissions, submissionAnswers,
textbooks, chapters
exams, examQuestions,
homeworkAssignments,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkSubmissions,
homeworkAnswers,
textbooks, chapters,
schools,
grades,
classes, classEnrollments, classSchedule,
subjects
} from "../src/shared/db/schema";
import { createId } from "@paralleldrive/cuid2";
import { faker } from "@faker-js/faker";
@@ -30,9 +39,12 @@ async function seed() {
try {
await db.execute(sql`SET FOREIGN_KEY_CHECKS = 0;`);
const tables = [
"class_schedule", "class_enrollments", "classes",
"homework_answers", "homework_submissions", "homework_assignment_targets", "homework_assignment_questions", "homework_assignments",
"submission_answers", "exam_submissions", "exam_questions", "exams",
"questions_to_knowledge_points", "questions", "knowledge_points",
"chapters", "textbooks",
"grades", "schools", "subjects",
"users_to_roles", "roles", "users", "accounts", "sessions"
];
for (const table of tables) {
@@ -52,14 +64,16 @@ async function seed() {
admin: "role_admin",
teacher: "role_teacher",
student: "role_student",
grade_head: "role_grade_head"
grade_head: "role_grade_head",
teaching_head: "role_teaching_head"
};
await db.insert(roles).values([
{ id: roleMap.admin, name: "admin", description: "System Administrator" },
{ id: roleMap.teacher, name: "teacher", description: "Academic Instructor" },
{ id: roleMap.student, name: "student", description: "Learner" },
{ id: roleMap.grade_head, name: "grade_head", description: "Head of Grade Year" }
{ id: roleMap.grade_head, name: "grade_head", description: "Head of Grade Year" },
{ id: roleMap.teaching_head, name: "teaching_head", description: "Teaching Research Lead" }
]);
// Users
@@ -98,6 +112,118 @@ async function seed() {
{ userId: "user_student_1", roleId: roleMap.student },
]);
const extraStudentIds: string[] = [];
for (let i = 0; i < 12; i++) {
const studentId = createId();
extraStudentIds.push(studentId);
await db.insert(users).values({
id: studentId,
name: faker.person.fullName(),
email: faker.internet.email().toLowerCase(),
role: "student",
image: `https://api.dicebear.com/7.x/avataaars/svg?seed=${studentId}`,
});
await db.insert(usersToRoles).values({ userId: studentId, roleId: roleMap.student });
}
const schoolId = "school_nextedu"
const grade10Id = "grade_10"
await db.insert(schools).values([
{ id: schoolId, name: "Next_Edu School", code: "NEXTEDU" },
{ id: "school_demo_2", name: "Demo School No.2", code: "DEMO2" },
])
// --- Seeding Subjects ---
await db.insert(subjects).values([
{ id: createId(), name: "Mathematics", code: "MATH", order: 1 },
{ id: createId(), name: "Physics", code: "PHYS", order: 2 },
{ id: createId(), name: "Chemistry", code: "CHEM", order: 3 },
{ id: createId(), name: "English", code: "ENG", order: 4 },
{ id: createId(), name: "History", code: "HIST", order: 5 },
{ id: createId(), name: "Geography", code: "GEO", order: 6 },
{ id: createId(), name: "Biology", code: "BIO", order: 7 },
])
await db.insert(grades).values([
{
id: grade10Id,
schoolId,
name: "Grade 10",
order: 10,
gradeHeadId: "user_teacher_math",
teachingHeadId: "user_teacher_math",
},
])
await db.insert(classes).values([
{
id: "class_10_3",
schoolName: "Next_Edu School",
schoolId,
name: "Grade 10 · Class 3",
grade: "Grade 10",
gradeId: grade10Id,
homeroom: "10-3",
room: "Room 304",
invitationCode: "100003",
teacherId: "user_teacher_math",
},
{
id: "class_10_7",
schoolName: "Next_Edu School",
schoolId,
name: "Grade 10 · Class 7",
grade: "Grade 10",
gradeId: grade10Id,
homeroom: "10-7",
room: "Room 201",
invitationCode: "100007",
teacherId: "user_teacher_math",
},
]);
await db.insert(classEnrollments).values([
{ classId: "class_10_3", studentId: "user_student_1", status: "active" },
...extraStudentIds.slice(0, 8).map((studentId) => ({ classId: "class_10_3", studentId, status: "active" as const })),
...extraStudentIds.slice(8, 12).map((studentId) => ({ classId: "class_10_7", studentId, status: "active" as const })),
]);
await db.insert(classSchedule).values([
{ id: "cs_001", classId: "class_10_3", weekday: 1, startTime: "09:00", endTime: "09:45", course: "Mathematics", location: "Room 304" },
{ id: "cs_002", classId: "class_10_3", weekday: 3, startTime: "14:00", endTime: "14:45", course: "Physics", location: "Lab A" },
{ id: "cs_003", classId: "class_10_7", weekday: 2, startTime: "11:00", endTime: "11:45", course: "Mathematics", location: "Room 201" },
]);
await db.insert(textbooks).values([
{
id: "tb_01",
title: "Advanced Mathematics Grade 10",
subject: "Mathematics",
grade: "Grade 10",
publisher: "Next Education Press",
},
])
await db.insert(chapters).values([
{
id: "ch_01",
textbookId: "tb_01",
title: "Chapter 1: Real Numbers",
order: 1,
parentId: null,
content: "# Chapter 1: Real Numbers\n\nIn this chapter, we will explore the properties of real numbers...",
},
{
id: "ch_01_01",
textbookId: "tb_01",
title: "1.1 Introduction to Real Numbers",
order: 1,
parentId: "ch_01",
content: "## 1.1 Introduction\n\nReal numbers include rational and irrational numbers.",
},
])
// --- 2. Knowledge Graph (Tree) ---
console.log("🧠 Seeding Knowledge Graph...");
@@ -111,103 +237,273 @@ async function seed() {
{ id: kpLinearId, name: "Linear Equations", parentId: kpAlgebraId, level: 2 },
]);
await db.insert(knowledgePoints).values([
{
id: "kp_01",
name: "Real Numbers",
description: "Definition and properties of real numbers",
level: 1,
order: 1,
chapterId: "ch_01",
},
{
id: "kp_02",
name: "Rational Numbers",
description: "Numbers that can be expressed as a fraction",
level: 2,
order: 1,
chapterId: "ch_01_01",
},
])
// --- 3. Question Bank (Rich Content) ---
console.log("📚 Seeding Question Bank...");
// 3.1 Simple Single Choice
const qSimpleId = createId();
await db.insert(questions).values({
id: qSimpleId,
authorId: "user_teacher_math",
type: "single_choice",
difficulty: 1,
content: {
text: "What is 2 + 2?",
options: [
{ id: "A", text: "3", isCorrect: false },
{ id: "B", text: "4", isCorrect: true },
{ id: "C", text: "5", isCorrect: false }
]
}
});
// Link to KP
await db.insert(questionsToKnowledgePoints).values({
questionId: qSimpleId,
knowledgePointId: kpLinearId // Just for demo
});
// 3.2 Composite Question (Reading Comprehension)
const qParentId = createId();
const qChild1Id = createId();
const qChild2Id = createId();
// Parent (Passage)
await db.insert(questions).values({
id: qParentId,
authorId: "user_teacher_math",
type: "composite",
difficulty: 3,
content: {
text: "Read the following passage about Algebra...\n(Long text here)...",
assets: []
}
});
// Children
await db.insert(questions).values([
const mathExamQuestions: Array<{
id: string;
type: "single_choice" | "text" | "judgment";
difficulty: number;
content: unknown;
score: number;
}> = [
{
id: qChild1Id,
authorId: "user_teacher_math",
parentId: qParentId, // <--- Key: Nested
id: createId(),
type: "single_choice",
difficulty: 2,
difficulty: 1,
score: 4,
content: {
text: "What is the main topic?",
text: "1) What is 2 + 2?",
options: [
{ id: "A", text: "Geometry", isCorrect: false },
{ id: "B", text: "Algebra", isCorrect: true }
]
}
{ id: "A", text: "3", isCorrect: false },
{ id: "B", text: "4", isCorrect: true },
{ id: "C", text: "5", isCorrect: false },
{ id: "D", text: "6", isCorrect: false },
],
},
},
{
id: qChild2Id,
authorId: "user_teacher_math",
parentId: qParentId,
type: "text",
difficulty: 4,
id: createId(),
type: "single_choice",
difficulty: 2,
score: 4,
content: {
text: "Explain the concept of variables.",
}
}
]);
text: "2) If f(x) = 2x + 1, then f(3) = ?",
options: [
{ id: "A", text: "5", isCorrect: false },
{ id: "B", text: "7", isCorrect: true },
{ id: "C", text: "8", isCorrect: false },
{ id: "D", text: "10", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 2,
score: 4,
content: {
text: "3) Solve 3x - 5 = 7. What is x?",
options: [
{ id: "A", text: "3", isCorrect: false },
{ id: "B", text: "4", isCorrect: true },
{ id: "C", text: "5", isCorrect: false },
{ id: "D", text: "6", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 3,
score: 4,
content: {
text: "4) Which is a factor of x^2 - 9?",
options: [
{ id: "A", text: "(x - 3)", isCorrect: true },
{ id: "B", text: "(x + 9)", isCorrect: false },
{ id: "C", text: "(x - 9)", isCorrect: false },
{ id: "D", text: "(x^2 + 9)", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 2,
score: 4,
content: {
text: "5) If a^2 = 49 and a > 0, then a = ?",
options: [
{ id: "A", text: "-7", isCorrect: false },
{ id: "B", text: "0", isCorrect: false },
{ id: "C", text: "7", isCorrect: true },
{ id: "D", text: "49", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 3,
score: 4,
content: {
text: "6) Simplify (x^2 y)(x y^3).",
options: [
{ id: "A", text: "x^2 y^3", isCorrect: false },
{ id: "B", text: "x^3 y^4", isCorrect: true },
{ id: "C", text: "x^3 y^3", isCorrect: false },
{ id: "D", text: "x^4 y^4", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 2,
score: 4,
content: {
text: "7) The slope of the line y = -3x + 2 is:",
options: [
{ id: "A", text: "2", isCorrect: false },
{ id: "B", text: "-3", isCorrect: true },
{ id: "C", text: "3", isCorrect: false },
{ id: "D", text: "-2", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 1,
score: 4,
content: {
text: "8) The probability of getting heads in one fair coin toss is:",
options: [
{ id: "A", text: "0", isCorrect: false },
{ id: "B", text: "1/4", isCorrect: false },
{ id: "C", text: "1/2", isCorrect: true },
{ id: "D", text: "1", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 2,
score: 4,
content: {
text: "9) In an arithmetic sequence with a1 = 2 and d = 3, a5 = ?",
options: [
{ id: "A", text: "11", isCorrect: false },
{ id: "B", text: "12", isCorrect: false },
{ id: "C", text: "14", isCorrect: true },
{ id: "D", text: "17", isCorrect: false },
],
},
},
{
id: createId(),
type: "single_choice",
difficulty: 2,
score: 4,
content: {
text: "10) The solution set of x^2 = 0 is:",
options: [
{ id: "A", text: "{0}", isCorrect: true },
{ id: "B", text: "{1}", isCorrect: false },
{ id: "C", text: "{-1, 1}", isCorrect: false },
{ id: "D", text: "Empty set", isCorrect: false },
],
},
},
{ id: createId(), type: "text", difficulty: 1, score: 4, content: { text: "11) Fill in the blank: √81 = ____.", correctAnswer: "9" } },
{ id: createId(), type: "text", difficulty: 2, score: 4, content: { text: "12) Fill in the blank: (a - b)^2 = ____.", correctAnswer: ["a^2 - 2ab + b^2", "a² - 2ab + b²"] } },
{ id: createId(), type: "text", difficulty: 1, score: 4, content: { text: "13) Fill in the blank: 2^5 = ____.", correctAnswer: "32" } },
{ id: createId(), type: "text", difficulty: 2, score: 4, content: { text: "14) Fill in the blank: The area of a circle with radius r is ____.", correctAnswer: ["πr^2", "pi r^2", "πr²"] } },
{ id: createId(), type: "text", difficulty: 1, score: 4, content: { text: "15) Fill in the blank: If x = -2, then x^3 = ____.", correctAnswer: "-8" } },
{ id: createId(), type: "judgment", difficulty: 1, score: 2, content: { text: "16) If x > y, then x + 1 > y + 1.", correctAnswer: true } },
{ id: createId(), type: "judgment", difficulty: 1, score: 2, content: { text: "17) The graph of y = 2x is a parabola.", correctAnswer: false } },
{ id: createId(), type: "judgment", difficulty: 1, score: 2, content: { text: "18) The sum of interior angles of a triangle is 180°.", correctAnswer: true } },
{ id: createId(), type: "judgment", difficulty: 2, score: 2, content: { text: "19) (x + y)^2 = x^2 + y^2.", correctAnswer: false } },
{ id: createId(), type: "judgment", difficulty: 1, score: 2, content: { text: "20) 0 is a positive number.", correctAnswer: false } },
{ id: createId(), type: "text", difficulty: 3, score: 10, content: { text: "21) Solve the system: x + y = 5, x - y = 1." } },
{ id: createId(), type: "text", difficulty: 3, score: 10, content: { text: "22) Expand and simplify: (2x - 3)(x + 4)." } },
{ id: createId(), type: "text", difficulty: 3, score: 10, content: { text: "23) In a right triangle with legs 6 and 8, find the hypotenuse and the area." } },
];
await db.insert(questions).values(
mathExamQuestions.map((q) => ({
id: q.id,
type: q.type,
difficulty: q.difficulty,
content: q.content,
authorId: "user_teacher_math",
}))
);
await db.insert(questionsToKnowledgePoints).values({
questionId: mathExamQuestions[0].id,
knowledgePointId: kpLinearId
});
// --- 4. Exams (New Structure) ---
console.log("📝 Seeding Exams...");
const examId = createId();
const makeGroup = (title: string, children: unknown[]) => ({
id: createId(),
type: "group",
title,
children,
});
const makeQuestionNode = (questionId: string, score: number) => ({
id: createId(),
type: "question",
questionId,
score,
});
const choiceIds = mathExamQuestions.slice(0, 10).map((q) => q.id);
const fillIds = mathExamQuestions.slice(10, 15).map((q) => q.id);
const judgmentIds = mathExamQuestions.slice(15, 20).map((q) => q.id);
const shortAnswerIds = mathExamQuestions.slice(20, 23).map((q) => q.id);
const examStructure = [
{
type: "group",
title: "Part 1: Basics",
children: [
{ type: "question", questionId: qSimpleId, score: 10 }
]
},
{
type: "group",
title: "Part 2: Reading",
children: [
// For composite questions, we usually add the parent, and the system fetches children
{ type: "question", questionId: qParentId, score: 20 }
]
}
makeGroup(
"第一部分单项选择题共10题每题4分共40分",
choiceIds.map((id) => makeQuestionNode(id, 4))
),
makeGroup(
"第二部分填空题共5题每题4分共20分",
fillIds.map((id) => makeQuestionNode(id, 4))
),
makeGroup(
"第三部分判断题共5题每题2分共10分",
judgmentIds.map((id) => makeQuestionNode(id, 2))
),
makeGroup(
"第四部分解答题共3题每题10分共30分",
shortAnswerIds.map((id) => makeQuestionNode(id, 10))
),
];
await db.insert(exams).values({
id: examId,
title: "Algebra Mid-Term 2025",
description: "Comprehensive assessment",
title: "Grade 10 Mathematics Final Exam (Seed)",
description: JSON.stringify({
subject: "Mathematics",
grade: "Grade 10",
difficulty: 3,
totalScore: 100,
durationMin: 120,
questionCount: 23,
tags: ["seed", "math", "grade10", "final"],
}),
creatorId: "user_teacher_math",
status: "published",
startTime: new Date(),
@@ -215,15 +511,118 @@ async function seed() {
});
// Link questions physically (Source of Truth)
await db.insert(examQuestions).values([
{ examId, questionId: qSimpleId, score: 10, order: 0 },
{ examId, questionId: qParentId, score: 20, order: 1 },
// Note: Child questions are often implicitly included or explicitly added depending on logic.
// For this seed, we assume linking Parent is enough for the relation,
// but let's link children too for completeness if the query strategy requires it.
{ examId, questionId: qChild1Id, score: 0, order: 2 },
{ examId, questionId: qChild2Id, score: 0, order: 3 },
]);
const orderedQuestionIds = [...choiceIds, ...fillIds, ...judgmentIds, ...shortAnswerIds];
const scoreById = new Map(mathExamQuestions.map((q) => [q.id, q.score] as const));
await db.insert(examQuestions).values(
orderedQuestionIds.map((questionId, order) => ({
examId,
questionId,
score: scoreById.get(questionId) ?? 0,
order,
}))
);
console.log("📌 Seeding Homework Assignments...");
const assignmentId = createId();
const now = new Date();
const dueAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const lateDueAt = new Date(now.getTime() + 9 * 24 * 60 * 60 * 1000);
await db.insert(homeworkAssignments).values({
id: assignmentId,
sourceExamId: examId,
title: "Grade 10 Mathematics Final - Homework (Seed)",
description: "Auto-generated homework assignment from seeded math exam.",
structure: examStructure as unknown,
status: "published",
creatorId: "user_teacher_math",
availableAt: now,
dueAt,
allowLate: true,
lateDueAt,
maxAttempts: 2,
});
await db.insert(homeworkAssignmentQuestions).values(
orderedQuestionIds.map((questionId, order) => ({
assignmentId,
questionId,
score: scoreById.get(questionId) ?? 0,
order,
}))
);
const targetStudentIds = ["user_student_1", ...extraStudentIds.slice(0, 4)];
await db.insert(homeworkAssignmentTargets).values(
targetStudentIds.map((studentId) => ({
assignmentId,
studentId,
}))
);
const scoreForQuestion = (questionId: string) => scoreById.get(questionId) ?? 0;
const buildAnswer = (questionId: string, type: string) => {
if (type === "single_choice") return { answer: "B" };
if (type === "judgment") return { answer: true };
return { answer: "Seed answer" };
};
const questionTypeById = new Map(mathExamQuestions.map((q) => [q.id, q.type] as const));
const submissionIds: string[] = [];
for (let i = 0; i < 3; i++) {
const studentId = targetStudentIds[i];
const submissionId = createId();
submissionIds.push(submissionId);
const submittedAt = new Date(now.getTime() - (i + 1) * 24 * 60 * 60 * 1000);
const status = i === 0 ? "graded" : i === 1 ? "graded" : "submitted";
const perQuestionScores = orderedQuestionIds.map((qid, idx) => {
const max = scoreForQuestion(qid);
if (status !== "graded") return null;
if (max <= 0) return 0;
if (idx % 7 === 0) return Math.max(0, max - 1);
return max;
});
const totalScore =
status === "graded"
? perQuestionScores.reduce<number>((sum, s) => sum + Number(s ?? 0), 0)
: null;
await db.insert(homeworkSubmissions).values({
id: submissionId,
assignmentId,
studentId,
attemptNo: 1,
score: totalScore,
status,
startedAt: submittedAt,
submittedAt,
isLate: false,
createdAt: submittedAt,
updatedAt: submittedAt,
});
await db.insert(homeworkAnswers).values(
orderedQuestionIds.map((questionId, idx) => {
const questionType = questionTypeById.get(questionId) ?? "text";
const score = status === "graded" ? (perQuestionScores[idx] ?? 0) : null;
return {
id: createId(),
submissionId,
questionId,
answerContent: buildAnswer(questionId, questionType),
score,
feedback: status === "graded" ? (score === scoreForQuestion(questionId) ? "Good" : "Check calculation") : null,
createdAt: submittedAt,
updatedAt: submittedAt,
};
})
);
}
const end = performance.now();
console.log(`✅ Seed completed in ${(end - start).toFixed(2)}ms`);

View File

@@ -1,20 +1,9 @@
"use client"
import { useEffect } from "react"
import { Button } from "@/shared/components/ui/button"
import { AlertCircle } from "lucide-react"
export default function AuthError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
export default function AuthError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">

View File

@@ -1,4 +1,5 @@
import { Metadata } from "next"
import { Suspense } from "react"
import { LoginForm } from "@/modules/auth/components/login-form"
export const metadata: Metadata = {
@@ -7,5 +8,9 @@ export const metadata: Metadata = {
}
export default function LoginPage() {
return <LoginForm />
return (
<Suspense fallback={null}>
<LoginForm />
</Suspense>
)
}

View File

@@ -1,4 +1,8 @@
import { Metadata } from "next"
import { createId } from "@paralleldrive/cuid2"
import { eq } from "drizzle-orm"
import type { ActionState } from "@/shared/types/action-state"
import { RegisterForm } from "@/modules/auth/components/register-form"
export const metadata: Metadata = {
@@ -7,5 +11,104 @@ export const metadata: Metadata = {
}
export default function RegisterPage() {
return <RegisterForm />
async function registerAction(formData: FormData): Promise<ActionState> {
"use server"
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) return { success: false, message: "DATABASE_URL 未配置" }
try {
const [{ db }, { users }] = await Promise.all([
import("@/shared/db"),
import("@/shared/db/schema"),
])
const name = String(formData.get("name") ?? "").trim()
const email = String(formData.get("email") ?? "").trim().toLowerCase()
const password = String(formData.get("password") ?? "")
if (!email) return { success: false, message: "请输入邮箱" }
if (!password) return { success: false, message: "请输入密码" }
if (password.length < 6) return { success: false, message: "密码至少 6 位" }
const existing = await db.query.users.findFirst({
where: eq(users.email, email),
columns: { id: true },
})
if (existing) return { success: false, message: "该邮箱已注册" }
await db.insert(users).values({
id: createId(),
name: name.length ? name : null,
email,
password,
role: "student",
})
return { success: true, message: "账户创建成功" }
} catch (error) {
const isProd = process.env.NODE_ENV === "production"
const anyErr = error as unknown as {
code?: string
message?: string
sqlMessage?: string
cause?: unknown
}
const cause1 = anyErr?.cause as
| { code?: string; message?: string; sqlMessage?: string; cause?: unknown }
| undefined
const cause2 = (cause1?.cause ?? undefined) as
| { code?: string; message?: string; sqlMessage?: string }
| undefined
const code = String(cause2?.code ?? cause1?.code ?? anyErr?.code ?? "").trim()
const msg = String(
cause2?.sqlMessage ??
cause1?.sqlMessage ??
anyErr?.sqlMessage ??
cause2?.message ??
cause1?.message ??
anyErr?.message ??
""
).trim()
const msgLower = msg.toLowerCase()
if (
code === "ER_DUP_ENTRY" ||
msgLower.includes("duplicate") ||
msgLower.includes("unique")
) {
return { success: false, message: "该邮箱已注册" }
}
if (
code === "ER_NO_SUCH_TABLE" ||
msgLower.includes("doesn't exist") ||
msgLower.includes("unknown column")
) {
return {
success: false,
message: "数据库未初始化或未迁移,请先运行 npm run db:migrate",
}
}
if (code === "ER_ACCESS_DENIED_ERROR") {
return { success: false, message: "数据库账号/权限错误,请检查 DATABASE_URL" }
}
if (code === "ECONNREFUSED" || code === "ENOTFOUND") {
return { success: false, message: "数据库连接失败,请检查 DATABASE_URL 与网络" }
}
if (!isProd && msg) {
return { success: false, message: `创建账户失败:${msg}` }
}
return { success: false, message: "创建账户失败,请稍后重试" }
}
}
return <RegisterForm registerAction={registerAction} />
}

View File

@@ -1,5 +1,9 @@
import { AdminDashboard } from "@/modules/dashboard/components/admin-view"
import { AdminDashboardView } from "@/modules/dashboard/components/admin-dashboard/admin-dashboard"
import { getAdminDashboardData } from "@/modules/dashboard/data-access"
export default function AdminDashboardPage() {
return <AdminDashboard />
export const dynamic = "force-dynamic"
export default async function AdminDashboardPage() {
const data = await getAdminDashboardData()
return <AdminDashboardView data={data} />
}

View File

@@ -0,0 +1,18 @@
import { AcademicYearClient } from "@/modules/school/components/academic-year-view"
import { getAcademicYears } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminAcademicYearPage() {
const years = await getAcademicYears()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Academic Year</h2>
<p className="text-muted-foreground">Manage academic year ranges and the active year.</p>
</div>
<AcademicYearClient years={years} />
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { getAdminClasses, getTeacherOptions } from "@/modules/classes/data-access"
import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view"
export const dynamic = "force-dynamic"
export default async function AdminSchoolClassesPage() {
const [classes, teachers] = await Promise.all([getAdminClasses(), getTeacherOptions()])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Classes</h2>
<p className="text-muted-foreground">Manage classes and assign teachers.</p>
</div>
<AdminClassesClient classes={classes} teachers={teachers} />
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { DepartmentsClient } from "@/modules/school/components/departments-view"
import { getDepartments } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminDepartmentsPage() {
const departments = await getDepartments()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Departments</h2>
<p className="text-muted-foreground">Manage school departments.</p>
</div>
<DepartmentsClient departments={departments} />
</div>
)
}

View File

@@ -0,0 +1,231 @@
import Link from "next/link"
import { getGrades } from "@/modules/school/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { BarChart3 } from "lucide-react"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
if (typeof v === "string") return v
if (Array.isArray(v)) return v[0]
return undefined
}
const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
export default async function AdminGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const gradeId = getParam(params, "gradeId")
const grades = await getGrades()
const selected = gradeId && gradeId !== "all" ? gradeId : ""
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
<p className="text-muted-foreground">Homework statistics aggregated across all classes in a grade.</p>
</div>
<Button asChild variant="outline">
<Link href="/admin/school/grades">Manage grades</Link>
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Filters</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{grades.length}
</Badge>
</CardHeader>
<CardContent>
<form action="/admin/school/grades/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
<label className="text-sm font-medium">Grade</label>
<select
name="gradeId"
defaultValue={selected || "all"}
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
>
<option value="all">Select a grade</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.school.name} / {g.name}
</option>
))}
</select>
<Button type="submit" className="md:ml-2">
Apply
</Button>
</form>
</CardContent>
</Card>
{!selected ? (
<EmptyState
icon={BarChart3}
title="Select a grade to view insights"
description="Pick a grade to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
) : !insights ? (
<EmptyState
icon={BarChart3}
title="Grade not found"
description="This grade may not exist or has no accessible data."
className="h-[360px] bg-card"
/>
) : insights.assignments.length === 0 ? (
<EmptyState
icon={BarChart3}
title="No homework data for this grade"
description="No homework assignments were targeted to students in this grade yet."
className="h-[360px] bg-card"
/>
) : (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Classes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.classCount}</div>
<div className="text-xs text-muted-foreground">
{insights.grade.school.name} / {insights.grade.name}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.overallScores.avg)}</div>
<div className="text-xs text-muted-foreground">Across graded homework</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Latest Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.latest?.scoreStats.avg ?? null)}</div>
<div className="text-xs text-muted-foreground">{insights.latest ? insights.latest.title : "-"}</div>
</CardContent>
</Card>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Latest homework</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.assignments.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.avg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.median)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Class ranking</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.classes.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Class</TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead className="text-right">Latest Avg</TableHead>
<TableHead className="text-right">Prev Avg</TableHead>
<TableHead className="text-right">Δ</TableHead>
<TableHead className="text-right">Overall Avg</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.classes.map((c) => (
<TableRow key={c.class.id}>
<TableCell className="font-medium">
{c.class.name}
{c.class.homeroom ? <span className="text-muted-foreground"> {c.class.homeroom}</span> : null}
</TableCell>
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.latestAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.prevAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.deltaAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.overallScores.avg)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { GradesClient } from "@/modules/school/components/grades-view"
import { getGrades, getSchools, getStaffOptions } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminGradesPage() {
const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grades</h2>
<p className="text-muted-foreground">Manage grades and assign grade heads.</p>
</div>
<GradesClient grades={grades} schools={schools} staff={staff} />
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function AdminSchoolPage() {
redirect("/admin/school/classes")
}

View File

@@ -0,0 +1,18 @@
import { SchoolsClient } from "@/modules/school/components/schools-view"
import { getSchools } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
export default async function AdminSchoolsPage() {
const schools = await getSchools()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Schools</h2>
<p className="text-muted-foreground">Manage schools for multi-school setups.</p>
</div>
<SchoolsClient schools={schools} />
</div>
)
}

View File

@@ -1,72 +1,16 @@
"use client"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/shared/components/ui/button"
import Link from "next/link"
import { Shield, GraduationCap, Users, User } from "lucide-react"
export const dynamic = "force-dynamic"
// In a real app, this would be a server component that redirects based on session
// But for this demo/dev environment, we keep the manual selection or add auto-redirect logic if we had auth state.
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect("/login")
export default function DashboardPage() {
// Mock Auth Logic (Optional: Uncomment to test auto-redirect)
/*
const router = useRouter();
useEffect(() => {
// const role = "teacher"; // Fetch from auth hook
// if (role) router.push(`/${role}/dashboard`);
}, []);
*/
const role = String(session.user.role ?? "teacher")
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] space-y-8">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold">Welcome to Next_Edu</h1>
<p className="text-muted-foreground">Select your role to view the corresponding dashboard.</p>
<p className="text-xs text-muted-foreground bg-muted p-2 rounded inline-block">
[DEV MODE] In production, you would be redirected automatically based on your login session.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Link href="/admin/dashboard">
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-primary hover:bg-primary/5 transition-all">
<Shield className="h-10 w-10 text-primary" />
<div className="space-y-1">
<span className="font-semibold text-lg block">Admin</span>
<span className="text-xs text-muted-foreground font-normal">System Management</span>
</div>
</Button>
</Link>
<Link href="/teacher/dashboard">
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-indigo-500 hover:bg-indigo-50 transition-all">
<GraduationCap className="h-10 w-10 text-indigo-600" />
<div className="space-y-1">
<span className="font-semibold text-lg block">Teacher</span>
<span className="text-xs text-muted-foreground font-normal">Class & Exams</span>
</div>
</Button>
</Link>
<Link href="/student/dashboard">
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-emerald-500 hover:bg-emerald-50 transition-all">
<Users className="h-10 w-10 text-emerald-600" />
<div className="space-y-1">
<span className="font-semibold text-lg block">Student</span>
<span className="text-xs text-muted-foreground font-normal">My Learning</span>
</div>
</Button>
</Link>
<Link href="/parent/dashboard">
<Button variant="outline" className="h-40 w-40 flex flex-col gap-4 hover:border-amber-500 hover:bg-amber-50 transition-all">
<User className="h-10 w-10 text-amber-600" />
<div className="space-y-1">
<span className="font-semibold text-lg block">Parent</span>
<span className="text-xs text-muted-foreground font-normal">Family Overview</span>
</div>
</Button>
</Link>
</div>
</div>
)
if (role === "admin") redirect("/admin/dashboard")
if (role === "student") redirect("/student/dashboard")
if (role === "parent") redirect("/parent/dashboard")
redirect("/teacher/dashboard")
}

View File

@@ -1,23 +1,10 @@
"use client"
import { useEffect } from "react"
import { AlertCircle } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
export default function Error({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<EmptyState

View File

@@ -0,0 +1,31 @@
import { auth } from "@/auth"
import { getGradeManagedClasses, getManagedGrades, getTeacherOptions } from "@/modules/classes/data-access"
import { GradeClassesClient } from "@/modules/classes/components/grade-classes-view"
export const dynamic = "force-dynamic"
export default async function GradeClassesPage() {
const session = await auth()
const userId = session?.user?.id ?? ""
const [classes, teachers, managedGrades] = await Promise.all([
getGradeManagedClasses(userId),
getTeacherOptions(),
getManagedGrades(userId),
])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Class Management</h2>
<p className="text-muted-foreground">
Manage classes for your grades.
</p>
</div>
</div>
<GradeClassesClient classes={classes} teachers={teachers} managedGrades={managedGrades} />
</div>
)
}

View File

@@ -0,0 +1,244 @@
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { getGradesForStaff } from "@/modules/school/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { BarChart3 } from "lucide-react"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
if (typeof v === "string") return v
if (Array.isArray(v)) return v[0]
return undefined
}
const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
export default async function TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const gradeId = getParam(params, "gradeId")
const teacherId = await getTeacherIdForMutations()
const grades = await getGradesForStaff(teacherId)
const allowedIds = new Set(grades.map((g) => g.id))
const selected = gradeId && gradeId !== "all" && allowedIds.has(gradeId) ? gradeId : ""
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
if (grades.length === 0) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
<p className="text-muted-foreground">View grade-level homework statistics for grades you lead.</p>
</div>
<EmptyState
icon={BarChart3}
title="No grades assigned"
description="You are not assigned as a grade head or teaching head for any grade."
className="h-[360px] bg-card"
/>
</div>
)
}
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2>
<p className="text-muted-foreground">Homework statistics aggregated across all classes in a grade.</p>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Filters</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{grades.length}
</Badge>
</CardHeader>
<CardContent>
<form action="/management/grade/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
<label className="text-sm font-medium">Grade</label>
<select
name="gradeId"
defaultValue={selected || "all"}
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
>
<option value="all">Select a grade</option>
{grades.map((g) => (
<option key={g.id} value={g.id}>
{g.school.name} / {g.name}
</option>
))}
</select>
<Button type="submit" className="md:ml-2">
Apply
</Button>
</form>
</CardContent>
</Card>
{!selected ? (
<EmptyState
icon={BarChart3}
title="Select a grade to view insights"
description="Pick a grade to see latest homework and historical score statistics."
className="h-[360px] bg-card"
/>
) : !insights ? (
<EmptyState
icon={BarChart3}
title="Grade not found"
description="This grade may not exist or has no accessible data."
className="h-[360px] bg-card"
/>
) : insights.assignments.length === 0 ? (
<EmptyState
icon={BarChart3}
title="No homework data for this grade"
description="No homework assignments were targeted to students in this grade yet."
className="h-[360px] bg-card"
/>
) : (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Classes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.classCount}</div>
<div className="text-xs text-muted-foreground">
{insights.grade.school.name} / {insights.grade.name}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Students</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{insights.studentCounts.total}</div>
<div className="text-xs text-muted-foreground">
Active {insights.studentCounts.active} Inactive {insights.studentCounts.inactive}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Overall Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.overallScores.avg)}</div>
<div className="text-xs text-muted-foreground">Across graded homework</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Latest Avg</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{fmt(insights.latest?.scoreStats.avg ?? null)}</div>
<div className="text-xs text-muted-foreground">{insights.latest ? insights.latest.title : "-"}</div>
</CardContent>
</Card>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Homework timeline</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.assignments.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Targeted</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Median</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.assignments.map((a) => (
<TableRow key={a.assignmentId}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.avg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(a.scoreStats.median)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Class ranking</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{insights.classes.length}
</Badge>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead>Class</TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead className="text-right">Latest Avg</TableHead>
<TableHead className="text-right">Prev Avg</TableHead>
<TableHead className="text-right">Δ</TableHead>
<TableHead className="text-right">Overall Avg</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.classes.map((c) => (
<TableRow key={c.class.id}>
<TableCell className="font-medium">
{c.class.name}
{c.class.homeroom ? <span className="text-muted-foreground"> {c.class.homeroom}</span> : null}
</TableCell>
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.latestAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.prevAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.deltaAvg)}</TableCell>
<TableCell className="text-right tabular-nums">{fmt(c.overallScores.avg)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,236 @@
import Link from "next/link"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { getUserProfile } from "@/modules/users/data-access"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Separator } from "@/shared/components/ui/separator"
import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"
export const dynamic = "force-dynamic"
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
const day = d.getDay()
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
}
const formatDate = (date: Date | null) => {
if (!date) return "-"
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
}
export default async function ProfilePage() {
const session = await auth()
if (!session?.user) redirect("/login")
const userId = String(session.user.id ?? "").trim()
const userProfile = await getUserProfile(userId)
if (!userProfile) {
redirect("/login")
}
const role = userProfile.role || "student"
const isStudent = role === "student"
const studentData =
isStudent
? await (async () => {
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
getStudentClasses(userId),
getStudentSchedule(userId),
getStudentHomeworkAssignments(userId),
getStudentDashboardGrades(userId),
])
const now = new Date()
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
const dueSoonCount = assignmentsAll.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due >= now && due <= in7Days && a.progressStatus !== "graded"
}).length
const overdueCount = assignmentsAll.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due < now && a.progressStatus !== "graded"
}).length
const gradedCount = assignmentsAll.filter((a) => a.progressStatus === "graded").length
const upcomingAssignments = [...assignmentsAll]
.sort((a, b) => {
const aTime = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
const bTime = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
.slice(0, 8)
const todayWeekday = toWeekday(now)
const todayScheduleItems = schedule
.filter((s) => s.weekday === todayWeekday)
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
return {
enrolledClassCount: classes.length,
dueSoonCount,
overdueCount,
gradedCount,
todayScheduleItems,
upcomingAssignments,
grades,
}
})()
: null
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Profile</h1>
<div className="text-sm text-muted-foreground">Manage your personal and account information.</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/settings">Edit Profile</Link>
</Button>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Personal Information
</CardTitle>
<CardDescription>Basic personal details.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Full Name</div>
<div className="text-sm font-medium">{userProfile.name ?? "-"}</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Gender</div>
<div className="text-sm capitalize">{userProfile.gender ?? "-"}</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Age</div>
<div className="text-sm">{userProfile.age ?? "-"}</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Phone</div>
<div className="flex items-center gap-2 text-sm">
{userProfile.phone ? <Phone className="h-3 w-3 text-muted-foreground" /> : null}
{userProfile.phone ?? "-"}
</div>
</div>
<div className="col-span-1 sm:col-span-2 space-y-1">
<div className="text-sm font-medium text-muted-foreground">Address</div>
<div className="flex items-center gap-2 text-sm">
{userProfile.address ? <MapPin className="h-3 w-3 text-muted-foreground" /> : null}
{userProfile.address ?? "-"}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Account Information
</CardTitle>
<CardDescription>System account details.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="col-span-1 sm:col-span-2 space-y-1">
<div className="text-sm font-medium text-muted-foreground">Email</div>
<div className="flex items-center gap-2 text-sm">
<Mail className="h-3 w-3 text-muted-foreground" />
{userProfile.email}
</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Role</div>
<Badge variant="secondary" className="capitalize">
{userProfile.role}
</Badge>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Member Since</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-3 w-3 text-muted-foreground" />
{formatDate(userProfile.createdAt)}
</div>
</div>
<div className="space-y-1">
<div className="text-sm font-medium text-muted-foreground">Onboarded At</div>
<div className="flex items-center gap-2 text-sm">
<Clock className="h-3 w-3 text-muted-foreground" />
{formatDate(userProfile.onboardedAt)}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{studentData ? (
<div className="space-y-6">
<Separator />
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">Student Overview</h2>
<div className="text-sm text-muted-foreground">Your academic performance and schedule.</div>
</div>
<StudentStatsGrid
enrolledClassCount={studentData.enrolledClassCount}
dueSoonCount={studentData.dueSoonCount}
overdueCount={studentData.overdueCount}
gradedCount={studentData.gradedCount}
ranking={studentData.grades.ranking}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<StudentUpcomingAssignmentsCard upcomingAssignments={studentData.upcomingAssignments} />
<StudentGradesCard grades={studentData.grades} />
</div>
<div className="space-y-6">
<StudentTodayScheduleCard items={studentData.todayScheduleItems} />
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,27 @@
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
import { getUserProfile } from "@/modules/users/data-access"
export const dynamic = "force-dynamic"
export default async function SettingsPage() {
const session = await auth()
if (!session?.user) redirect("/login")
const userId = String(session.user.id ?? "").trim()
const userProfile = await getUserProfile(userId)
if (!userProfile) redirect("/login")
const role = userProfile.role || "student"
if (role === "admin") return <AdminSettingsView user={userProfile} />
if (role === "student") return <StudentSettingsView user={userProfile} />
if (role === "teacher") return <TeacherSettingsView user={userProfile} />
redirect("/dashboard")
}

View File

@@ -0,0 +1,61 @@
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 flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-2">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-4 w-56" />
</div>
<Skeleton className="h-10 w-40" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4 rounded-full" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-28" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle className="text-sm">
<Skeleton className="h-4 w-40" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
<Card className="lg:col-span-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-sm">
<Skeleton className="h-4 w-44" />
</CardTitle>
<Skeleton className="h-9 w-24" />
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,5 +1,88 @@
import { StudentDashboard } from "@/modules/dashboard/components/student-view"
import { StudentDashboard } from "@/modules/dashboard/components/student-dashboard/student-dashboard-view"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getDemoStudentUser, getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Inbox } from "lucide-react"
export default function StudentDashboardPage() {
return <StudentDashboard />
export const dynamic = "force-dynamic"
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
const day = d.getDay()
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
}
export default async function StudentDashboardPage() {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="flex h-full flex-col items-center justify-center">
<EmptyState
title="No user found"
description="Create a student user to see dashboard."
icon={Inbox}
className="border-none shadow-none h-auto"
/>
</div>
)
}
const [classes, schedule, assignments, grades] = await Promise.all([
getStudentClasses(student.id),
getStudentSchedule(student.id),
getStudentHomeworkAssignments(student.id),
getStudentDashboardGrades(student.id),
])
const now = new Date()
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
const dueSoonCount = assignments.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due >= now && due <= in7Days && a.progressStatus !== "graded"
}).length
const overdueCount = assignments.filter((a) => {
if (!a.dueAt) return false
const due = new Date(a.dueAt)
return due < now && a.progressStatus !== "graded"
}).length
const gradedCount = assignments.filter((a) => a.progressStatus === "graded").length
const todayWeekday = toWeekday(now)
const todayScheduleItems = schedule
.filter((s) => s.weekday === todayWeekday)
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
}))
.sort((a, b) => a.startTime.localeCompare(b.startTime))
const upcomingAssignments = [...assignments]
.sort((a, b) => {
const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
return aDue - bDue
})
.slice(0, 6)
return (
<StudentDashboard
studentName={student.name}
enrolledClassCount={classes.length}
dueSoonCount={dueSoonCount}
overdueCount={overdueCount}
gradedCount={gradedCount}
todayScheduleItems={todayScheduleItems}
upcomingAssignments={upcomingAssignments}
grades={grades}
/>
)
}

View File

@@ -2,8 +2,11 @@ import { notFound } from "next/navigation"
import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access"
import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view"
import { HomeworkReviewView } from "@/modules/homework/components/student-homework-review-view"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
export default async function StudentAssignmentTakePage({
params,
}: {
@@ -16,6 +19,23 @@ export default async function StudentAssignmentTakePage({
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
if (!data) return notFound()
// If status is graded or submitted, use the review view
const status = data.submission?.status
if (status === "graded" || status === "submitted") {
return (
<div className="flex h-full flex-col space-y-4 p-6">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
<div className="text-sm text-muted-foreground">
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
</div>
</div>
<HomeworkReviewView initialData={data} />
</div>
)
}
return (
<div className="flex h-full flex-col space-y-4 p-6">
<div className="flex flex-col gap-1">
@@ -31,4 +51,3 @@ export default async function StudentAssignmentTakePage({
</div>
)
}

View File

@@ -15,6 +15,8 @@ import { formatDate } from "@/shared/lib/utils"
import { getDemoStudentUser, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { Inbox } from "lucide-react"
export const dynamic = "force-dynamic"
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
if (status === "graded") return "default"
if (status === "submitted") return "secondary"
@@ -29,6 +31,18 @@ const getStatusLabel = (status: string) => {
return "Not started"
}
const getActionLabel = (status: string) => {
if (status === "graded") return "Review"
if (status === "submitted") return "View"
if (status === "in_progress") return "Continue"
return "Start"
}
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
if (status === "graded" || status === "submitted") return "outline"
return "default"
}
export default async function StudentAssignmentsPage() {
const student = await getDemoStudentUser()
@@ -73,6 +87,7 @@ export default async function StudentAssignmentsPage() {
<TableHead>Due</TableHead>
<TableHead>Attempts</TableHead>
<TableHead>Score</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -93,6 +108,13 @@ export default async function StudentAssignmentsPage() {
{a.attemptsUsed}/{a.maxAttempts}
</TableCell>
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
<Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus)}
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -102,4 +124,3 @@ export default async function StudentAssignmentsPage() {
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-52" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-44" />
<Skeleton className="mt-2 h-4 w-56" />
</CardHeader>
<CardContent className="pt-2 flex items-center justify-between">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-9 w-24" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { Inbox } from "lucide-react"
import { getStudentClasses } from "@/modules/classes/data-access"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { StudentCoursesView } from "@/modules/student/components/student-courses-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic"
export default async function StudentCoursesPage() {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p>
</div>
<EmptyState
title="No user found"
description="Create a student user to see courses."
icon={Inbox}
/>
</div>
)
}
const classes = await getStudentClasses(student.id)
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p>
</div>
<StudentCoursesView classes={classes} />
</div>
)
}

View File

@@ -0,0 +1,77 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { BookOpen, Inbox } from "lucide-react"
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getDemoStudentUser } from "@/modules/homework/data-access"
export const dynamic = "force-dynamic"
export default async function StudentTextbookDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbook</h2>
<p className="text-muted-foreground">Read chapters and review content.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to read textbooks." icon={Inbox} />
</div>
)
}
const { id } = await params
const [textbook, chapters, knowledgePoints] = await Promise.all([
getTextbookById(id),
getChaptersByTextbookId(id),
getKnowledgePointsByTextbookId(id)
])
if (!textbook) notFound()
return (
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden bg-muted/5">
<div className="flex items-center justify-between border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
<div className="flex items-center gap-3 min-w-0">
<h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="hidden sm:inline-block w-px h-4 bg-border" />
<Badge variant="outline" className="font-normal text-xs">{textbook.subject}</Badge>
{textbook.grade && (
<Badge variant="secondary" className="font-normal text-xs">{textbook.grade}</Badge>
)}
</div>
</div>
</div>
<div className="flex-1 overflow-hidden p-6">
{chapters.length === 0 ? (
<div className="h-full flex items-center justify-center rounded-lg border border-dashed bg-card">
<EmptyState
icon={BookOpen}
title="No chapters"
description="This textbook has no chapters yet."
className="border-none shadow-none"
/>
</div>
) : (
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
<TextbookReader chapters={chapters} knowledgePoints={knowledgePoints} />
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { BookOpen, Inbox } from "lucide-react"
import { getTextbooks } from "@/modules/textbooks/data-access"
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Button } from "@/shared/components/ui/button"
import Link from "next/link"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function StudentTextbooksPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const [student, sp] = await Promise.all([getDemoStudentUser(), searchParams])
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
</div>
</div>
<EmptyState title="No user found" description="Create a student user to see textbooks." icon={Inbox} />
</div>
)
}
const q = getParam(sp, "q") || undefined
const subject = getParam(sp, "subject") || undefined
const grade = getParam(sp, "grade") || undefined
const textbooks = await getTextbooks(q, subject, grade)
const hasFilters = Boolean(q || (subject && subject !== "all") || (grade && grade !== "all"))
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
<p className="text-muted-foreground">Browse your course textbooks.</p>
</div>
<Button asChild variant="outline">
<Link href="/student/dashboard">Back</Link>
</Button>
</div>
<TextbookFilters />
{textbooks.length === 0 ? (
<EmptyState
icon={BookOpen}
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "No textbooks are available right now."}
action={hasFilters ? { label: "Clear filters", href: "/student/learning/textbooks" } : undefined}
className="bg-card"
/>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{textbooks.map((textbook) => (
<TextbookCard key={textbook.id} textbook={textbook} hrefBase="/student/learning/textbooks" hideActions />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-52" />
</div>
<Skeleton className="h-10 w-60" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-3">
<Skeleton className="h-4 w-16" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 3 }).map((__, j) => (
<Skeleton key={j} className="h-16 w-full" />
))}
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { Inbox } from "lucide-react"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getDemoStudentUser } from "@/modules/homework/data-access"
import { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters"
import { StudentScheduleView } from "@/modules/student/components/student-schedule-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
export default async function StudentSchedulePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const student = await getDemoStudentUser()
if (!student) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">Your weekly timetable.</p>
</div>
<EmptyState title="No user found" description="Create a student user to see schedule." icon={Inbox} />
</div>
)
}
const [sp, classes, schedule] = await Promise.all([
searchParams,
getStudentClasses(student.id),
getStudentSchedule(student.id),
])
const classIdParam = sp.classId
const classId = typeof classIdParam === "string" ? classIdParam : Array.isArray(classIdParam) ? classIdParam[0] : "all"
const filteredItems =
classId && classId !== "all" ? schedule.filter((s) => s.classId === classId) : schedule
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">Your weekly timetable.</p>
</div>
<StudentScheduleFilters classes={classes} />
</div>
<StudentScheduleView items={filteredItems} />
</div>
)
}

View File

@@ -0,0 +1,117 @@
import { notFound } from "next/navigation"
import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access"
import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget"
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
import { ClassQuickActions } from "@/modules/classes/components/class-detail/class-quick-actions"
import { ClassScheduleWidget } from "@/modules/classes/components/class-detail/class-schedule-widget"
import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/class-students-widget"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
export default async function ClassDetailPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<SearchParams>
}) {
const { id } = await params
// Parallel data fetching
const [insights, students, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: id, limit: 20 }), // Limit increased to 20 for better list view
getClassStudents({ classId: id }),
getClassSchedule({ classId: id }),
])
if (!insights) return notFound()
// Fetch subject scores
const studentScores = await getClassStudentSubjectScoresV2(id)
// Data mapping for widgets
const assignmentSummaries = insights.assignments.map(a => ({
id: a.assignmentId,
title: a.title,
status: a.status,
subject: a.subject,
isActive: a.isActive,
isOverdue: a.isOverdue,
dueAt: a.dueAt ? new Date(a.dueAt) : null,
submittedCount: a.submittedCount,
targetCount: a.targetCount,
avgScore: a.scoreStats.avg,
medianScore: a.scoreStats.median
}))
const studentSummaries = students.map(s => ({
id: s.id,
name: s.name,
email: s.email,
image: s.image,
status: s.status,
subjectScores: studentScores.get(s.id) ?? {}
}))
// Calculate advanced stats
const activeAssignments = insights.assignments.filter(a => a.isActive)
const papersToGrade = activeAssignments.reduce((acc, a) => acc + (a.submittedCount - a.gradedCount), 0)
const overdueCount = activeAssignments.filter(a => a.isOverdue).length
const totalSubmissionRate = activeAssignments.length > 0
? activeAssignments.reduce((acc, a) => acc + (a.targetCount > 0 ? a.submittedCount / a.targetCount : 0), 0) / activeAssignments.length
: 0
return (
<div className="flex min-h-screen flex-col bg-muted/10">
<ClassHeader
classId={insights.class.id}
name={insights.class.name}
grade={insights.class.grade}
homeroom={insights.class.homeroom}
room={insights.class.room}
schoolName={insights.class.schoolName}
studentCount={insights.studentCounts.total}
/>
<div className="flex-1 space-y-6 p-6">
{/* Key Metrics */}
<ClassOverviewStats
averageScore={insights.overallScores.avg}
submissionRate={totalSubmissionRate * 100}
papersToGrade={papersToGrade}
overdueCount={overdueCount}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content Area (Left 2/3) */}
<div className="space-y-6 lg:col-span-2">
<ClassTrendsWidget
classId={insights.class.id}
assignments={assignmentSummaries}
/>
<ClassStudentsWidget
classId={insights.class.id}
students={studentSummaries}
/>
</div>
{/* Sidebar Area (Right 1/3) */}
<div className="space-y-6">
{/* <ClassQuickActions classId={insights.class.id} /> */}
<ClassScheduleWidget classId={insights.class.id} schedule={schedule} />
<ClassAssignmentsWidget
classId={insights.class.id}
assignments={assignmentSummaries}
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-start justify-between gap-3">
<Skeleton className="h-5 w-[60%]" />
<Skeleton className="h-5 w-20" />
</div>
<Skeleton className="mt-3 h-4 w-32" />
<div className="mt-6 flex items-center justify-between">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-16" />
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,22 +1,22 @@
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Users } from "lucide-react"
import { eq } from "drizzle-orm"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { grades } from "@/shared/db/schema"
export const dynamic = "force-dynamic"
export default function MyClassesPage() {
return <MyClassesPageImpl />
}
async function MyClassesPageImpl() {
const classes = await getTeacherClasses()
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">My Classes</h2>
<p className="text-muted-foreground">
Overview of your classes.
</p>
</div>
</div>
<EmptyState
title="No classes found"
description="You are not assigned to any classes yet."
icon={Users}
/>
<div className="flex h-full flex-col space-y-4 p-8">
<MyClassesGrid classes={classes} canCreateClass={false} />
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-10 w-full" />
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
<div className="mt-6 space-y-3">
<Skeleton className="h-4 w-[70%]" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[60%]" />
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,22 +1,81 @@
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Suspense } from "react"
import { Calendar } from "lucide-react"
export default function SchedulePage() {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">
View class schedule.
</p>
</div>
</div>
import { getClassSchedule, getTeacherClasses } from "@/modules/classes/data-access"
import { ScheduleFilters } from "@/modules/classes/components/schedule-filters"
import { ScheduleView } from "@/modules/classes/components/schedule-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
async function ScheduleResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const classId = getParam(params, "classId")
const classes = await getTeacherClasses()
const schedule = await getClassSchedule({
classId: classId && classId !== "all" ? classId : undefined,
})
const hasFilters = Boolean(classId && classId !== "all")
if (schedule.length === 0) {
return (
<EmptyState
title="No schedule available"
description="Your class schedule has not been set up yet."
icon={Calendar}
title={hasFilters ? "No schedule for this class" : "No schedule available"}
description={hasFilters ? "Try selecting another class." : "Your class schedule has not been set up yet."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/classes/schedule" } : undefined}
className="h-[360px] bg-card"
/>
)
}
return <ScheduleView schedule={schedule} classes={classes} />
}
function ScheduleResultsFallback() {
return (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
<div className="mt-6 space-y-3">
<Skeleton className="h-4 w-[70%]" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[60%]" />
</div>
</div>
))}
</div>
)
}
export default async function SchedulePage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-6">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ScheduleFilters classes={classes} />
</Suspense>
<Suspense fallback={<ScheduleResultsFallback />}>
<ScheduleResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-10 w-full" />
<div className="rounded-md border bg-card">
<div className="space-y-2 p-4">
{Array.from({ length: 10 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
)
}

View File

@@ -1,22 +1,103 @@
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Suspense } from "react"
import { User } from "lucide-react"
export default function StudentsPage() {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Students</h2>
<p className="text-muted-foreground">
Manage student list.
</p>
</div>
</div>
import { getClassStudents, getTeacherClasses, getStudentsSubjectScores } from "@/modules/classes/data-access"
import { StudentsFilters } from "@/modules/classes/components/students-filters"
import { StudentsTable } from "@/modules/classes/components/students-table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
async function StudentsResults({ searchParams, defaultClassId }: { searchParams: Promise<SearchParams>, defaultClassId?: string }) {
const params = await searchParams
const q = getParam(params, "q") || undefined
const classId = getParam(params, "classId")
const status = getParam(params, "status")
// If classId is explicit in URL, use it (unless "all"). If not, use defaultClassId.
// If user explicitly selects "all", classId will be "all".
// However, the requirement is "Default to showing the first class".
// If classId param is missing, we use defaultClassId.
const targetClassId = classId ? (classId !== "all" ? classId : undefined) : defaultClassId
const filteredStudents = await getClassStudents({
q,
classId: targetClassId,
status: status && status !== "all" ? status : undefined,
})
// Fetch subject scores for all filtered students
if (filteredStudents.length > 0) {
const studentIds = filteredStudents.map(s => s.id)
const scores = await getStudentsSubjectScores(studentIds)
for (const student of filteredStudents) {
student.subjectScores = scores.get(student.id)
}
}
const hasFilters = Boolean(q || (classId && classId !== "all") || (status && status !== "all"))
if (filteredStudents.length === 0) {
return (
<EmptyState
title="No students found"
description="There are no students in your classes yet."
icon={User}
title={hasFilters ? "No students match your filters" : "No students found"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "There are no students in your classes yet."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/classes/students" } : undefined}
className="h-[360px] bg-card"
/>
)
}
return (
<div className="rounded-md border bg-card">
<StudentsTable students={filteredStudents} />
</div>
)
}
function StudentsResultsFallback() {
return (
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 8 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
)
}
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const classes = await getTeacherClasses()
const params = await searchParams
// Logic to determine default class (first one available)
const defaultClassId = classes.length > 0 ? classes[0].id : undefined
return (
<div className="flex h-full flex-col space-y-4 p-8">
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<StudentsFilters classes={classes} defaultClassId={defaultClassId} />
</Suspense>
<Suspense fallback={<StudentsResultsFallback />}>
<StudentsResults searchParams={searchParams} defaultClassId={defaultClassId} />
</Suspense>
</div>
</div>
)
}

View File

@@ -1,28 +1,37 @@
import { TeacherStats } from "@/modules/dashboard/components/teacher-stats";
import { TeacherSchedule } from "@/modules/dashboard/components/teacher-schedule";
import { RecentSubmissions } from "@/modules/dashboard/components/recent-submissions";
import { TeacherQuickActions } from "@/modules/dashboard/components/teacher-quick-actions";
import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view"
import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access";
import { getHomeworkAssignments, getHomeworkSubmissions, getTeacherGradeTrends } from "@/modules/homework/data-access";
import { db } from "@/shared/db";
import { users } from "@/shared/db/schema";
import { eq } from "drizzle-orm";
export const dynamic = "force-dynamic";
export default async function TeacherDashboardPage() {
const teacherId = await getTeacherIdForMutations();
const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([
getTeacherClasses({ teacherId }),
getClassSchedule({ teacherId }),
getHomeworkAssignments({ creatorId: teacherId }),
getHomeworkSubmissions({ creatorId: teacherId }),
db.query.users.findFirst({
where: eq(users.id, teacherId),
columns: { name: true },
}),
getTeacherGradeTrends(teacherId),
]);
export default function TeacherDashboardPage() {
return (
<div className="flex-1 space-y-4">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Teacher Dashboard</h2>
<div className="flex items-center space-x-2">
<TeacherQuickActions />
</div>
</div>
{/* Overview Stats */}
<TeacherStats />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
{/* Left Column: Schedule (3/7 width) */}
<TeacherSchedule />
{/* Right Column: Recent Activity (4/7 width) */}
<RecentSubmissions />
</div>
</div>
);
<TeacherDashboardView
data={{
classes,
schedule,
assignments,
submissions,
teacherName: teacherProfile?.name ?? "Teacher",
gradeTrends,
}}
/>
)
}

View File

@@ -12,9 +12,8 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
const exam = await getExamById(id)
if (!exam) return notFound()
// Fetch all available questions (for selection pool)
// In a real app, this might be paginated or filtered by exam subject/grade
const { data: questionsData } = await getQuestions({ pageSize: 100 })
// Fetch initial questions for the bank (pagination handled by client)
const { data: questionsData } = await getQuestions({ pageSize: 20 })
const initialSelected = (exam.questions || []).map(q => ({
id: q.id,
@@ -103,13 +102,7 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
}
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Build Exam</h2>
<p className="text-muted-foreground">Add questions and adjust scores.</p>
</div>
</div>
<div className="flex h-full flex-col space-y-4 p-4">
<ExamAssembly
examId={exam.id}
title={exam.title}

View File

@@ -131,13 +131,6 @@ export default async function AllExamsPage({
}) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">All Exams</h2>
<p className="text-muted-foreground">View and manage all your exams.</p>
</div>
</div>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ExamFilters />

View File

@@ -1,14 +1,37 @@
import { ExamForm } from "@/modules/exams/components/exam-form"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/shared/components/ui/breadcrumb"
export default function CreateExamPage() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between">
<div className="flex flex-col space-y-8 p-8 max-w-[1200px] mx-auto">
<div className="space-y-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/teacher/exams/all">Exams</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Create</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div>
<h2 className="text-2xl font-bold tracking-tight">Create Exam</h2>
<p className="text-muted-foreground">Design a new exam for your students.</p>
<h1 className="text-3xl font-bold tracking-tight">Create Exam</h1>
<p className="text-muted-foreground mt-2">
Set up a new exam draft and choose your assembly method.
</p>
</div>
</div>
<ExamForm />
</div>
)

View File

@@ -1,79 +1,101 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import { getHomeworkAssignmentById } from "@/modules/homework/data-access"
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card"
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
import { ChevronLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function HomeworkAssignmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const assignment = await getHomeworkAssignmentById(id)
const analytics = await getHomeworkAssignmentAnalytics(id)
if (!assignment) return notFound()
if (!analytics) return notFound()
const { assignment, questions, gradedSampleCount } = analytics
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold tracking-tight">{assignment.title}</h2>
<Badge variant="outline" className="capitalize">
{assignment.status}
</Badge>
<div className="flex flex-col min-h-full">
{/* Header */}
<div className="border-b bg-background px-8 py-5">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Link href="/teacher/homework/assignments" className="flex items-center hover:text-foreground transition-colors">
<ChevronLeft className="h-4 w-4 mr-1" />
Assignments
</Link>
<span>/</span>
<span>Details</span>
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-foreground">{assignment.title}</h1>
<Badge variant={assignment.status === "published" ? "default" : "secondary"} className="capitalize">
{assignment.status}
</Badge>
</div>
<p className="text-muted-foreground text-sm max-w-2xl">{assignment.description || "No description provided."}</p>
</div>
<p className="text-muted-foreground mt-1">{assignment.description || "—"}</p>
<div className="mt-2 text-sm text-muted-foreground">
<span>Source Exam: {assignment.sourceExamTitle}</span>
<span className="mx-2"></span>
<span>Created: {formatDate(assignment.createdAt)}</span>
<div className="flex items-center gap-3 mt-2 md:mt-0">
<Button asChild variant="outline" className="shadow-sm">
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>
<Users className="h-4 w-4 mr-2" />
View Submissions
</Link>
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/homework/assignments">Back</Link>
</Button>
<Button asChild>
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>View Submissions</Link>
</Button>
{/* Quick Stats Row */}
<div className="flex flex-wrap gap-x-8 gap-y-2 mt-6 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>Due: <span className="font-medium text-foreground">{assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}</span></span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>Targets: <span className="font-medium text-foreground">{assignment.targetCount}</span></span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<CheckCircle2 className="h-4 w-4" />
<span>Submissions: <span className="font-medium text-foreground">{assignment.submissionCount}</span></span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<BarChart3 className="h-4 w-4" />
<span>Graded: <span className="font-medium text-foreground">{gradedSampleCount}</span></span>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Targets</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{assignment.targetCount}</div>
</CardContent>
</Card>
<div className="flex-1 p-8 space-y-8 bg-muted/5">
{/* Analytics Section */}
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Performance Analytics</h2>
</div>
<div className="grid gap-6 md:grid-cols-1">
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} />
</div>
</section>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Submissions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{assignment.submissionCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Due</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm">
<div className="font-medium">{assignment.dueAt ? formatDate(assignment.dueAt) : "—"}</div>
<div className="text-muted-foreground">
Late: {assignment.allowLate ? (assignment.lateDueAt ? formatDate(assignment.lateDueAt) : "Allowed") : "Not allowed"}
</div>
</div>
</CardContent>
</Card>
{/* Content Section */}
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Assignment Content</h2>
</div>
<HomeworkAssignmentExamContentCard
structure={assignment.structure}
questions={questions}
gradedSampleCount={gradedSampleCount}
/>
</section>
</div>
</div>
)
}

View File

@@ -13,6 +13,8 @@ import {
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignmentById, getHomeworkSubmissions } from "@/modules/homework/data-access"
export const dynamic = "force-dynamic"
export default async function HomeworkAssignmentSubmissionsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const assignment = await getHomeworkAssignmentById(id)
@@ -26,10 +28,22 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa
<div>
<h2 className="text-2xl font-bold tracking-tight">Submissions</h2>
<p className="text-muted-foreground">{assignment.title}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>Exam: {assignment.sourceExamTitle}</span>
<span></span>
<span>Targets: {assignment.targetCount}</span>
<span></span>
<span>Submitted: {assignment.submittedCount}</span>
<span></span>
<span>Graded: {assignment.gradedCount}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={`/teacher/homework/assignments/${id}`}>Back</Link>
<Link href="/teacher/homework/submissions">Back</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/homework/assignments/${id}`}>Open Assignment</Link>
</Button>
</div>
</div>
@@ -70,4 +84,3 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa
</div>
)
}

View File

@@ -1,10 +1,13 @@
import { HomeworkAssignmentForm } from "@/modules/homework/components/homework-assignment-form"
import { getExams } from "@/modules/exams/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { FileQuestion } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function CreateHomeworkAssignmentPage() {
const exams = await getExams({})
const [exams, classes] = await Promise.all([getExams({}), getTeacherClasses()])
const options = exams.map((e) => ({ id: e.id, title: e.title }))
return (
@@ -23,8 +26,15 @@ export default async function CreateHomeworkAssignmentPage() {
icon={FileQuestion}
action={{ label: "Create Exam", href: "/teacher/exams/create" }}
/>
) : classes.length === 0 ? (
<EmptyState
title="No classes available"
description="Create a class first, then publish homework to that class."
icon={FileQuestion}
action={{ label: "Go to Classes", href: "/teacher/classes/my" }}
/>
) : (
<HomeworkAssignmentForm exams={options} />
<HomeworkAssignmentForm exams={options} classes={classes} />
)}
</div>
)

View File

@@ -12,11 +12,28 @@ import {
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignments } from "@/modules/homework/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { PenTool, PlusCircle } from "lucide-react"
export default async function AssignmentsPage() {
const assignments = await getHomeworkAssignments()
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function AssignmentsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const classId = getParam(sp, "classId") || undefined
const [assignments, classes] = await Promise.all([
getHomeworkAssignments({ classId: classId && classId !== "all" ? classId : undefined }),
classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([]),
])
const hasAssignments = assignments.length > 0
const className = classId && classId !== "all" ? classes.find((c) => c.id === classId)?.name : undefined
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
@@ -24,25 +41,41 @@ export default async function AssignmentsPage() {
<div>
<h2 className="text-2xl font-bold tracking-tight">Assignments</h2>
<p className="text-muted-foreground">
Manage homework assignments.
{classId && classId !== "all" ? `Filtered by class: ${className ?? classId}` : "Manage homework assignments."}
</p>
</div>
<Button asChild>
<Link href="/teacher/homework/assignments/create">
<PlusCircle className="mr-2 h-4 w-4" />
Create Assignment
</Link>
</Button>
<div className="flex items-center gap-2">
{classId && classId !== "all" ? (
<Button asChild variant="outline">
<Link href="/teacher/homework/assignments">Clear filter</Link>
</Button>
) : null}
<Button asChild>
<Link
href={
classId && classId !== "all"
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`
: "/teacher/homework/assignments/create"
}
>
<PlusCircle className="mr-2 h-4 w-4" />
Create Assignment
</Link>
</Button>
</div>
</div>
{!hasAssignments ? (
<EmptyState
title="No assignments"
description="You haven't created any assignments yet."
description={classId && classId !== "all" ? "No assignments for this class yet." : "You haven't created any assignments yet."}
icon={PenTool}
action={{
label: "Create Assignment",
href: "/teacher/homework/assignments/create",
href:
classId && classId !== "all"
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`
: "/teacher/homework/assignments/create",
}}
/>
) : (

View File

@@ -3,6 +3,8 @@ import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access"
import { HomeworkGradingView } from "@/modules/homework/components/homework-grading-view"
import { formatDate } from "@/shared/lib/utils"
export const dynamic = "force-dynamic"
export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }) {
const { submissionId } = await params
const submission = await getHomeworkSubmissionDetails(submissionId)
@@ -34,8 +36,9 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
status={submission.status}
totalScore={submission.totalScore}
answers={submission.answers}
prevSubmissionId={submission.prevSubmissionId}
nextSubmissionId={submission.nextSubmissionId}
/>
</div>
)
}

View File

@@ -10,12 +10,16 @@ import {
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkSubmissions } from "@/modules/homework/data-access"
import { getHomeworkAssignmentReviewList } from "@/modules/homework/data-access"
import { Inbox } from "lucide-react"
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
export default async function SubmissionsPage() {
const submissions = await getHomeworkSubmissions()
const hasSubmissions = submissions.length > 0
const creatorId = await getTeacherIdForMutations()
const assignments = await getHomeworkAssignmentReviewList({ creatorId })
const hasAssignments = assignments.length > 0
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
@@ -23,15 +27,15 @@ export default async function SubmissionsPage() {
<div>
<h2 className="text-2xl font-bold tracking-tight">Submissions</h2>
<p className="text-muted-foreground">
Review student homework submissions.
Review homework by assignment.
</p>
</div>
</div>
{!hasSubmissions ? (
{!hasAssignments ? (
<EmptyState
title="No submissions"
description="There are no homework submissions to review."
title="No assignments"
description="There are no homework assignments to review yet."
icon={Inbox}
/>
) : (
@@ -40,29 +44,31 @@ export default async function SubmissionsPage() {
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Student</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Score</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targets</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submissions.map((s) => (
<TableRow key={s.id}>
{assignments.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link href={`/teacher/homework/submissions/${s.id}`} className="hover:underline">
{s.assignmentTitle}
<Link href={`/teacher/homework/assignments/${a.id}/submissions`} className="hover:underline">
{a.title}
</Link>
<div className="text-xs text-muted-foreground">{a.sourceExamTitle}</div>
</TableCell>
<TableCell>{s.studentName}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
{a.status}
</Badge>
{s.isLate ? <span className="ml-2 text-xs text-destructive">Late</span> : null}
</TableCell>
<TableCell className="text-muted-foreground">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
<TableCell>{typeof s.score === "number" ? s.score : "-"}</TableCell>
<TableCell className="text-muted-foreground">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right">{a.targetCount}</TableCell>
<TableCell className="text-right">{a.submittedCount}</TableCell>
<TableCell className="text-right">{a.gradedCount}</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -1,12 +1,14 @@
import { notFound } from "next/navigation";
import { ArrowLeft, Edit } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByChapterId } from "@/modules/textbooks/data-access";
import { TextbookContentLayout } from "@/modules/textbooks/components/textbook-content-layout";
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access";
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader";
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog";
export const dynamic = "force-dynamic"
export default async function TextbookDetailPage({
params,
}: {
@@ -14,36 +16,16 @@ export default async function TextbookDetailPage({
}) {
const { id } = await params;
const [textbook, chapters] = await Promise.all([
const [textbook, chapters, knowledgePoints] = await Promise.all([
getTextbookById(id),
getChaptersByTextbookId(id),
getKnowledgePointsByTextbookId(id),
]);
if (!textbook) {
notFound();
}
// Fetch all KPs for these chapters. In a real app, this might be optimized to fetch only needed or use a different query strategy.
// For now, we simulate fetching KPs for all chapters to pass down, or we could fetch on demand.
// Given the layout loads everything client-side for interactivity, let's fetch all KPs associated with any chapter in this textbook.
// We'll need to extend the data access for this specific query pattern or loop.
// For simplicity in this mock, let's assume getKnowledgePointsByChapterId can handle fetching all KPs for a textbook if we had such a function,
// or we iterate. Let's create a helper to get all KPs for the textbook's chapters.
// Actually, let's update data-access to support getting KPs by Textbook ID directly or just fetch all for mock.
// Since we don't have getKnowledgePointsByTextbookId, we will map over chapters.
const allKnowledgePoints = (await Promise.all(
chapters.map(c => getKnowledgePointsByChapterId(c.id))
)).flat();
// Also need to get KPs for children chapters if any
const childrenKPs = (await Promise.all(
chapters.flatMap(c => c.children || []).map(child => getKnowledgePointsByChapterId(child.id))
)).flat();
const knowledgePoints = [...allKnowledgePoints, ...childrenKPs];
return (
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
{/* Header / Nav (Fixed height) */}
@@ -69,10 +51,11 @@ export default async function TextbookDetailPage({
{/* Main Content Layout (Flex grow) */}
<div className="flex-1 overflow-hidden pt-6">
<TextbookContentLayout
<TextbookReader
chapters={chapters}
knowledgePoints={knowledgePoints}
textbookId={id}
canEdit={true}
/>
</div>
</div>

View File

@@ -1,23 +1,56 @@
import { Search, Filter } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { Suspense } from "react"
import { BookOpen } from "lucide-react"
import { TextbookCard } from "@/modules/textbooks/components/textbook-card";
import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog";
import { getTextbooks } from "@/modules/textbooks/data-access";
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default async function TextbooksPage() {
// In a real app, we would parse searchParams here
const textbooks = await getTextbooks();
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
async function TextbooksResults({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams
const q = getParam(params, "q") || undefined
const subject = getParam(params, "subject")
const grade = getParam(params, "grade")
const textbooks = await getTextbooks(q, subject || undefined, grade || undefined)
const hasFilters = Boolean(q || (subject && subject !== "all") || (grade && grade !== "all"))
if (textbooks.length === 0) {
return (
<EmptyState
icon={BookOpen}
title={hasFilters ? "No textbooks match your filters" : "No textbooks yet"}
description={hasFilters ? "Try clearing filters or adjusting keywords." : "Create your first textbook to start organizing chapters."}
action={hasFilters ? { label: "Clear filters", href: "/teacher/textbooks" } : undefined}
className="min-h-[400px] border-muted-foreground/10"
/>
)
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{textbooks.map((textbook) => (
<TextbookCard key={textbook.id} textbook={textbook} />
))}
</div>
)
}
export default async function TextbooksPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
return (
<div className="space-y-6 p-8">
{/* Page Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
@@ -29,50 +62,13 @@ export default async function TextbooksPage() {
<TextbookFormDialog />
</div>
{/* Toolbar */}
<div className="flex flex-col gap-4 md:flex-row md:items-center justify-between bg-card p-4 rounded-lg border shadow-sm">
<div className="relative w-full md:w-96">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search textbooks..."
className="pl-9 bg-background"
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Select>
<SelectTrigger className="w-[140px] bg-background">
<Filter className="mr-2 h-4 w-4 text-muted-foreground" />
<SelectValue placeholder="Subject" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Subjects</SelectItem>
<SelectItem value="math">Mathematics</SelectItem>
<SelectItem value="physics">Physics</SelectItem>
<SelectItem value="history">History</SelectItem>
<SelectItem value="english">English</SelectItem>
</SelectContent>
</Select>
<Select>
<SelectTrigger className="w-[140px] bg-background">
<SelectValue placeholder="Grade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Grades</SelectItem>
<SelectItem value="10">Grade 10</SelectItem>
<SelectItem value="11">Grade 11</SelectItem>
<SelectItem value="12">Grade 12</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Suspense fallback={<div className="h-14 w-full animate-pulse rounded-lg bg-muted" />}>
<TextbookFilters />
</Suspense>
{/* Grid Content */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{textbooks.map((textbook) => (
<TextbookCard key={textbook.id} textbook={textbook} />
))}
</div>
<Suspense fallback={<div className="h-[360px] w-full animate-pulse rounded-md bg-muted" />}>
<TextbooksResults searchParams={searchParams} />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,4 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

View File

@@ -0,0 +1,109 @@
import { NextResponse } from "next/server"
import { eq, inArray } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { classes, classSubjectTeachers, users } from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
import { enrollStudentByInvitationCode } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
function parseCodes(input: string) {
const raw = input
.split(/[\s,;]+/g)
.map((s) => s.trim())
.filter(Boolean)
return Array.from(new Set(raw))
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null
}
export async function POST(req: Request) {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
const body = await req.json().catch(() => null)
if (!isRecord(body)) return NextResponse.json({ success: false, message: "Invalid payload" }, { status: 400 })
const roleRaw = String(body.role ?? "").trim()
const allowedRoles = ["student", "teacher", "parent", "admin"] as const
const role = (allowedRoles as readonly string[]).includes(roleRaw) ? roleRaw : null
if (!role) return NextResponse.json({ success: false, message: "Invalid role" }, { status: 400 })
const current = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { role: true },
})
const currentRole = String(current?.role ?? "student")
if (role === "admin" && currentRole !== "admin") {
return NextResponse.json({ success: false, message: "Forbidden" }, { status: 403 })
}
const name = String(body.name ?? "").trim()
if (!name) return NextResponse.json({ success: false, message: "Name is required" }, { status: 400 })
const phone = String(body.phone ?? "").trim()
const address = String(body.address ?? "").trim()
const classCodesText = String(body.classCodes ?? "").trim()
const codes = classCodesText.length ? parseCodes(classCodesText) : []
const teacherSubjectsRaw = Array.isArray(body.teacherSubjects) ? body.teacherSubjects : []
const teacherSubjects = teacherSubjectsRaw
.map((s) => String(s).trim())
.filter((s): s is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(s as ClassSubject))
await db
.update(users)
.set({
role,
name,
phone: phone.length ? phone : null,
address: address.length ? address : null,
})
.where(eq(users.id, userId))
if (role === "student" && codes.length) {
for (const code of codes) {
await enrollStudentByInvitationCode(userId, code)
}
}
if (role === "teacher" && codes.length && teacherSubjects.length) {
const classRows = await db
.select({ id: classes.id, invitationCode: classes.invitationCode })
.from(classes)
.where(inArray(classes.invitationCode, codes))
const byCode = new Map<string, string>()
for (const r of classRows) {
if (typeof r.invitationCode === "string") {
byCode.set(r.invitationCode, r.id)
}
}
for (const code of codes) {
const classId = byCode.get(code)
if (!classId) continue
for (const subject of teacherSubjects) {
await db
.insert(classSubjectTeachers)
.values({ classId, subject, teacherId: userId })
.onDuplicateKeyUpdate({ set: { teacherId: userId, updatedAt: new Date() } })
}
}
}
await db
.update(users)
.set({ onboardedAt: new Date() })
.where(eq(users.id, userId))
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server"
import { eq } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
export const dynamic = "force-dynamic"
export async function GET() {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) {
return NextResponse.json({ required: false })
}
const row = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { onboardedAt: true, role: true },
})
const required = !row?.onboardedAt
return NextResponse.json({ required, role: row?.role ?? "student" })
}

View File

@@ -1,6 +1,7 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));

View File

@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { ThemeProvider } from "@/shared/components/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner";
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { AuthSessionProvider } from "@/shared/components/auth-session-provider"
import { OnboardingGate } from "@/shared/components/onboarding-gate"
import "./globals.css";
export const metadata: Metadata = {
@@ -25,9 +27,12 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<NuqsAdapter>
{children}
</NuqsAdapter>
<AuthSessionProvider>
<NuqsAdapter>
{children}
<OnboardingGate />
</NuqsAdapter>
</AuthSessionProvider>
<Toaster />
</ThemeProvider>
</body>

87
src/auth.ts Normal file
View File

@@ -0,0 +1,87 @@
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
secret: process.env.NEXTAUTH_SECRET,
session: { strategy: "jwt" },
pages: { signIn: "/login" },
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
const email = String(credentials?.email ?? "").trim().toLowerCase()
const password = String(credentials?.password ?? "")
if (!email || !password) return null
const [{ eq }, { db }, { users }] = await Promise.all([
import("drizzle-orm"),
import("@/shared/db"),
import("@/shared/db/schema"),
])
const user = await db.query.users.findFirst({
where: eq(users.email, email),
})
if (!user) return null
const storedPassword = user.password ?? null
if (storedPassword) {
if (storedPassword !== password) return null
} else if (process.env.NODE_ENV === "production") {
return null
}
return {
id: user.id,
name: user.name ?? undefined,
email: user.email,
role: (user.role ?? "student") as string,
}
},
}),
],
callbacks: {
jwt: async ({ token, user }) => {
if (user) {
token.id = (user as { id: string }).id
token.role = (user as { role?: string }).role ?? "student"
token.name = (user as { name?: string }).name
}
const userId = String(token.id ?? "").trim()
if (userId) {
const [{ eq }, { db }, { users }] = await Promise.all([
import("drizzle-orm"),
import("@/shared/db"),
import("@/shared/db/schema"),
])
const fresh = await db.query.users.findFirst({
where: eq(users.id, userId),
columns: { role: true, name: true },
})
if (fresh) {
token.role = fresh.role ?? token.role ?? "student"
token.name = fresh.name ?? token.name
}
}
return token
},
session: async ({ session, token }) => {
if (session.user) {
session.user.id = String(token.id ?? "")
session.user.role = String(token.role ?? "student")
if (typeof token.name === "string") {
session.user.name = token.name
}
}
return session
},
},
})

View File

@@ -1,4 +1,3 @@
import Link from "next/link"
import { GraduationCap } from "lucide-react"
interface AuthLayoutProps {

View File

@@ -2,6 +2,8 @@
import * as React from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { signIn } from "next-auth/react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
@@ -12,14 +14,32 @@ type LoginFormProps = React.HTMLAttributes<HTMLDivElement>
export function LoginForm({ className, ...props }: LoginFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const router = useRouter()
const searchParams = useSearchParams()
async function onSubmit(event: React.SyntheticEvent) {
event.preventDefault()
setIsLoading(true)
setTimeout(() => {
setIsLoading(false)
}, 3000)
const form = event.currentTarget as HTMLFormElement
const formData = new FormData(form)
const email = String(formData.get("email") ?? "")
const password = String(formData.get("password") ?? "")
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"
const result = await signIn("credentials", {
redirect: false,
email,
password,
callbackUrl,
})
setIsLoading(false)
if (!result?.error) {
router.push(result?.url ?? callbackUrl)
router.refresh()
}
}
return (
@@ -38,6 +58,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
@@ -58,6 +79,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
</div>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
disabled={isLoading}

View File

@@ -2,24 +2,44 @@
import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { cn } from "@/shared/lib/utils"
import { Loader2, Github } from "lucide-react"
import type { ActionState } from "@/shared/types/action-state"
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement>
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement> & {
registerAction: (formData: FormData) => Promise<ActionState>
}
export function RegisterForm({ className, ...props }: RegisterFormProps) {
export function RegisterForm({ className, registerAction, ...props }: RegisterFormProps) {
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const router = useRouter()
async function onSubmit(event: React.SyntheticEvent) {
event.preventDefault()
setIsLoading(true)
setTimeout(() => {
try {
const form = event.currentTarget as HTMLFormElement
const formData = new FormData(form)
const res = await registerAction(formData)
if (res.success) {
toast.success(res.message || "Account created")
router.push("/login")
router.refresh()
} else {
toast.error(res.message || "Failed to create account")
}
} catch {
toast.error("Failed to create account")
} finally {
setIsLoading(false)
}, 3000)
}
}
return (
@@ -38,6 +58,7 @@ export function RegisterForm({ className, ...props }: RegisterFormProps) {
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
name="name"
placeholder="John Doe"
type="text"
autoCapitalize="words"
@@ -50,6 +71,7 @@ export function RegisterForm({ className, ...props }: RegisterFormProps) {
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
@@ -62,6 +84,7 @@ export function RegisterForm({ className, ...props }: RegisterFormProps) {
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
disabled={isLoading}

View File

@@ -0,0 +1,662 @@
"use server";
import { revalidatePath } from "next/cache"
import { and, eq, sql, or, inArray } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { grades, classes } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import {
createAdminClass,
createClassScheduleItem,
createTeacherClass,
deleteAdminClass,
deleteClassScheduleItem,
deleteTeacherClass,
enrollStudentByEmail,
enrollStudentByInvitationCode,
ensureClassInvitationCode,
regenerateClassInvitationCode,
setClassSubjectTeachers,
setStudentEnrollmentStatus,
updateAdminClass,
updateClassScheduleItem,
updateTeacherClass,
} from "./data-access"
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "./types"
const isClassSubject = (v: string): v is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(v as ClassSubject)
export async function createTeacherClassAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
if (typeof name !== "string" || name.trim().length === 0) {
return { success: false, message: "Class name is required" }
}
if (typeof grade !== "string" || grade.trim().length === 0) {
return { success: false, message: "Grade is required" }
}
const session = await auth()
if (!session?.user) return { success: false, message: "Unauthorized" }
const role = String(session.user.role ?? "")
if (role !== "admin") {
const userId = String(session.user.id ?? "").trim()
if (!userId) return { success: false, message: "Unauthorized" }
const normalizedGradeId = typeof gradeId === "string" ? gradeId.trim() : ""
const normalizedGradeName = grade.trim().toLowerCase()
const where = normalizedGradeId
? and(eq(grades.id, normalizedGradeId), eq(grades.gradeHeadId, userId))
: and(eq(grades.gradeHeadId, userId), sql`LOWER(${grades.name}) = ${normalizedGradeName}`)
const [ownedGrade] = await db.select({ id: grades.id }).from(grades).where(where).limit(1)
if (!ownedGrade) {
return { success: false, message: "Only admins and grade heads can create classes" }
}
}
try {
const id = await createTeacherClass({
schoolName: typeof schoolName === "string" ? schoolName : null,
schoolId: typeof schoolId === "string" ? schoolId : null,
name,
grade,
gradeId: typeof gradeId === "string" ? gradeId : null,
homeroom: typeof homeroom === "string" ? homeroom : null,
room: typeof room === "string" ? room : null,
})
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
}
}
export async function updateTeacherClassAction(
classId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
await updateTeacherClass(classId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined,
name: typeof name === "string" ? name : undefined,
grade: typeof grade === "string" ? grade : undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined,
room: typeof room === "string" ? room : undefined,
})
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
}
}
export async function deleteTeacherClassAction(classId: string): Promise<ActionState> {
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
await deleteTeacherClass(classId)
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
}
}
export async function createGradeClassAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
const session = await auth()
const userId = session?.user?.id
if (!userId) return { success: false, message: "Unauthorized" }
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
if (typeof name !== "string" || name.trim().length === 0) {
return { success: false, message: "Class name is required" }
}
if (typeof gradeId !== "string" || gradeId.trim().length === 0) {
return { success: false, message: "Grade selection is required" }
}
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
return { success: false, message: "Teacher is required" }
}
// Verify access
const [managedGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
.limit(1)
if (!managedGrade) {
return { success: false, message: "You do not have permission to create classes for this grade" }
}
try {
const id = await createAdminClass({
schoolName: typeof schoolName === "string" ? schoolName : null,
schoolId: typeof schoolId === "string" ? schoolId : null,
name,
grade: typeof grade === "string" ? grade : "", // Should be passed from UI based on selected grade
gradeId,
teacherId,
homeroom: typeof homeroom === "string" ? homeroom : null,
room: typeof room === "string" ? room : null,
})
revalidatePath("/management/grade/classes")
return { success: true, message: "Class created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
}
}
export async function updateGradeClassAction(
classId: string,
prevState: ActionState | undefined,
formData: FormData
): Promise<ActionState> {
const session = await auth()
const userId = session?.user?.id
if (!userId) return { success: false, message: "Unauthorized" }
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
const subjectTeachers = formData.get("subjectTeachers")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
// Verify access: Check if the class belongs to a managed grade
const [cls] = await db
.select({ gradeId: classes.gradeId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!cls || !cls.gradeId) {
return { success: false, message: "Class not found or not linked to a grade" }
}
const [managedGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
.limit(1)
if (!managedGrade) {
return { success: false, message: "You do not have permission to update this class" }
}
// If changing gradeId, verify target grade too
if (typeof gradeId === "string" && gradeId !== cls.gradeId) {
const [targetGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
.limit(1)
if (!targetGrade) {
return { success: false, message: "You do not have permission to move class to this grade" }
}
}
try {
await updateAdminClass(classId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined,
name: typeof name === "string" ? name : undefined,
grade: typeof grade === "string" ? grade : undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined,
teacherId: typeof teacherId === "string" ? teacherId : undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined,
room: typeof room === "string" ? room : undefined,
})
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
const parsed = JSON.parse(subjectTeachers) as unknown
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
await setClassSubjectTeachers({
classId,
assignments: parsed.flatMap((item) => {
if (!item || typeof item !== "object") return []
const subject = (item as { subject?: unknown }).subject
const teacherId = (item as { teacherId?: unknown }).teacherId
if (typeof subject !== "string" || !isClassSubject(subject)) return []
if (teacherId === null || typeof teacherId === "undefined") {
return [{ subject, teacherId: null }]
}
if (typeof teacherId !== "string") return []
const trimmed = teacherId.trim()
return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }]
}),
})
}
revalidatePath("/management/grade/classes")
return { success: true, message: "Class updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
}
}
export async function deleteGradeClassAction(classId: string): Promise<ActionState> {
const session = await auth()
const userId = session?.user?.id
if (!userId) return { success: false, message: "Unauthorized" }
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
// Verify access
const [cls] = await db
.select({ gradeId: classes.gradeId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!cls || !cls.gradeId) {
return { success: false, message: "Class not found or not linked to a grade" }
}
const [managedGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId))))
.limit(1)
if (!managedGrade) {
return { success: false, message: "You do not have permission to delete this class" }
}
try {
await deleteAdminClass(classId)
revalidatePath("/management/grade/classes")
return { success: true, message: "Class deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
}
}
export async function enrollStudentByEmailAction(
classId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const email = formData.get("email")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Please select a class" }
}
if (typeof email !== "string" || email.trim().length === 0) {
return { success: false, message: "Student email is required" }
}
try {
await enrollStudentByEmail(classId, email)
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/my")
return { success: true, message: "Student added successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to add student" }
}
}
export async function joinClassByInvitationCodeAction(
prevState: ActionState<{ classId: string }> | null,
formData: FormData
): Promise<ActionState<{ classId: string }>> {
const code = formData.get("code")
if (typeof code !== "string" || code.trim().length === 0) {
return { success: false, message: "Invitation code is required" }
}
const session = await auth()
const role = String(session?.user?.role ?? "")
if (!session?.user?.id || (role !== "student" && role !== "teacher")) {
return { success: false, message: "Unauthorized" }
}
try {
const classId = await enrollStudentByInvitationCode(session.user.id, code)
if (role === "student") {
revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule")
} else {
revalidatePath("/teacher/classes/my")
}
revalidatePath("/profile")
return { success: true, message: "Joined class successfully", data: { classId } }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to join class" }
}
}
export async function ensureClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
const code = await ensureClassInvitationCode(classId)
revalidatePath("/teacher/classes/my")
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
return { success: true, message: "Invitation code ready", data: { code } }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to generate code" }
}
}
export async function regenerateClassInvitationCodeAction(classId: string): Promise<ActionState<{ code: string }>> {
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
const code = await regenerateClassInvitationCode(classId)
revalidatePath("/teacher/classes/my")
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
return { success: true, message: "Invitation code updated", data: { code } }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to regenerate code" }
}
}
export async function setStudentEnrollmentStatusAction(
classId: string,
studentId: string,
status: "active" | "inactive"
): Promise<ActionState> {
if (!classId?.trim() || !studentId?.trim()) {
return { success: false, message: "Missing enrollment info" }
}
try {
await setStudentEnrollmentStatus(classId, studentId, status)
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/my")
return { success: true, message: "Student updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update student" }
}
}
export async function createClassScheduleItemAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const classId = formData.get("classId")
const weekday = formData.get("weekday")
const startTime = formData.get("startTime")
const endTime = formData.get("endTime")
const course = formData.get("course")
const location = formData.get("location")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Please select a class" }
}
if (typeof weekday !== "string" || weekday.trim().length === 0) {
return { success: false, message: "Weekday is required" }
}
const weekdayNum = Number(weekday)
if (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7) {
return { success: false, message: "Invalid weekday" }
}
if (typeof course !== "string" || course.trim().length === 0) {
return { success: false, message: "Course is required" }
}
if (typeof startTime !== "string" || typeof endTime !== "string") {
return { success: false, message: "Time is required" }
}
try {
const id = await createClassScheduleItem({
classId,
weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7,
startTime,
endTime,
course,
location: typeof location === "string" ? location : null,
})
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create schedule item" }
}
}
export async function updateClassScheduleItemAction(
scheduleId: string,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const classId = formData.get("classId")
const weekday = formData.get("weekday")
const startTime = formData.get("startTime")
const endTime = formData.get("endTime")
const course = formData.get("course")
const location = formData.get("location")
if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) {
return { success: false, message: "Missing schedule id" }
}
const weekdayNum = typeof weekday === "string" && weekday.trim().length > 0 ? Number(weekday) : undefined
if (weekdayNum !== undefined && (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7)) {
return { success: false, message: "Invalid weekday" }
}
try {
await updateClassScheduleItem(scheduleId, {
classId: typeof classId === "string" ? classId : undefined,
weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7 | undefined,
startTime: typeof startTime === "string" ? startTime : undefined,
endTime: typeof endTime === "string" ? endTime : undefined,
course: typeof course === "string" ? course : undefined,
location: typeof location === "string" ? location : undefined,
})
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update schedule item" }
}
}
export async function deleteClassScheduleItemAction(scheduleId: string): Promise<ActionState> {
if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) {
return { success: false, message: "Missing schedule id" }
}
try {
await deleteClassScheduleItem(scheduleId)
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete schedule item" }
}
}
export async function createAdminClassAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
const session = await auth()
if (!session?.user?.id || String(session.user.role ?? "") !== "admin") {
return { success: false, message: "Unauthorized" }
}
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
if (typeof name !== "string" || name.trim().length === 0) {
return { success: false, message: "Class name is required" }
}
if (typeof grade !== "string" || grade.trim().length === 0) {
return { success: false, message: "Grade is required" }
}
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
return { success: false, message: "Teacher is required" }
}
try {
const id = await createAdminClass({
schoolName: typeof schoolName === "string" ? schoolName : null,
schoolId: typeof schoolId === "string" ? schoolId : null,
name,
grade,
gradeId: typeof gradeId === "string" ? gradeId : null,
teacherId,
homeroom: typeof homeroom === "string" ? homeroom : null,
room: typeof room === "string" ? room : null,
})
revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class created successfully", data: id }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to create class" }
}
}
export async function updateAdminClassAction(
classId: string,
prevState: ActionState | undefined,
formData: FormData
): Promise<ActionState> {
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
const subjectTeachers = formData.get("subjectTeachers")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
await updateAdminClass(classId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined,
name: typeof name === "string" ? name : undefined,
grade: typeof grade === "string" ? grade : undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined,
teacherId: typeof teacherId === "string" ? teacherId : undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined,
room: typeof room === "string" ? room : undefined,
})
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
const parsed = JSON.parse(subjectTeachers) as unknown
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
await setClassSubjectTeachers({
classId,
assignments: parsed.flatMap((item) => {
if (!item || typeof item !== "object") return []
const subject = (item as { subject?: unknown }).subject
const teacherId = (item as { teacherId?: unknown }).teacherId
if (typeof subject !== "string" || !isClassSubject(subject)) return []
if (teacherId === null || typeof teacherId === "undefined") {
return [{ subject, teacherId: null }]
}
if (typeof teacherId !== "string") return []
const trimmed = teacherId.trim()
return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }]
}),
})
}
revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class updated successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to update class" }
}
}
export async function deleteAdminClassAction(classId: string): Promise<ActionState> {
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
await deleteAdminClass(classId)
revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Class deleted successfully" }
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" }
}
}

View File

@@ -0,0 +1,434 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import type { AdminClassListItem, ClassSubjectTeacherAssignment, TeacherOption } from "../types"
import { DEFAULT_CLASS_SUBJECTS } from "../types"
import { createAdminClassAction, deleteAdminClassAction, updateAdminClassAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { formatDate } from "@/shared/lib/utils"
export function AdminClassesClient({
classes,
teachers,
}: {
classes: AdminClassListItem[]
teachers: TeacherOption[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<AdminClassListItem | null>(null)
const [deleteItem, setDeleteItem] = useState<AdminClassListItem | null>(null)
const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers])
const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId)
const [editTeacherId, setEditTeacherId] = useState("")
const [editSubjectTeachers, setEditSubjectTeachers] = useState<Array<{ subject: string; teacherId: string | null }>>([])
useEffect(() => {
if (!createOpen) return
setCreateTeacherId(defaultTeacherId)
}, [createOpen, defaultTeacherId])
useEffect(() => {
if (!editItem) return
setEditTeacherId(editItem.teacher.id)
setEditSubjectTeachers(
DEFAULT_CLASS_SUBJECTS.map((s) => ({
subject: s,
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
}))
)
}, [editItem])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await createAdminClassAction(undefined, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create class")
}
} catch {
toast.error("Failed to create class")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
const res = await updateAdminClassAction(editItem.id, undefined, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
}
} catch {
toast.error("Failed to update class")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteAdminClassAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete class")
}
} catch {
toast.error("Failed to delete class")
} finally {
setIsWorking(false)
}
}
const setSubjectTeacher = (subject: string, teacherId: string | null) => {
setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p)))
}
const formatSubjectTeachers = (list: ClassSubjectTeacherAssignment[]) => {
const pairs = list
.filter((x) => x.teacher)
.map((x) => `${x.subject}:${x.teacher?.name ?? ""}`)
.filter((x) => x.length > 0)
return pairs.length > 0 ? pairs.join("") : "-"
}
return (
<>
<div className="flex justify-end">
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
<Plus className="mr-2 h-4 w-4" />
New class
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">All classes</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{classes.length}
</Badge>
</CardHeader>
<CardContent>
{classes.length === 0 ? (
<EmptyState
title="No classes"
description="Create classes to manage students and schedules."
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>School</TableHead>
<TableHead>Name</TableHead>
<TableHead>Grade</TableHead>
<TableHead>Homeroom</TableHead>
<TableHead>Room</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{classes.map((c) => (
<TableRow key={c.id}>
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditItem(c)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(c)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>New class</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-school-name" className="text-right">
School
</Label>
<Input id="create-school-name" name="schoolName" className="col-span-3" placeholder="e.g. First Primary School" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-name" className="text-right">
Name
</Label>
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Grade 10 · Class 3" autoFocus />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade" className="text-right">
Grade
</Label>
<Input id="create-grade" name="grade" className="col-span-3" placeholder="e.g. Grade 10" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-homeroom" className="text-right">
Homeroom
</Label>
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-room" className="text-right">
Room
</Label>
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Teacher</Label>
<div className="col-span-3">
<Select value={createTeacherId} onValueChange={setCreateTeacherId} disabled={teachers.length === 0}>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={createTeacherId} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking || teachers.length === 0 || !createTeacherId}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(editItem)}
onOpenChange={(open) => {
if (isWorking) return
if (!open) setEditItem(null)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Edit class</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-school-name" className="text-right">
School
</Label>
<Input
id="edit-school-name"
name="schoolName"
className="col-span-3"
defaultValue={editItem.schoolName ?? ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-name" className="text-right">
Name
</Label>
<Input id="edit-name" name="name" className="col-span-3" defaultValue={editItem.name} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-grade" className="text-right">
Grade
</Label>
<Input id="edit-grade" name="grade" className="col-span-3" defaultValue={editItem.grade} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-homeroom" className="text-right">
Homeroom
</Label>
<Input id="edit-homeroom" name="homeroom" className="col-span-3" defaultValue={editItem.homeroom ?? ""} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-room" className="text-right">
Room
</Label>
<Input id="edit-room" name="room" className="col-span-3" defaultValue={editItem.room ?? ""} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<div className="col-span-3">
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={editTeacherId} />
</div>
</div>
<div className="space-y-3 rounded-md border p-4">
<div className="text-sm font-medium"></div>
<div className="grid gap-3">
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
return (
<div key={subject} className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{subject}</Label>
<div className="col-span-3">
<Select
value={selected ?? ""}
onValueChange={(v) => setSubjectTeacher(subject, v ? v : null)}
disabled={teachers.length === 0}
>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
})}
</div>
<input type="hidden" name="subjectTeachers" value={JSON.stringify(editSubjectTeachers)} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking || !editTeacherId}>
Save
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteItem)}
onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete class</AlertDialogTitle>
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this class"}.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,109 @@
import Link from "next/link"
import { ChevronRight, FileText } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
interface AssignmentSummary {
id: string
title: string
status: string
isActive: boolean
isOverdue: boolean
dueAt: Date | null
submittedCount: number
targetCount: number
avgScore: number | null
medianScore: number | null
}
interface ClassAssignmentsWidgetProps {
classId: string
assignments: AssignmentSummary[]
}
export function ClassAssignmentsWidget({ classId, assignments }: ClassAssignmentsWidgetProps) {
const activeAssignments = assignments.filter((a) => a.isActive)
return (
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">Recent Homework</CardTitle>
<CardDescription>
{activeAssignments.length} active assignments
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/homework/assignments?classId=${encodeURIComponent(classId)}`}>
View All
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="pt-4">
{assignments.length === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
<div className="rounded-full bg-muted p-3">
<FileText className="h-6 w-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">No homework yet</p>
<p className="text-xs text-muted-foreground">
Create an assignment to get started.
</p>
</div>
<Button size="sm" asChild>
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
Create Homework
</Link>
</Button>
</div>
) : (
<div className="space-y-4">
{assignments.slice(0, 5).map((assignment) => (
<div
key={assignment.id}
className="flex items-start justify-between space-x-4 rounded-md border p-3 transition-all hover:bg-muted/50"
>
<div className="space-y-1">
<Link
href={`/teacher/homework/assignments/${assignment.id}`}
className="block font-medium hover:underline line-clamp-1"
>
{assignment.title}
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={assignment.isOverdue ? "text-destructive font-medium" : ""}>
Due {assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}
</span>
<span></span>
<span>
{assignment.submittedCount}/{assignment.targetCount} Submitted
</span>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Badge
variant={assignment.isActive ? "default" : "secondary"}
className="rounded-sm px-1.5 py-0.5 text-[10px] uppercase"
>
{assignment.status}
</Badge>
{typeof assignment.avgScore === "number" && (
<span className="text-xs font-medium tabular-nums">
Avg: {assignment.avgScore.toFixed(0)}%
</span>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import { useState } from "react"
import { MoreHorizontal, Pencil, Settings, Share2 } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { EditClassDialog } from "./edit-class-dialog"
interface ClassHeaderProps {
classId: string
name: string
grade: string
homeroom?: string | null
room?: string | null
schoolName?: string | null
studentCount: number
}
export function ClassHeader({
classId,
name,
grade,
homeroom,
room,
schoolName,
studentCount,
}: ClassHeaderProps) {
const [showEdit, setShowEdit] = useState(false)
return (
<>
<div className="flex flex-col gap-4 border-b bg-background px-6 py-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{name}
</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{schoolName && (
<>
<span>{schoolName}</span>
<span className="text-muted-foreground/40"></span>
</>
)}
<Badge variant="secondary" className="font-medium">
{grade}
</Badge>
{homeroom && (
<>
<span className="text-muted-foreground/40"></span>
<span>Homeroom {homeroom}</span>
</>
)}
{room && (
<>
<span className="text-muted-foreground/40"></span>
<span>Room {room}</span>
</>
)}
<span className="text-muted-foreground/40"></span>
<span>{studentCount} Students</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="hidden sm:flex">
<Share2 className="mr-2 h-4 w-4" />
Invite
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setShowEdit(true)}>
<Pencil className="mr-2 h-4 w-4" />
Edit details
</DropdownMenuItem>
<DropdownMenuItem>
<Share2 className="mr-2 h-4 w-4" />
Invite students
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Settings className="mr-2 h-4 w-4" />
Class settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<EditClassDialog
open={showEdit}
onOpenChange={setShowEdit}
classId={classId}
initialData={{
name,
grade,
homeroom,
room,
schoolName
}}
/>
</>
)
}

View File

@@ -0,0 +1,74 @@
import { AlertCircle, BarChart3, CheckCircle2, PenTool } from "lucide-react"
import { Card, CardContent } from "@/shared/components/ui/card"
interface ClassOverviewStatsProps {
averageScore: number | null
submissionRate: number
papersToGrade: number
overdueCount: number
}
export function ClassOverviewStats({
averageScore,
submissionRate,
papersToGrade,
overdueCount,
}: ClassOverviewStatsProps) {
return (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatsCard
title="Class Average"
value={averageScore ? `${averageScore.toFixed(1)}%` : "-"}
subValue="Overall performance"
icon={BarChart3}
/>
<StatsCard
title="Submission Rate"
value={`${submissionRate.toFixed(0)}%`}
subValue="Average turn-in rate"
icon={CheckCircle2}
/>
<StatsCard
title="To Grade"
value={papersToGrade.toString()}
subValue="Pending reviews"
icon={PenTool}
/>
<StatsCard
title="Missed Deadlines"
value={overdueCount.toString()}
subValue="Active assignments past due"
icon={AlertCircle}
/>
</div>
)
}
function StatsCard({
title,
value,
subValue,
icon: Icon,
}: {
title: string
value: string
subValue: string
icon: React.ElementType
}) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground">{subValue}</p>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,42 @@
import Link from "next/link"
import { Calendar, FilePlus, Mail, MessageSquare, Settings } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
interface ClassQuickActionsProps {
classId: string
}
export function ClassQuickActions({ classId }: ClassQuickActionsProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
<Button asChild className="w-full justify-start" size="sm">
<Link href={`/teacher/homework/assignments/create?classId=${encodeURIComponent(classId)}`}>
<FilePlus className="mr-2 h-4 w-4" />
Create Homework
</Link>
</Button>
<Button asChild variant="outline" className="w-full justify-start" size="sm">
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
<Calendar className="mr-2 h-4 w-4" />
Manage Schedule
</Link>
</Button>
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
<MessageSquare className="mr-2 h-4 w-4" />
Message Class (Coming soon)
</Button>
<Button variant="outline" className="w-full justify-start" size="sm" disabled>
<Settings className="mr-2 h-4 w-4" />
Class Settings
</Button>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,111 @@
import Link from "next/link"
import { Calendar, ChevronRight, Clock, MapPin } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/shared/components/ui/hover-card"
import type { ClassScheduleItem } from "@/modules/classes/types"
interface ClassScheduleWidgetProps {
classId: string
schedule: ClassScheduleItem[]
}
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
const WEEKDAY_INDICES = [1, 2, 3, 4, 5, 6, 7] // 1=Mon, 7=Sun
export function ClassScheduleGrid({ schedule, compact = false }: { schedule: ClassScheduleItem[], compact?: boolean }) {
// Group by weekday
const groupedSchedule = schedule.reduce((acc, item) => {
const day = item.weekday
if (!acc[day]) acc[day] = []
acc[day].push(item)
return acc
}, {} as Record<number, ClassScheduleItem[]>)
// Sort items within each day by start time
Object.keys(groupedSchedule).forEach(key => {
groupedSchedule[Number(key)].sort((a, b) => a.startTime.localeCompare(b.startTime))
})
if (schedule.length === 0) {
return (
<div className="flex flex-col items-center justify-center space-y-3 py-6 text-center">
<div className="rounded-full bg-muted p-3">
<Calendar className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">No sessions scheduled.</p>
</div>
)
}
return (
<div className="grid grid-cols-5 gap-1 text-center h-full grid-rows-[auto_1fr]">
{WEEKDAYS.slice(0, 5).map((day, i) => (
<div key={day} className="text-[10px] font-medium text-muted-foreground uppercase py-0.5 border-b bg-muted/20 h-fit">
{day}
</div>
))}
{WEEKDAY_INDICES.slice(0, 5).map((dayNum) => {
const items = groupedSchedule[dayNum] || []
return (
<div key={dayNum} className={`flex flex-col gap-1 py-1 border-r last:border-r-0 border-muted/30 ${compact ? 'max-h-[140px]' : 'min-h-[100px]'}`}>
{items.length === 0 ? (
<div className="flex-1" />
) : (
items.map(item => (
<HoverCard key={item.id}>
<HoverCardTrigger asChild>
<div className="bg-primary/5 text-primary rounded-[2px] p-1 text-[10px] text-left relative hover:bg-primary/10 transition-colors cursor-default leading-tight shrink-0">
<div className="font-semibold truncate">{item.course}</div>
<div className="opacity-70 scale-90 origin-left mt-0.5 whitespace-nowrap">{item.startTime}-{item.endTime}</div>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-48 p-3" align="start" side="top">
<div className="flex flex-col gap-2">
<div className="font-semibold text-sm border-b pb-1 mb-1">{item.course}</div>
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>{item.startTime} - {item.endTime}</span>
</div>
{item.location && (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span>{item.location}</span>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
))
)}
</div>
)
})}
</div>
)
}
export function ClassScheduleWidget({ classId, schedule }: ClassScheduleWidgetProps) {
return (
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base font-semibold">Weekly Schedule</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/schedule?classId=${encodeURIComponent(classId)}`}>
Manage
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="pt-4">
<ClassScheduleGrid schedule={schedule} />
<div className="mt-2 text-[10px] text-muted-foreground text-center">
* Showing Mon-Fri schedule
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,106 @@
import Link from "next/link"
import { ChevronRight, Users } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils"
interface StudentSummary {
id: string
name: string
email: string
image?: string | null
status: string
subjectScores?: Record<string, number | null>
}
interface ClassStudentsWidgetProps {
classId: string
students: StudentSummary[]
}
export function ClassStudentsWidget({ classId, students }: ClassStudentsWidgetProps) {
const activeCount = students.filter(s => s.status === "active").length
return (
<Card className="h-fit">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">Students</CardTitle>
<CardDescription>
{activeCount} active students
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href={`/teacher/classes/students?classId=${encodeURIComponent(classId)}`}>
View All
<ChevronRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="pt-4">
{students.length === 0 ? (
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
<div className="rounded-full bg-muted p-3">
<Users className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">No students enrolled yet.</p>
</div>
) : (
<div className="space-y-4">
{students.slice(0, 6).map((student) => (
<div key={student.id} className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={student.image || undefined} alt={student.name} />
<AvatarFallback className="text-xs">
{student.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className="space-y-0.5">
<div className="text-sm font-medium leading-none">{student.name}</div>
<div className="text-xs text-muted-foreground line-clamp-1">{student.email}</div>
</div>
</div>
<Badge
variant={student.status === "active" ? "outline" : "secondary"}
className="text-[10px] capitalize"
>
{student.status}
</Badge>
</div>
{/* Subject Scores */}
{student.subjectScores && Object.keys(student.subjectScores).length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{Object.entries(student.subjectScores).map(([subject, score]) => (
<div key={subject} className="flex items-center gap-1.5 rounded bg-muted/50 px-2 py-1 text-[10px]">
<span className="font-medium text-muted-foreground">{subject}</span>
{score !== null ? (
<span className={score >= 60 ? "font-semibold text-primary" : "font-semibold text-destructive"}>
{score}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,398 @@
"use client"
import { useState, useMemo } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChevronDown } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
interface AssignmentSummary {
id: string
title: string
status: string
subject?: string | null
isActive: boolean
isOverdue: boolean
dueAt: Date | null
submittedCount: number
targetCount: number
avgScore: number | null
medianScore: number | null
}
interface ClassTrendsWidgetProps {
classId: string
assignments: AssignmentSummary[]
compact?: boolean
className?: string
}
const chartConfig = {
submitted: {
label: "Submitted",
color: "hsl(var(--primary))",
},
target: {
label: "Total Students",
color: "hsl(var(--muted-foreground))",
},
avg: {
label: "Average Score",
color: "hsl(var(--chart-2))",
},
median: {
label: "Median Score",
color: "hsl(var(--chart-4))",
},
} satisfies ChartConfig
export function transformAssignmentsToChartData(assignments: AssignmentSummary[], limit?: number) {
const data = [...assignments].reverse().map(a => ({
title: a.title.length > 10 ? a.title.substring(0, 10) + "..." : a.title,
fullTitle: a.title,
submitted: a.submittedCount,
target: a.targetCount,
avg: a.avgScore ? Math.round(a.avgScore) : null,
median: a.medianScore ? Math.round(a.medianScore) : null,
}))
if (limit) {
return data.slice(-limit)
}
return data
}
export function ClassSubmissionTrendChart({
data,
className
}: {
data: any[]
className?: string
}) {
return (
<ChartContainer config={chartConfig} className={className}>
<LineChart accessibilityLayer data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={10}
hide
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={10}
domain={[0, 'auto']}
tickFormatter={(value) => `${value}`}
hide
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/>
<Line
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
</LineChart>
</ChartContainer>
)
}
export function ClassTrendsWidget({ classId, assignments, compact, className }: ClassTrendsWidgetProps) {
const [chartTab, setChartTab] = useState<"submission" | "score">("submission")
const [selectedSubject, setSelectedSubject] = useState<string>("all")
// Extract unique subjects
const subjects = Array.from(new Set(assignments.map(a => a.subject).filter(Boolean))) as string[]
const activeAssignments = assignments.filter((a) => {
if (selectedSubject !== "all" && a.subject !== selectedSubject) return false
return a.isActive || a.status === "published" // Include published even if not "active" in terms of due date
})
const chartData = transformAssignmentsToChartData(activeAssignments, 7)
if (chartData.length === 0 && selectedSubject === "all") return null
if (compact) {
// Calculate simple stats for compact view
const lastAssignment = chartData[chartData.length - 1]
let metricValue = "0%"
let metricLabel = "Latest"
if (lastAssignment) {
if (chartTab === "submission") {
metricValue = lastAssignment.target > 0
? `${Math.round((lastAssignment.submitted / lastAssignment.target) * 100)}%`
: "0%"
} else {
metricValue = lastAssignment.avg ? `${lastAssignment.avg}` : "-"
}
}
return (
<div className={`flex flex-col h-full ${className || ""}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs font-semibold text-foreground/80 hover:bg-muted">
{chartTab === "submission" ? "Submission" : "Score"}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setChartTab("submission")} className="text-xs">
Submission Trends
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setChartTab("score")} className="text-xs">
Score Trends
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{subjects.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground">
{selectedSubject === "all" ? "All Subjects" : selectedSubject}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setSelectedSubject("all")} className="text-xs">
All Subjects
</DropdownMenuItem>
{subjects.map(s => (
<DropdownMenuItem key={s} onClick={() => setSelectedSubject(s)} className="text-xs">
{s}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="text-xs font-medium text-muted-foreground">
{metricLabel}: <span className="text-foreground">{metricValue}</span>
</div>
</div>
{/* Compact Sparkline Chart */}
<div className="flex-1 w-full min-h-0">
<ChartContainer config={chartConfig} className="h-full w-full">
{chartTab === "submission" ? (
<AreaChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="fillSubmitted" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-submitted)" stopOpacity={0.3}/>
<stop offset="95%" stopColor="var(--color-submitted)" stopOpacity={0.05}/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
<XAxis dataKey="title" hide />
<YAxis hide domain={[0, 'auto']} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" hideLabel />}
/>
<Area
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
fill="url(#fillSubmitted)"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={1}
strokeDasharray="2 2"
dot={false}
activeDot={false}
/>
</AreaChart>
) : (
<LineChart accessibilityLayer data={chartData} margin={{ top: 5, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" horizontal={false} />
<XAxis dataKey="title" hide />
<YAxis hide domain={[0, 100]} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Line
type="monotone"
dataKey="avg"
stroke="var(--color-avg)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
<Line
type="monotone"
dataKey="median"
stroke="var(--color-median)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
activeDot={{ r: 4 }}
/>
</LineChart>
)}
</ChartContainer>
</div>
</div>
)
}
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle className="text-base font-semibold">
{chartTab === "submission" ? "Submission Trends" : "Score Trends"}
</CardTitle>
<CardDescription>
{chartTab === "submission" ? "Recent assignment turn-in rates" : "Average vs Median performance"}
</CardDescription>
</div>
<Tabs value={chartTab} onValueChange={(v) => setChartTab(v as "submission" | "score")} className="w-auto">
<TabsList className="grid w-full grid-cols-2 h-8">
<TabsTrigger value="submission" className="text-xs">Submission</TabsTrigger>
<TabsTrigger value="score" className="text-xs">Score</TabsTrigger>
</TabsList>
</Tabs>
</div>
{subjects.length > 0 && (
<Tabs value={selectedSubject} onValueChange={setSelectedSubject} className="w-full">
<TabsList className="h-8 w-auto flex-wrap justify-start bg-transparent p-0">
<TabsTrigger
value="all"
className="h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
>
All Subjects
</TabsTrigger>
{subjects.map(s => (
<TabsTrigger
key={s}
value={s}
className="ml-2 h-7 rounded-md border bg-background px-3 text-xs data-[state=active]:bg-muted data-[state=active]:text-foreground"
>
{s}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
</CardHeader>
<CardContent>
{chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="h-[250px] w-full">
{chartTab === "submission" ? (
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={12}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
domain={[0, 'auto']}
tickFormatter={(value) => `${value}`}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="target"
stroke="var(--color-target)"
strokeWidth={2}
strokeDasharray="4 4"
dot={false}
/>
<Line
type="monotone"
dataKey="submitted"
stroke="var(--color-submitted)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
</LineChart>
) : (
<LineChart accessibilityLayer data={chartData} margin={{ top: 20, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="title"
tickLine={false}
tickMargin={10}
axisLine={false}
fontSize={12}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey="avg"
stroke="var(--color-avg)"
strokeWidth={2}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="median"
stroke="var(--color-median)"
strokeWidth={2}
strokeDasharray="4 4"
activeDot={{ r: 6 }}
/>
</LineChart>
)}
</ChartContainer>
) : (
<div className="flex h-[250px] items-center justify-center text-sm text-muted-foreground">
No data for this subject
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,143 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { updateTeacherClassAction } from "../../actions"
interface EditClassDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
classId: string
initialData: {
name: string
grade: string
homeroom?: string | null
room?: string | null
schoolName?: string | null
}
}
export function EditClassDialog({
open,
onOpenChange,
classId,
initialData,
}: EditClassDialogProps) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const handleEdit = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await updateTeacherClassAction(classId, null, formData)
if (res.success) {
toast.success(res.message)
onOpenChange(false)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
}
} catch {
toast.error("Failed to update class")
} finally {
setIsWorking(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(val) => {
if (isWorking) return
onOpenChange(val)
}}
>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Edit class</DialogTitle>
<DialogDescription>Update basic class information.</DialogDescription>
</DialogHeader>
<form action={handleEdit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="schoolName" className="text-right">
School
</Label>
<Input
id="schoolName"
name="schoolName"
className="col-span-3"
defaultValue={initialData.schoolName ?? ""}
placeholder="Optional"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
name="name"
className="col-span-3"
defaultValue={initialData.name}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade" className="text-right">
Grade
</Label>
<Input
id="grade"
name="grade"
className="col-span-3"
defaultValue={initialData.grade}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="homeroom" className="text-right">
Homeroom
</Label>
<Input
id="homeroom"
name="homeroom"
className="col-span-3"
defaultValue={initialData.homeroom ?? ""}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="room" className="text-right">
Room
</Label>
<Input
id="room"
name="room"
className="col-span-3"
defaultValue={initialData.room ?? ""}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking}>
{isWorking ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,455 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import type { AdminClassListItem, ClassSubjectTeacherAssignment, TeacherOption } from "../types"
import { DEFAULT_CLASS_SUBJECTS } from "../types"
import { createGradeClassAction, deleteGradeClassAction, updateGradeClassAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { formatDate } from "@/shared/lib/utils"
export function GradeClassesClient({
classes,
teachers,
managedGrades,
}: {
classes: AdminClassListItem[]
teachers: TeacherOption[]
managedGrades: { id: string; name: string; schoolId: string; schoolName: string | null }[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<AdminClassListItem | null>(null)
const [deleteItem, setDeleteItem] = useState<AdminClassListItem | null>(null)
const defaultTeacherId = useMemo(() => teachers[0]?.id ?? "", [teachers])
const [createTeacherId, setCreateTeacherId] = useState(defaultTeacherId)
const [createGradeId, setCreateGradeId] = useState(managedGrades[0]?.id ?? "")
const [editTeacherId, setEditTeacherId] = useState("")
const [editGradeId, setEditGradeId] = useState("")
const [editSubjectTeachers, setEditSubjectTeachers] = useState<Array<{ subject: string; teacherId: string | null }>>([])
useEffect(() => {
if (!createOpen) return
setCreateTeacherId(defaultTeacherId)
setCreateGradeId(managedGrades[0]?.id ?? "")
}, [createOpen, defaultTeacherId, managedGrades])
useEffect(() => {
if (!editItem) return
setEditTeacherId(editItem.teacher.id)
setEditGradeId(editItem.gradeId ?? managedGrades[0]?.id ?? "")
setEditSubjectTeachers(
DEFAULT_CLASS_SUBJECTS.map((s) => ({
subject: s,
teacherId: editItem.subjectTeachers.find((st) => st.subject === s)?.teacher?.id ?? null,
}))
)
}, [editItem, managedGrades])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await createGradeClassAction(undefined, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create class")
}
} catch {
toast.error("Failed to create class")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
const res = await updateGradeClassAction(editItem.id, undefined, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update class")
}
} catch {
toast.error("Failed to update class")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteGradeClassAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete class")
}
} catch {
toast.error("Failed to delete class")
} finally {
setIsWorking(false)
}
}
const setSubjectTeacher = (subject: string, teacherId: string | null) => {
setEditSubjectTeachers((prev) => prev.map((p) => (p.subject === subject ? { ...p, teacherId } : p)))
}
const formatSubjectTeachers = (list: ClassSubjectTeacherAssignment[]) => {
const pairs = list
.filter((x) => x.teacher)
.map((x) => `${x.subject}:${x.teacher?.name ?? ""}`)
.filter((x) => x.length > 0)
return pairs.length > 0 ? pairs.join("") : "-"
}
const selectedCreateGrade = managedGrades.find(g => g.id === createGradeId)
const selectedEditGrade = managedGrades.find(g => g.id === editGradeId)
return (
<>
<div className="flex justify-end">
<Button onClick={() => setCreateOpen(true)} disabled={isWorking || managedGrades.length === 0}>
<Plus className="mr-2 h-4 w-4" />
New class
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">All classes</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{classes.length}
</Badge>
</CardHeader>
<CardContent>
{classes.length === 0 ? (
<EmptyState
title="No classes"
description={managedGrades.length === 0 ? "You are not managing any grades yet." : "Create classes to manage students and schedules."}
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>School</TableHead>
<TableHead>Name</TableHead>
<TableHead>Grade</TableHead>
<TableHead>Homeroom</TableHead>
<TableHead>Room</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">Students</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{classes.map((c) => (
<TableRow key={c.id}>
<TableCell className="text-muted-foreground">{c.schoolName ?? "-"}</TableCell>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="text-muted-foreground">{c.grade}</TableCell>
<TableCell className="text-muted-foreground">{c.homeroom ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{c.room ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{c.teacher.name}</TableCell>
<TableCell className="text-muted-foreground">{formatSubjectTeachers(c.subjectTeachers)}</TableCell>
<TableCell className="text-muted-foreground tabular-nums text-right">{c.studentCount}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(c.updatedAt)}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditItem(c)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(c)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>New class</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Grade</Label>
<div className="col-span-3">
<Select value={createGradeId} onValueChange={setCreateGradeId} disabled={managedGrades.length === 0}>
<SelectTrigger>
<SelectValue placeholder={managedGrades.length === 0 ? "No managed grades" : "Select a grade"} />
</SelectTrigger>
<SelectContent>
{managedGrades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name} ({g.schoolName})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="gradeId" value={createGradeId} />
<input type="hidden" name="grade" value={selectedCreateGrade?.name ?? ""} />
<input type="hidden" name="schoolId" value={selectedCreateGrade?.schoolId ?? ""} />
<input type="hidden" name="schoolName" value={selectedCreateGrade?.schoolName ?? ""} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-name" className="text-right">
Name
</Label>
<Input id="create-name" name="name" className="col-span-3" placeholder="e.g. Class 3" autoFocus />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-homeroom" className="text-right">
Homeroom
</Label>
<Input id="create-homeroom" name="homeroom" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-room" className="text-right">
Room
</Label>
<Input id="create-room" name="room" className="col-span-3" placeholder="Optional" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<div className="col-span-3">
<Select value={createTeacherId} onValueChange={setCreateTeacherId} disabled={teachers.length === 0}>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={createTeacherId} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking || teachers.length === 0 || !createTeacherId || !createGradeId}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(editItem)}
onOpenChange={(open) => {
if (isWorking) return
if (!open) setEditItem(null)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Edit class</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Grade</Label>
<div className="col-span-3">
<Select value={editGradeId} onValueChange={setEditGradeId} disabled={managedGrades.length === 0}>
<SelectTrigger>
<SelectValue placeholder="Select a grade" />
</SelectTrigger>
<SelectContent>
{managedGrades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name} ({g.schoolName})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="gradeId" value={editGradeId} />
<input type="hidden" name="grade" value={selectedEditGrade?.name ?? ""} />
<input type="hidden" name="schoolId" value={selectedEditGrade?.schoolId ?? ""} />
<input type="hidden" name="schoolName" value={selectedEditGrade?.schoolName ?? ""} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-name" className="text-right">
Name
</Label>
<Input id="edit-name" name="name" className="col-span-3" defaultValue={editItem.name} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-homeroom" className="text-right">
Homeroom
</Label>
<Input id="edit-homeroom" name="homeroom" className="col-span-3" defaultValue={editItem.homeroom ?? ""} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-room" className="text-right">
Room
</Label>
<Input id="edit-room" name="room" className="col-span-3" defaultValue={editItem.room ?? ""} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<div className="col-span-3">
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="teacherId" value={editTeacherId} />
</div>
</div>
<div className="space-y-3 rounded-md border p-4">
<div className="text-sm font-medium"></div>
<div className="grid gap-3">
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
return (
<div key={subject} className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{subject}</Label>
<div className="col-span-3">
<Select
value={selected ?? ""}
onValueChange={(v) => setSubjectTeacher(subject, v ? v : null)}
disabled={teachers.length === 0}
>
<SelectTrigger>
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
</SelectTrigger>
<SelectContent>
{teachers.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
})}
</div>
<input type="hidden" name="subjectTeachers" value={JSON.stringify(editSubjectTeachers)} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking || !editTeacherId}>
Save
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteItem)}
onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete class</AlertDialogTitle>
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this class"}.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,395 @@
"use client"
import Link from "next/link"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import {
Plus,
RefreshCw,
Copy,
Users,
MapPin,
GraduationCap,
Search,
} from "lucide-react"
import { toast } from "sonner"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { cn } from "@/shared/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/components/ui/tooltip"
import type { TeacherClass, ClassScheduleItem } from "../types"
import {
ensureClassInvitationCodeAction,
regenerateClassInvitationCodeAction,
joinClassByInvitationCodeAction,
} from "../actions"
const GRADIENTS = [
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
"bg-card border-border",
]
function getClassGradient(id: string) {
return "bg-card border-border shadow-sm hover:shadow-md"
}
export function MyClassesGrid({ classes, canCreateClass }: { classes: TeacherClass[]; canCreateClass: boolean }) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [joinOpen, setJoinOpen] = useState(false)
const handleJoin = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await joinClassByInvitationCodeAction(null, formData)
if (res.success) {
toast.success(res.message || "Joined class successfully")
setJoinOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to join class")
}
} catch {
toast.error("Failed to join class")
} finally {
setIsWorking(false)
}
}
return (
<div className="space-y-6">
{/* Filter Bar */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end">
<div className="relative">
<Dialog
open={joinOpen}
onOpenChange={(open) => {
if (isWorking) return
setJoinOpen(open)
}}
>
<DialogTrigger asChild>
<div className="group relative">
{/* Decorative Ticket Stub Effect */}
<div className="absolute -inset-0.5 bg-gradient-to-r from-primary/20 to-secondary/20 rounded-lg blur opacity-30 group-hover:opacity-60 transition duration-500"></div>
<Button className="relative gap-2 h-10 px-5 shadow-sm border border-primary/10 hover:shadow-md transition-all bg-background text-foreground hover:bg-muted/50" disabled={isWorking} variant="outline">
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary group-hover:scale-110 transition-transform duration-300">
<Plus className="size-3.5" strokeWidth={3} />
</div>
<span className="font-semibold tracking-tight">Join New Class</span>
</Button>
</div>
</DialogTrigger>
<DialogContent className="sm:max-w-[480px] p-0 overflow-hidden gap-0 border-none shadow-2xl">
{/* Header with Pattern */}
<div className="relative bg-primary/5 p-6 border-b border-border/50">
<div className="absolute inset-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '12px 12px' }}></div>
<DialogHeader className="relative z-10">
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
<Plus className="size-5" />
</div>
Join a Class
</DialogTitle>
<DialogDescription className="text-muted-foreground mt-1.5">
Enter the 6-digit invitation code provided by your administrator.
</DialogDescription>
</DialogHeader>
</div>
<form action={handleJoin} className="bg-card">
<div className="p-6 space-y-6">
<div className="space-y-3">
<Label htmlFor="join-code" className="text-sm font-medium">
Invitation Code
</Label>
<div className="relative">
<Input
id="join-code"
name="code"
className="h-12 text-center text-2xl font-mono tracking-[0.5em] font-bold uppercase placeholder:tracking-normal placeholder:font-sans placeholder:text-base placeholder:font-normal"
placeholder="e.g. 123456"
required
maxLength={6}
pattern="\d{6}"
autoComplete="off"
autoFocus
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/30 pointer-events-none">
<Users className="size-5" />
</div>
</div>
<p className="text-xs text-muted-foreground">
Ask your administrator for the code if you don't have one.
</p>
</div>
</div>
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t border-border/50">
<Button type="button" variant="ghost" onClick={() => setJoinOpen(false)} disabled={isWorking}>
Cancel
</Button>
<Button type="submit" disabled={isWorking} className="min-w-[100px]">
{isWorking ? "Joining..." : "Join Class"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
{/* List */}
<div className="flex flex-col gap-4">
{classes.length === 0 ? (
<EmptyState
title="No classes yet"
description="Join a class to start managing students and schedules."
icon={Users}
action={{ label: "Join class", onClick: () => setJoinOpen(true) }}
className="h-[360px] bg-card border-dashed"
/>
) : (
classes.map((c) => (
<ClassTicket key={c.id} c={c} onWorkingChange={setIsWorking} isWorking={isWorking} />
))
)}
</div>
</div>
)
}
import { ClassScheduleGrid } from "./class-detail/class-schedule-widget"
import { ClassTrendsWidget } from "./class-detail/class-trends-widget"
// Removed MiniSchedule since we're using ClassScheduleGrid now
function ClassTicket({
c,
isWorking,
onWorkingChange,
}: {
c: TeacherClass
isWorking: boolean
onWorkingChange: (v: boolean) => void
}) {
const router = useRouter()
const handleEnsureCode = async () => {
onWorkingChange(true)
try {
const res = await ensureClassInvitationCodeAction(c.id)
if (res.success) {
toast.success(res.message || "Invitation code ready")
router.refresh()
} else {
toast.error(res.message || "Failed to generate invitation code")
}
} catch {
toast.error("Failed to generate invitation code")
} finally {
onWorkingChange(false)
}
}
const handleRegenerateCode = async () => {
onWorkingChange(true)
try {
const res = await regenerateClassInvitationCodeAction(c.id)
if (res.success) {
toast.success(res.message || "Invitation code updated")
router.refresh()
} else {
toast.error(res.message || "Failed to regenerate invitation code")
}
} catch {
toast.error("Failed to regenerate invitation code")
} finally {
onWorkingChange(false)
}
}
const handleCopyCode = async () => {
const code = c.invitationCode ?? ""
if (!code) return
try {
await navigator.clipboard.writeText(code)
toast.success("Copied invitation code")
} catch {
toast.error("Failed to copy")
}
}
// Real data for chart
const recentAssignments = c.recentAssignments ?? []
// Calculate performance change for indicator (still needed for the top indicator)
// We can't reuse chart data easily here without recalculating, but ClassTrendsWidget handles its own data now
const lastTwoAssignments = [...recentAssignments].reverse().slice(-2)
const performanceChange = lastTwoAssignments.length === 2 && lastTwoAssignments[0].submittedCount > 0
? ((lastTwoAssignments[1].submittedCount - lastTwoAssignments[0].submittedCount) / lastTwoAssignments[0].submittedCount) * 100
: 0
const isPositive = performanceChange >= 0
return (
<div className="group relative flex w-full overflow-hidden rounded-xl border bg-card shadow-sm transition-all hover:shadow-md">
{/* Realistic Paper Texture & Noise */}
<div className="absolute inset-0 pointer-events-none opacity-[0.02]" style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg viewBox=\'0 0 200 200\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'noiseFilter\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.65\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23noiseFilter)\'/%3E%3C/svg%3E")' }}></div>
<div className="absolute inset-0 pointer-events-none opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '16px 16px' }}></div>
{/* Decorative Barcode Strip */}
<div className="absolute left-0 top-0 bottom-0 w-1.5 bg-primary/10 flex flex-col justify-between py-2 pointer-events-none">
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="w-full h-px bg-primary/20" style={{ marginBottom: Math.random() * 8 + 2 + 'px' }}></div>
))}
</div>
{/* Left Section: Basic Info (Narrower) */}
<div className="flex w-full flex-col justify-between p-5 pl-7 sm:w-[320px] sm:flex-shrink-0 relative z-10 border-r border-dashed border-muted-foreground/20">
{/* Punch Hole Effect Top-Left */}
<div className="absolute -left-2 -top-2 h-6 w-6 rounded-full bg-background border border-border shadow-[inset_1px_1px_2px_rgba(0,0,0,0.1)] z-20"></div>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/5 text-xl font-bold text-primary shadow-sm border border-primary/10">
{c.grade.replace(/[^0-9]/g, '')}
</div>
<div className="flex flex-col">
<Link href={`/teacher/classes/my/${encodeURIComponent(c.id)}`} className="text-lg font-bold hover:underline tracking-tight line-clamp-1">
{c.name}
</Link>
<Badge variant="secondary" className="w-fit font-normal text-xs bg-muted/50 font-mono tracking-tight">
{c.grade} {c.id.slice(-4).toUpperCase()}
</Badge>
</div>
</div>
<div className="space-y-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Users className="size-4 text-muted-foreground/70" />
<span className="font-medium text-foreground/80">{c.studentCount}</span> Students
</div>
<div className="flex items-center gap-2">
<MapPin className="size-4 text-muted-foreground/70" />
<span className="font-medium text-foreground/80">{c.room || "No Room"}</span>
</div>
{c.schoolName && (
<div className="flex items-center gap-2">
<GraduationCap className="size-4 text-muted-foreground/70" />
<span className="line-clamp-1">{c.schoolName}</span>
</div>
)}
</div>
</div>
{/* Invitation Code Section */}
<div className="mt-6 pt-4 border-t border-dashed border-border relative">
{/* Tiny Cut marks */}
<div className="absolute -left-5 top-[-1px] w-2 h-[2px] bg-border"></div>
<div className="absolute -right-5 top-[-1px] w-2 h-[2px] bg-border"></div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider">Entry Pass</span>
<div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="w-0.5 h-2 bg-muted-foreground/20"></div>
))}
</div>
</div>
<div className="flex items-center justify-between gap-2 bg-muted/30 px-3 py-1.5 rounded-sm border border-dashed border-muted-foreground/30 relative overflow-hidden">
<span className="font-mono text-lg font-bold tracking-widest text-foreground z-10">{c.invitationCode || "—"}</span>
{/* Faint QR Code Placeholder Background */}
<div className="absolute right-10 top-1/2 -translate-y-1/2 opacity-[0.03]">
<div className="w-8 h-8 bg-current grid grid-cols-4 grid-rows-4 gap-px">
{Array.from({ length: 16 }).map((_, i) => (
<div key={i} className={cn("bg-transparent", Math.random() > 0.5 && "bg-black")}></div>
))}
</div>
</div>
{c.invitationCode ? (
<div className="flex gap-1 z-10">
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleCopyCode} title="Copy">
<Copy className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-muted" onClick={handleRegenerateCode} title="Regenerate">
<RefreshCw className="size-3.5" />
</Button>
</div>
) : (
<Button variant="outline" size="sm" className="h-7 text-xs z-10" onClick={handleEnsureCode}>
Generate
</Button>
)}
</div>
</div>
</div>
</div>
{/* Dashed Divider (Ticket perforation) */}
<div className="relative hidden w-4 flex-col items-center justify-center sm:flex -ml-2 z-20">
<div className="absolute -top-2 h-4 w-4 rounded-full bg-background border border-border shadow-[inset_0_-1px_1px_rgba(0,0,0,0.05)]" />
<div className="h-full w-px border-l-2 border-dashed border-muted-foreground/20 relative">
{/* Scissor Icon */}
<div className="absolute top-1/2 -left-[5px] -translate-y-1/2 text-muted-foreground/20 -rotate-90 text-[10px]"></div>
</div>
<div className="absolute -bottom-2 h-4 w-4 rounded-full bg-background border border-border shadow-[inset_0_1px_1px_rgba(0,0,0,0.05)]" />
</div>
{/* Right Section: Stats & Actions (Wider) */}
<div className="flex flex-1 flex-col bg-muted/5 p-6 relative z-10">
<div className="flex flex-1 gap-6">
{/* Left: Submission Trends */}
<div className="flex-1 flex flex-col gap-4 min-w-0">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-foreground/80">Submission Trends</h4>
<span className={cn(
"text-xs font-bold px-2 py-0.5 rounded-full border flex items-center gap-1",
isPositive
? "text-emerald-600 bg-emerald-50 border-emerald-100"
: "text-red-600 bg-red-50 border-red-100"
)}>
{isPositive ? "+" : ""}{Math.round(performanceChange)}% <span className={cn("font-normal opacity-70 hidden sm:inline")}>vs last week</span>
</span>
</div>
{/* Real Chart */}
<div className="h-[140px] w-full">
<ClassTrendsWidget
classId={c.id}
assignments={recentAssignments}
compact
className="h-full w-full"
/>
</div>
</div>
{/* Right: Weekly Schedule */}
<div className="flex-1 flex flex-col gap-4 border-l border-dashed border-muted-foreground/20 pl-6 min-w-0">
<div className="h-[170px] w-full overflow-y-auto pr-1">
<ClassScheduleGrid schedule={c.schedule ?? []} compact />
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,195 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Plus } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import type { TeacherClass } from "../types"
import { createClassScheduleItemAction } from "../actions"
export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all").withOptions({ shallow: false }))
const router = useRouter()
const [open, setOpen] = useState(false)
const [isWorking, setIsWorking] = useState(false)
const defaultClassId = useMemo(() => (classId !== "all" ? classId : classes[0]?.id ?? ""), [classId, classes])
const [createClassId, setCreateClassId] = useState(defaultClassId)
const [weekday, setWeekday] = useState<string>("1")
useEffect(() => {
if (!open) return
setCreateClassId(defaultClassId)
setWeekday("1")
}, [open, defaultClassId])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("classId", createClassId)
const res = await createClassScheduleItemAction(null, formData)
if (res.success) {
toast.success(res.message)
setOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create schedule item")
}
} catch {
toast.error("Failed to create schedule item")
} finally {
setIsWorking(false)
}
}
const selectedClass = classes.find((c) => c.id === classId)
const title = selectedClass ? selectedClass.name : "All Classes"
return (
<div className="relative flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? "all" : val)}>
<SelectTrigger className="h-8 w-[180px] text-xs bg-transparent border-none shadow-none hover:bg-muted/50 focus:ring-0 text-muted-foreground hover:text-foreground">
<SelectValue placeholder="All Classes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">All Classes</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id} className="text-xs">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="absolute left-1/2 -translate-x-1/2 text-sm font-medium text-muted-foreground">
{title}
</div>
<Dialog
open={open}
onOpenChange={(v) => {
if (isWorking) return
setOpen(v)
}}
>
<DialogTrigger asChild>
<Button
className="h-8 gap-1.5 text-xs px-3 shadow-none border-transparent bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
disabled={classes.length === 0}
variant="ghost"
>
<Plus className="size-3.5" />
Add Event
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Add schedule item</DialogTitle>
<DialogDescription>Create a class schedule entry.</DialogDescription>
</DialogHeader>
<form action={handleCreate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={createClassId} onValueChange={setCreateClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="weekday" className="text-right">
Weekday
</Label>
<div className="col-span-3">
<Select value={weekday} onValueChange={setWeekday}>
<SelectTrigger>
<SelectValue placeholder="Select weekday" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Mon</SelectItem>
<SelectItem value="2">Tue</SelectItem>
<SelectItem value="3">Wed</SelectItem>
<SelectItem value="4">Thu</SelectItem>
<SelectItem value="5">Fri</SelectItem>
<SelectItem value="6">Sat</SelectItem>
<SelectItem value="7">Sun</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="weekday" value={weekday} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="startTime" className="text-right">
Start
</Label>
<Input id="startTime" name="startTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="endTime" className="text-right">
End
</Label>
<Input id="endTime" name="endTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="course" className="text-right">
Course
</Label>
<Input id="course" name="course" className="col-span-3" placeholder="e.g. Math" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="location" className="text-right">
Location
</Label>
<Input id="location" name="location" className="col-span-3" placeholder="Optional" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !createClassId}>
{isWorking ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,521 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Clock, MapPin, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import type { ClassScheduleItem, TeacherClass } from "../types"
import {
createClassScheduleItemAction,
deleteClassScheduleItemAction,
updateClassScheduleItemAction,
} from "../actions"
const WEEKDAYS: Array<{ key: ClassScheduleItem["weekday"]; label: string }> = [
{ key: 1, label: "Mon" },
{ key: 2, label: "Tue" },
{ key: 3, label: "Wed" },
{ key: 4, label: "Thu" },
{ key: 5, label: "Fri" },
{ key: 6, label: "Sat" },
{ key: 7, label: "Sun" },
]
export function ScheduleView({
schedule,
classes,
}: {
schedule: ClassScheduleItem[]
classes: TeacherClass[]
}) {
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [editItem, setEditItem] = useState<ClassScheduleItem | null>(null)
const [deleteItem, setDeleteItem] = useState<ClassScheduleItem | null>(null)
const [createWeekday, setCreateWeekday] = useState<ClassScheduleItem["weekday"]>(1)
const [createOpen, setCreateOpen] = useState(false)
const [createClassId, setCreateClassId] = useState<string>("")
const [editClassId, setEditClassId] = useState<string>("")
const [editWeekday, setEditWeekday] = useState<string>("1")
const classNameById = useMemo(() => new Map(classes.map((c) => [c.id, c.name] as const)), [classes])
const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes])
useEffect(() => {
if (!editItem) return
setEditClassId(editItem.classId)
setEditWeekday(String(editItem.weekday))
}, [editItem])
useEffect(() => {
if (!createOpen) return
setCreateClassId(defaultClassId)
}, [createOpen, defaultClassId])
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
for (const d of WEEKDAYS) byDay.set(d.key, [])
for (const item of schedule) byDay.get(item.weekday)?.push(item)
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("classId", createClassId || defaultClassId)
formData.set("weekday", String(createWeekday))
const res = await createClassScheduleItemAction(null, formData)
if (res.success) {
toast.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create schedule item")
}
} catch {
toast.error("Failed to create schedule item")
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
formData.set("classId", editClassId)
formData.set("weekday", editWeekday)
const res = await updateClassScheduleItemAction(editItem.id, null, formData)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update schedule item")
}
} catch {
toast.error("Failed to update schedule item")
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteClassScheduleItemAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete schedule item")
}
} catch {
toast.error("Failed to delete schedule item")
} finally {
setIsWorking(false)
}
}
const getPositionStyle = (startTime: string, endTime: string) => {
// Range 8:00 (480 min) -> 18:00 (1080 min)
// Total duration: 600 min
const startParts = startTime.split(':').map(Number)
const endParts = endTime.split(':').map(Number)
const startMinutes = startParts[0] * 60 + startParts[1]
const endMinutes = endParts[0] * 60 + endParts[1]
const minTime = 8 * 60
const maxTime = 18 * 60
const totalDuration = maxTime - minTime
// Calculate percentage positions
const top = Math.max(0, ((startMinutes - minTime) / totalDuration) * 100)
const height = Math.min(100 - top, ((endMinutes - startMinutes) / totalDuration) * 100)
return {
top: `${top}%`,
height: `${height}%`,
}
}
const HOURS = Array.from({ length: 11 }, (_, i) => 8 + i) // 8, 9, ..., 18
// Predefined colors for different subjects to add visual variety
const getSubjectColor = (subject: string) => {
const s = subject.toLowerCase()
if (s.includes('math')) return 'bg-blue-500/10 text-blue-700 border-blue-500/20 hover:bg-blue-500/20'
if (s.includes('physics') || s.includes('science')) return 'bg-purple-500/10 text-purple-700 border-purple-500/20 hover:bg-purple-500/20'
if (s.includes('english') || s.includes('lit')) return 'bg-amber-500/10 text-amber-700 border-amber-500/20 hover:bg-amber-500/20'
if (s.includes('history') || s.includes('geo')) return 'bg-orange-500/10 text-orange-700 border-orange-500/20 hover:bg-orange-500/20'
if (s.includes('art') || s.includes('music')) return 'bg-pink-500/10 text-pink-700 border-pink-500/20 hover:bg-pink-500/20'
if (s.includes('sport') || s.includes('pe')) return 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20 hover:bg-emerald-500/20'
return 'bg-primary/10 text-primary border-primary/20 hover:bg-primary/20'
}
return (
<div className="h-[600px] flex flex-col">
<div className="flex h-full">
{/* Time Axis */}
<div className="w-14 flex-shrink-0 flex flex-col">
<div className="h-10" /> {/* Header spacer */}
<div className="flex-1 relative">
{HOURS.map((h, i) => (
<div
key={h}
className="absolute w-full text-right pr-3 text-[11px] text-muted-foreground/60 font-medium -translate-y-1/2 font-mono"
style={{ top: `${(i / 10) * 100}%` }}
>
{h}:00
</div>
))}
</div>
</div>
{/* Days Columns */}
<div className="flex-1 grid grid-cols-5">
{WEEKDAYS.slice(0, 5).map((d) => (
<div key={d.key} className="flex flex-col h-full min-w-0">
<div className="flex items-center justify-center py-2 h-10 group">
<span className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors uppercase tracking-wider">{d.label}</span>
</div>
<div className="relative h-full mx-1">
{/* Subtle vertical guideline */}
<div className="absolute left-0 top-0 bottom-0 w-px bg-border/30" />
{(byDay.get(d.key) ?? []).map((item) => (
<div
key={item.id}
className="group absolute w-full px-1 z-10"
style={getPositionStyle(item.startTime, item.endTime)}
>
<div className={cn(
"rounded-md p-2 text-xs text-left relative transition-all cursor-default leading-tight h-full border overflow-hidden shadow-sm hover:shadow-md flex flex-col justify-center",
getSubjectColor(item.course)
)}>
<div className="flex justify-between items-start gap-1">
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
<div className="font-bold truncate text-[11px] leading-none tracking-tight">{item.course}</div>
<div className="opacity-80 scale-95 origin-left whitespace-nowrap tabular-nums text-[10px] font-medium leading-none font-mono">
{item.startTime} - {item.endTime}
</div>
<div className="opacity-70 scale-95 origin-left truncate text-[9px] leading-none mt-0.5 font-medium">
{classNameById.get(item.classId)}
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-1 right-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-5 w-5 hover:bg-background/20 p-0" disabled={isWorking}>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={() => setEditItem(item)} className="text-xs">
<Pencil className="mr-2 h-3 w-3" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-xs text-destructive focus:text-destructive"
onClick={() => setDeleteItem(item)}
>
<Trash2 className="mr-2 h-3 w-3" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
))}
{/* Add Button Overlay - Only visible on hover of the column */}
<div className="absolute inset-0 opacity-0 hover:opacity-100 transition-opacity pointer-events-none">
<div className="absolute top-2 right-2 pointer-events-auto">
<Button
variant="secondary"
size="icon"
className="h-6 w-6 rounded-full shadow-sm bg-background/80 backdrop-blur-sm hover:bg-primary hover:text-primary-foreground transition-all"
disabled={classes.length === 0}
onClick={() => {
setCreateWeekday(d.key)
setCreateOpen(true)
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
</div>
</div>
))}
</div>
</div>
<Dialog
open={createOpen}
onOpenChange={(v) => {
if (isWorking) return
setCreateOpen(v)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Add schedule item</DialogTitle>
<DialogDescription>Create a class schedule entry.</DialogDescription>
</DialogHeader>
<form action={handleCreate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={createClassId} onValueChange={setCreateClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="classId" value={createClassId} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Weekday</Label>
<Input value={WEEKDAYS.find((w) => w.key === createWeekday)?.label ?? ""} readOnly className="col-span-3" />
<input type="hidden" name="weekday" value={String(createWeekday)} />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-startTime" className="text-right">
Start
</Label>
<Input id="create-startTime" name="startTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-endTime" className="text-right">
End
</Label>
<Input id="create-endTime" name="endTime" type="time" className="col-span-3" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-course" className="text-right">
Course
</Label>
<Input id="create-course" name="course" className="col-span-3" placeholder="e.g. Math" required />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-location" className="text-right">
Location
</Label>
<Input id="create-location" name="location" className="col-span-3" placeholder="Optional" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !createClassId}>
{isWorking ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={!!editItem}
onOpenChange={(v) => {
if (isWorking) return
if (!v) setEditItem(null)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Edit schedule item</DialogTitle>
<DialogDescription>Update class schedule entry.</DialogDescription>
</DialogHeader>
<form action={handleUpdate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={editClassId} onValueChange={setEditClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="classId" value={editClassId} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-weekday" className="text-right">
Weekday
</Label>
<div className="col-span-3">
<Select value={editWeekday} onValueChange={setEditWeekday}>
<SelectTrigger>
<SelectValue placeholder="Select weekday" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Mon</SelectItem>
<SelectItem value="2">Tue</SelectItem>
<SelectItem value="3">Wed</SelectItem>
<SelectItem value="4">Thu</SelectItem>
<SelectItem value="5">Fri</SelectItem>
<SelectItem value="6">Sat</SelectItem>
<SelectItem value="7">Sun</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="weekday" value={editWeekday} />
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-startTime" className="text-right">
Start
</Label>
<Input
id="edit-startTime"
name="startTime"
type="time"
className="col-span-3"
defaultValue={editItem?.startTime}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-endTime" className="text-right">
End
</Label>
<Input
id="edit-endTime"
name="endTime"
type="time"
className="col-span-3"
defaultValue={editItem?.endTime}
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-course" className="text-right">
Course
</Label>
<Input
id="edit-course"
name="course"
className="col-span-3"
defaultValue={editItem?.course}
placeholder="e.g. Math"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-location" className="text-right">
Location
</Label>
<Input
id="edit-location"
name="location"
className="col-span-3"
defaultValue={editItem?.location ?? ""}
placeholder="Optional"
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !editClassId}>
{isWorking ? "Saving..." : "Save changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={!!deleteItem}
onOpenChange={(v) => {
if (isWorking) return
if (!v) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this schedule item.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleDelete()
}}
disabled={isWorking}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isWorking ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,218 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useQueryState, parseAsString } from "nuqs"
import { Search, UserPlus, X, ChevronDown, Check } from "lucide-react"
import { toast } from "sonner"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/shared/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Label } from "@/shared/components/ui/label"
import { cn } from "@/shared/lib/utils"
import type { TeacherClass } from "../types"
import { enrollStudentByEmailAction } from "../actions"
export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherClass[], defaultClassId?: string }) {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault("").withOptions({ shallow: false, throttleMs: 500 }))
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault(defaultClassId || "all").withOptions({ shallow: false }))
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all").withOptions({ shallow: false }))
const router = useRouter()
const [open, setOpen] = useState(false)
const [isWorking, setIsWorking] = useState(false)
const effectiveClassId = classId === "all" && defaultClassId ? defaultClassId : classId
const [enrollClassId, setEnrollClassId] = useState(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
useEffect(() => {
if (!open) return
setEnrollClassId(effectiveClassId !== "all" ? effectiveClassId : (classes[0]?.id ?? ""))
}, [open, effectiveClassId, classes])
const handleEnroll = async (formData: FormData) => {
setIsWorking(true)
try {
const res = await enrollStudentByEmailAction(enrollClassId, null, formData)
if (res.success) {
toast.success(res.message)
setOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to add student")
}
} catch {
toast.error("Failed to add student")
} finally {
setIsWorking(false)
}
}
const selectedClass = classes.find(c => c.id === classId)
const classLabel = classId === "all" ? "All Classes" : (selectedClass?.name || "Unknown Class")
const statusLabel = status === "all" ? "All Status" : (status === "active" ? "Active" : "Inactive")
const hasFilters = search || classId !== "all" || status !== "all"
return (
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">
{/* Search - Minimal */}
<div className="relative group">
<Search className="text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 group-hover:text-foreground transition-colors" />
<Input
placeholder="Search students..."
className="pl-8 h-8 w-[180px] text-xs bg-transparent border-transparent hover:bg-muted/50 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-input transition-all"
value={search}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
<div className="h-4 w-[1px] bg-border mx-1" />
{/* Class Filter - Compact */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
<span className="truncate max-w-[120px]">{classLabel}</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[200px]">
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Class</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setClassId("all")}
className="text-xs flex items-center justify-between"
>
All Classes
{classId === "all" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
<DropdownMenuSeparator />
{classes.map((c) => (
<DropdownMenuItem
key={c.id}
onClick={() => setClassId(c.id)}
className="text-xs flex items-center justify-between"
>
<span className="truncate">{c.name}</span>
{classId === c.id && <Check className="h-3 w-3" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Status Filter - Compact */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
{statusLabel}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Status</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setStatus(null)} className="text-xs flex items-center justify-between">
All Status
{status === "all" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatus("active")} className="text-xs flex items-center justify-between">
Active
{status === "active" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatus("inactive")} className="text-xs flex items-center justify-between">
Inactive
{status === "inactive" && <Check className="h-3 w-3" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Dialog
open={open}
onOpenChange={(v) => {
if (isWorking) return
setOpen(v)
}}
>
<DialogTrigger asChild>
<Button size="sm" className="h-8 gap-1.5 text-xs px-3" disabled={classes.length === 0}>
<UserPlus className="size-3.5" />
Add student
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Add student</DialogTitle>
<DialogDescription>Enroll a student by email to a class.</DialogDescription>
</DialogHeader>
<form action={handleEnroll}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">Class</Label>
<div className="col-span-3">
<Select value={enrollClassId} onValueChange={setEnrollClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="student-email" className="text-right">
Email
</Label>
<Input
id="student-email"
name="email"
type="email"
className="col-span-3"
placeholder="student@example.com"
required
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isWorking || !enrollClassId}>
{isWorking ? "Adding..." : "Add"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,211 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { MoreHorizontal, UserCheck, UserX } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
import { cn, formatDate } from "@/shared/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import type { ClassStudent } from "../types"
import { setStudentEnrollmentStatusAction } from "../actions"
export function StudentsTable({ students }: { students: ClassStudent[] }) {
const router = useRouter()
const [workingKey, setWorkingKey] = useState<string | null>(null)
const [removeTarget, setRemoveTarget] = useState<ClassStudent | null>(null)
const setStatus = async (student: ClassStudent, status: "active" | "inactive") => {
const key = `${student.classId}:${student.id}`
setWorkingKey(key)
try {
const res = await setStudentEnrollmentStatusAction(student.classId, student.id, status)
if (res.success) {
toast.success(res.message)
router.refresh()
} else {
toast.error(res.message)
}
} catch {
toast.error("Failed to update status")
} finally {
setWorkingKey(null)
}
}
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
}
return (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{students.map((s) => (
<Card key={`${s.classId}:${s.id}`} className="overflow-hidden">
<CardHeader className="flex flex-row items-center gap-4 space-y-0 p-4 pb-2">
<div className="relative">
<Avatar className="h-10 w-10 border">
<AvatarImage src={s.image || undefined} alt={s.name} />
<AvatarFallback>{getInitials(s.name)}</AvatarFallback>
</Avatar>
<span className={cn(
"absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background",
s.status === "active" ? "bg-emerald-500" : "bg-muted-foreground"
)} />
</div>
<div className="flex flex-col flex-1 overflow-hidden">
<div className="flex items-start justify-between">
<div className="flex flex-col overflow-hidden mr-2">
<span className="truncate font-semibold text-sm">{s.name}</span>
<span className="truncate text-xs text-muted-foreground">{s.email}</span>
</div>
<div className="flex flex-col items-end gap-0.5 text-xs text-muted-foreground shrink-0">
<span className="text-[10px] font-medium text-foreground/80">
{s.className}
</span>
<span className="text-[10px]">
{new Date(s.joinedAt).toLocaleDateString("en-GB", {
day: "2-digit",
month: "2-digit",
year: "2-digit"
})}
</span>
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
{s.subjectScores && Object.keys(s.subjectScores).length > 0 ? (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5">
{Object.entries(s.subjectScores).slice(0, 4).map(([subject, score]) => (
<div key={subject} className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1 text-xs border border-muted/50">
<span className="font-medium text-muted-foreground/80">{subject}</span>
{score !== null ? (
<span className={cn(
"font-bold",
score >= 90 ? "text-emerald-600" :
score >= 80 ? "text-primary" :
score >= 60 ? "text-yellow-600" : "text-destructive"
)}>
{score}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
))}
{Object.keys(s.subjectScores).length > 4 && (
<div className="flex items-center justify-center rounded-md bg-muted/50 px-2 py-1 text-xs text-muted-foreground font-medium border border-muted/50">
+{Object.keys(s.subjectScores).length - 4}
</div>
)}
</div>
</div>
) : (
<div className="flex items-center justify-center h-[32px] rounded-md bg-muted/20 border border-dashed border-muted">
<span className="text-xs text-muted-foreground/50 italic">No recent scores</span>
</div>
)}
</CardContent>
<CardFooter className="flex items-center justify-between border-t bg-muted/50 p-2">
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground" asChild>
<a href={`mailto:${s.email}`}>Email</a>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={workingKey !== null}>
<MoreHorizontal className="size-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{s.status !== "active" ? (
<DropdownMenuItem onClick={() => setStatus(s, "active")} disabled={workingKey !== null}>
<UserCheck className="mr-2 size-4" />
Set active
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => setStatus(s, "inactive")} disabled={workingKey !== null}>
<UserX className="mr-2 size-4" />
Set inactive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setRemoveTarget(s)}
className="text-destructive focus:text-destructive"
disabled={s.status === "inactive" || workingKey !== null}
>
<UserX className="mr-2 size-4" />
Remove from class
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardFooter>
</Card>
))}
</div>
<AlertDialog
open={Boolean(removeTarget)}
onOpenChange={(open) => {
if (workingKey !== null) return
if (!open) setRemoveTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove student from class?</AlertDialogTitle>
<AlertDialogDescription>
{removeTarget ? (
<>
This will set <span className="font-medium text-foreground">{removeTarget.name}</span> to inactive in{" "}
<span className="font-medium text-foreground">{removeTarget.className}</span>.
</>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={workingKey !== null}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={workingKey !== null}
onClick={() => {
if (!removeTarget) return
setRemoveTarget(null)
setStatus(removeTarget, "inactive")
}}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

Some files were not shown because too many files have changed in this diff Show More