Compare commits
9 Commits
13e91e628d
...
ui_opt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb4555f611 | ||
|
|
9bfc621d3f | ||
|
|
ade8d4346c | ||
|
|
8577280ab2 | ||
|
|
15fcf2bc78 | ||
|
|
15d9ea9cb8 | ||
|
|
f513cf5399 | ||
|
|
57807def37 | ||
|
|
0da2eac0b4 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
438
docs/architecture/003_frontend_engineering_standards.md
Normal file
438
docs/architecture/003_frontend_engineering_standards.md
Normal 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 + RSC(Server-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 异步组件与 Suspense(Streaming)
|
||||
|
||||
- 对于数据加载超过 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 State(nuqs 优先)
|
||||
|
||||
- 列表页筛选/分页/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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 报错。
|
||||
- 构建警告(middleware):Next.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、以及每次作业的 scoreStats:avg/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 入口。
|
||||
|
||||
@@ -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`)自动生成层级导航。
|
||||
* 解决了深层嵌套页面(如教材详情页)缺乏上下文回退路径的问题。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 值。
|
||||
|
||||
|
||||
164
docs/design/007_school_module_implementation.md
Normal file
164
docs/design/007_school_module_implementation.md
Normal 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`。
|
||||
@@ -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
107
docs/work_log.md
Normal 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).
|
||||
43
drizzle/0003_petite_newton_destine.sql
Normal file
43
drizzle/0003_petite_newton_destine.sql
Normal 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`);
|
||||
3
drizzle/0004_add_chapter_content_and_kp_chapter.sql
Normal file
3
drizzle/0004_add_chapter_content_and_kp_chapter.sql
Normal 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`);
|
||||
52
drizzle/0005_add_class_school_subject_teachers.sql
Normal file
52
drizzle/0005_add_class_school_subject_teachers.sql
Normal 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`);
|
||||
38
drizzle/0006_faithful_king_bedlam.sql
Normal file
38
drizzle/0006_faithful_king_bedlam.sql
Normal 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`);
|
||||
6
drizzle/0007_talented_bromley.sql
Normal file
6
drizzle/0007_talented_bromley.sql
Normal 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`);
|
||||
1
drizzle/0008_thin_madrox.sql
Normal file
1
drizzle/0008_thin_madrox.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `knowledge_points` ADD `anchor_text` varchar(255);
|
||||
2192
drizzle/meta/0003_snapshot.json
Normal file
2192
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2213
drizzle/meta/0004_snapshot.json
Normal file
2213
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2564
drizzle/meta/0005_snapshot.json
Normal file
2564
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2848
drizzle/meta/0006_snapshot.json
Normal file
2848
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3064
drizzle/meta/0007_snapshot.json
Normal file
3064
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3071
drizzle/meta/0008_snapshot.json
Normal file
3071
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3009
drizzle/meta/0009_snapshot.json
Normal file
3009
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
3032
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -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",
|
||||
|
||||
579
scripts/seed.ts
579
scripts/seed.ts
@@ -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`);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
18
src/app/(dashboard)/admin/school/academic-year/page.tsx
Normal file
18
src/app/(dashboard)/admin/school/academic-year/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
19
src/app/(dashboard)/admin/school/classes/page.tsx
Normal file
19
src/app/(dashboard)/admin/school/classes/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
17
src/app/(dashboard)/admin/school/departments/page.tsx
Normal file
17
src/app/(dashboard)/admin/school/departments/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
231
src/app/(dashboard)/admin/school/grades/insights/page.tsx
Normal file
231
src/app/(dashboard)/admin/school/grades/insights/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
19
src/app/(dashboard)/admin/school/grades/page.tsx
Normal file
19
src/app/(dashboard)/admin/school/grades/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
5
src/app/(dashboard)/admin/school/page.tsx
Normal file
5
src/app/(dashboard)/admin/school/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function AdminSchoolPage() {
|
||||
redirect("/admin/school/classes")
|
||||
}
|
||||
18
src/app/(dashboard)/admin/school/schools/page.tsx
Normal file
18
src/app/(dashboard)/admin/school/schools/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
31
src/app/(dashboard)/management/grade/classes/page.tsx
Normal file
31
src/app/(dashboard)/management/grade/classes/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
244
src/app/(dashboard)/management/grade/insights/page.tsx
Normal file
244
src/app/(dashboard)/management/grade/insights/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
236
src/app/(dashboard)/profile/page.tsx
Normal file
236
src/app/(dashboard)/profile/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
src/app/(dashboard)/settings/page.tsx
Normal file
27
src/app/(dashboard)/settings/page.tsx
Normal 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")
|
||||
}
|
||||
61
src/app/(dashboard)/student/dashboard/loading.tsx
Normal file
61
src/app/(dashboard)/student/dashboard/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
29
src/app/(dashboard)/student/learning/courses/loading.tsx
Normal file
29
src/app/(dashboard)/student/learning/courses/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
40
src/app/(dashboard)/student/learning/courses/page.tsx
Normal file
40
src/app/(dashboard)/student/learning/courses/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
77
src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx
Normal file
77
src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
src/app/(dashboard)/student/learning/textbooks/page.tsx
Normal file
80
src/app/(dashboard)/student/learning/textbooks/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
32
src/app/(dashboard)/student/schedule/loading.tsx
Normal file
32
src/app/(dashboard)/student/schedule/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
55
src/app/(dashboard)/student/schedule/page.tsx
Normal file
55
src/app/(dashboard)/student/schedule/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
117
src/app/(dashboard)/teacher/classes/my/[id]/page.tsx
Normal file
117
src/app/(dashboard)/teacher/classes/my/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
src/app/(dashboard)/teacher/classes/my/loading.tsx
Normal file
32
src/app/(dashboard)/teacher/classes/my/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
29
src/app/(dashboard)/teacher/classes/schedule/loading.tsx
Normal file
29
src/app/(dashboard)/teacher/classes/schedule/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
21
src/app/(dashboard)/teacher/classes/students/loading.tsx
Normal file
21
src/app/(dashboard)/teacher/classes/students/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
<Suspense fallback={<div className="h-14 w-full animate-pulse rounded-lg bg-muted" />}>
|
||||
<TextbookFilters />
|
||||
</Suspense>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
4
src/app/api/auth/[...nextauth]/route.ts
Normal file
4
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { handlers } from "@/auth"
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
|
||||
109
src/app/api/onboarding/complete/route.ts
Normal file
109
src/app/api/onboarding/complete/route.ts
Normal 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 })
|
||||
}
|
||||
|
||||
25
src/app/api/onboarding/status/route.ts
Normal file
25
src/app/api/onboarding/status/route.ts
Normal 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" })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
|
||||
@@ -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
87
src/auth.ts
Normal 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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import Link from "next/link"
|
||||
import { GraduationCap } from "lucide-react"
|
||||
|
||||
interface AuthLayoutProps {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
662
src/modules/classes/actions.ts
Normal file
662
src/modules/classes/actions.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
434
src/modules/classes/components/admin-classes-view.tsx
Normal file
434
src/modules/classes/components/admin-classes-view.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
119
src/modules/classes/components/class-detail/class-header.tsx
Normal file
119
src/modules/classes/components/class-detail/class-header.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
455
src/modules/classes/components/grade-classes-view.tsx
Normal file
455
src/modules/classes/components/grade-classes-view.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
395
src/modules/classes/components/my-classes-grid.tsx
Normal file
395
src/modules/classes/components/my-classes-grid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
195
src/modules/classes/components/schedule-filters.tsx
Normal file
195
src/modules/classes/components/schedule-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
521
src/modules/classes/components/schedule-view.tsx
Normal file
521
src/modules/classes/components/schedule-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
218
src/modules/classes/components/students-filters.tsx
Normal file
218
src/modules/classes/components/students-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
211
src/modules/classes/components/students-table.tsx
Normal file
211
src/modules/classes/components/students-table.tsx
Normal 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
Reference in New Issue
Block a user