diff --git a/.gitignore b/.gitignore index b90e61c..39b830a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,8 @@ next-env.d.ts # Local Materials .agents-feature-md + +# Document +docs \ No newline at end of file diff --git a/apps/lms/app/lms/learner/chat/[conversationId]/loading.tsx b/apps/lms/app/lms/learner/chat/[conversationId]/loading.tsx new file mode 100644 index 0000000..79d1004 --- /dev/null +++ b/apps/lms/app/lms/learner/chat/[conversationId]/loading.tsx @@ -0,0 +1,5 @@ +import { LearnerMessagesSkeleton } from "@/features/learner-messages/components/LearnerMessagesSkeleton"; + +export default function LearnerConversationLoading() { + return ; +} diff --git a/apps/lms/app/lms/learner/chat/[conversationId]/page.tsx b/apps/lms/app/lms/learner/chat/[conversationId]/page.tsx new file mode 100644 index 0000000..9e905b4 --- /dev/null +++ b/apps/lms/app/lms/learner/chat/[conversationId]/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { LearnerMessagesScreen } from "@/features/learner-messages/components/LearnerMessagesScreen"; + +export const metadata: Metadata = { + title: "Cuộc trò chuyện | BeeWise Learner", + description: "Trao đổi với gia sư theo lớp học.", +}; + +export default async function LearnerConversationPage({ + params, +}: { + params: Promise<{ conversationId: string }>; +}) { + const { conversationId } = await params; + return ; +} diff --git a/apps/lms/app/lms/learner/chat/loading.tsx b/apps/lms/app/lms/learner/chat/loading.tsx new file mode 100644 index 0000000..f648d87 --- /dev/null +++ b/apps/lms/app/lms/learner/chat/loading.tsx @@ -0,0 +1,5 @@ +import { LearnerMessagesSkeleton } from "@/features/learner-messages/components/LearnerMessagesSkeleton"; + +export default function LearnerMessagesLoading() { + return ; +} diff --git a/apps/lms/app/lms/learner/chat/page.tsx b/apps/lms/app/lms/learner/chat/page.tsx new file mode 100644 index 0000000..a0e5ae3 --- /dev/null +++ b/apps/lms/app/lms/learner/chat/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { LearnerMessagesScreen } from "@/features/learner-messages/components/LearnerMessagesScreen"; + +export const metadata: Metadata = { + title: "Tin nhắn với gia sư | BeeWise Learner", + description: "Trao đổi với gia sư theo từng lớp học.", +}; + +export default function LearnerMessagesPage() { + return ; +} diff --git a/apps/lms/app/lms/learner/exercises/[exerciseId]/loading.tsx b/apps/lms/app/lms/learner/exercises/[exerciseId]/loading.tsx new file mode 100644 index 0000000..1140a0f --- /dev/null +++ b/apps/lms/app/lms/learner/exercises/[exerciseId]/loading.tsx @@ -0,0 +1,5 @@ +import { ExerciseSkeleton } from "@/features/learner-exercises/components/ExerciseSkeleton"; + +export default function LearnerExerciseLoading() { + return ; +} diff --git a/apps/lms/app/lms/learner/exercises/[exerciseId]/page.tsx b/apps/lms/app/lms/learner/exercises/[exerciseId]/page.tsx new file mode 100644 index 0000000..405f472 --- /dev/null +++ b/apps/lms/app/lms/learner/exercises/[exerciseId]/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { ExerciseWorkspaceScreen } from "@/features/learner-exercises/components/ExerciseWorkspaceScreen"; + +export const metadata: Metadata = { + title: "Luyện tập | BeeWise Learner", + description: "Làm bài và xem lại lời giải theo từng buổi học.", +}; + +export default async function LearnerExercisePage({ + params, +}: { + params: Promise<{ exerciseId: string }>; +}) { + const { exerciseId } = await params; + return ; +} diff --git a/apps/lms/app/lms/learner/exercises/classes/[classId]/loading.tsx b/apps/lms/app/lms/learner/exercises/classes/[classId]/loading.tsx new file mode 100644 index 0000000..f18e6d4 --- /dev/null +++ b/apps/lms/app/lms/learner/exercises/classes/[classId]/loading.tsx @@ -0,0 +1,5 @@ +import { ExerciseSkeleton } from "@/features/learner-exercises/components/ExerciseSkeleton"; + +export default function LearnerExerciseSessionsLoading() { + return ; +} diff --git a/apps/lms/app/lms/learner/exercises/classes/[classId]/page.tsx b/apps/lms/app/lms/learner/exercises/classes/[classId]/page.tsx new file mode 100644 index 0000000..24abc66 --- /dev/null +++ b/apps/lms/app/lms/learner/exercises/classes/[classId]/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { ExerciseSessionSelectionScreen } from "@/features/learner-exercises/components/ExerciseSessionSelectionScreen"; + +export const metadata: Metadata = { + title: "Buổi học | BeeWise Learner", +}; + +export default async function LearnerExerciseSessionsPage({ + params, +}: { + params: Promise<{ classId: string }>; +}) { + const { classId } = await params; + return ; +} diff --git a/apps/lms/app/lms/learner/exercises/classes/[classId]/sessions/[sessionId]/loading.tsx b/apps/lms/app/lms/learner/exercises/classes/[classId]/sessions/[sessionId]/loading.tsx new file mode 100644 index 0000000..f6eb474 --- /dev/null +++ b/apps/lms/app/lms/learner/exercises/classes/[classId]/sessions/[sessionId]/loading.tsx @@ -0,0 +1,5 @@ +import { ExerciseSkeleton } from "@/features/learner-exercises/components/ExerciseSkeleton"; + +export default function LearnerSessionExercisesLoading() { + return ; +} diff --git a/apps/lms/app/lms/learner/exercises/classes/[classId]/sessions/[sessionId]/page.tsx b/apps/lms/app/lms/learner/exercises/classes/[classId]/sessions/[sessionId]/page.tsx new file mode 100644 index 0000000..3c31521 --- /dev/null +++ b/apps/lms/app/lms/learner/exercises/classes/[classId]/sessions/[sessionId]/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { ExerciseLibraryScreen } from "@/features/learner-exercises/components/ExerciseLibraryScreen"; + +export const metadata: Metadata = { + title: "Bài tập buổi học | BeeWise Learner", +}; + +export default async function LearnerSessionExercisesPage({ + params, +}: { + params: Promise<{ classId: string; sessionId: string }>; +}) { + const { classId, sessionId } = await params; + return ; +} diff --git a/apps/lms/app/lms/learner/exercises/loading.tsx b/apps/lms/app/lms/learner/exercises/loading.tsx new file mode 100644 index 0000000..452a534 --- /dev/null +++ b/apps/lms/app/lms/learner/exercises/loading.tsx @@ -0,0 +1,5 @@ +import { ExerciseSkeleton } from "@/features/learner-exercises/components/ExerciseSkeleton"; + +export default function LearnerExercisesLoading() { + return ; +} diff --git a/apps/lms/app/lms/learner/exercises/page.tsx b/apps/lms/app/lms/learner/exercises/page.tsx new file mode 100644 index 0000000..ccfb679 --- /dev/null +++ b/apps/lms/app/lms/learner/exercises/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { ExerciseClassSelectionScreen } from "@/features/learner-exercises/components/ExerciseClassSelectionScreen"; + +export const metadata: Metadata = { + title: "Làm bài tập | BeeWise Learner", + description: "Luyện tập sau buổi học với bài tập từ gia sư.", +}; + +export default function LearnerExercisesPage() { + return ; +} diff --git a/apps/lms/app/lms/learner/materials/classes/[classId]/loading.tsx b/apps/lms/app/lms/learner/materials/classes/[classId]/loading.tsx new file mode 100644 index 0000000..16ee97c --- /dev/null +++ b/apps/lms/app/lms/learner/materials/classes/[classId]/loading.tsx @@ -0,0 +1,5 @@ +import { LearnerMaterialsSkeleton } from "@/features/learner-materials/components/LearnerMaterialsSkeleton"; + +export default function LearnerClassMaterialsLoading() { + return ; +} diff --git a/apps/lms/app/lms/learner/materials/classes/[classId]/page.tsx b/apps/lms/app/lms/learner/materials/classes/[classId]/page.tsx new file mode 100644 index 0000000..bfb8b41 --- /dev/null +++ b/apps/lms/app/lms/learner/materials/classes/[classId]/page.tsx @@ -0,0 +1,10 @@ +import { ClassSessionsScreen } from "@/features/learner-materials/components/ClassSessionsScreen"; + +export default async function LearnerClassMaterialsPage({ + params, +}: { + params: Promise<{ classId: string }>; +}) { + const { classId } = await params; + return ; +} diff --git a/apps/lms/app/lms/learner/materials/classes/[classId]/sessions/[sessionId]/loading.tsx b/apps/lms/app/lms/learner/materials/classes/[classId]/sessions/[sessionId]/loading.tsx new file mode 100644 index 0000000..4287065 --- /dev/null +++ b/apps/lms/app/lms/learner/materials/classes/[classId]/sessions/[sessionId]/loading.tsx @@ -0,0 +1,5 @@ +import { LearnerMaterialsSkeleton } from "@/features/learner-materials/components/LearnerMaterialsSkeleton"; + +export default function LearnerSessionMaterialsLoading() { + return ; +} diff --git a/apps/lms/app/lms/learner/materials/classes/[classId]/sessions/[sessionId]/page.tsx b/apps/lms/app/lms/learner/materials/classes/[classId]/sessions/[sessionId]/page.tsx new file mode 100644 index 0000000..39919f5 --- /dev/null +++ b/apps/lms/app/lms/learner/materials/classes/[classId]/sessions/[sessionId]/page.tsx @@ -0,0 +1,10 @@ +import { SessionMaterialsScreen } from "@/features/learner-materials/components/SessionMaterialsScreen"; + +export default async function LearnerSessionMaterialsPage({ + params, +}: { + params: Promise<{ classId: string; sessionId: string }>; +}) { + const { classId, sessionId } = await params; + return ; +} diff --git a/apps/lms/app/lms/learner/materials/loading.tsx b/apps/lms/app/lms/learner/materials/loading.tsx new file mode 100644 index 0000000..8d334e4 --- /dev/null +++ b/apps/lms/app/lms/learner/materials/loading.tsx @@ -0,0 +1,5 @@ +import { LearnerMaterialsSkeleton } from "@/features/learner-materials/components/LearnerMaterialsSkeleton"; + +export default function LearnerMaterialsLoading() { + return ; +} diff --git a/apps/lms/app/lms/learner/materials/page.tsx b/apps/lms/app/lms/learner/materials/page.tsx new file mode 100644 index 0000000..8b68539 --- /dev/null +++ b/apps/lms/app/lms/learner/materials/page.tsx @@ -0,0 +1,9 @@ +import { ClassLibraryScreen } from "@/features/learner-materials/components/ClassLibraryScreen"; + +export const metadata = { + title: "Kho tài liệu | BeeWise Learner", +}; + +export default function LearnerMaterialsPage() { + return ; +} diff --git a/apps/lms/app/lms/learner/schedule/loading.tsx b/apps/lms/app/lms/learner/schedule/loading.tsx new file mode 100644 index 0000000..c96d22b --- /dev/null +++ b/apps/lms/app/lms/learner/schedule/loading.tsx @@ -0,0 +1,15 @@ +function Skeleton({ className }: { className: string }) { + return
; +} + +export default function LearnerScheduleLoading() { + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/apps/lms/app/lms/learner/schedule/page.tsx b/apps/lms/app/lms/learner/schedule/page.tsx new file mode 100644 index 0000000..31ea4cf --- /dev/null +++ b/apps/lms/app/lms/learner/schedule/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import { LearnerScheduleScreen } from "@/features/learner-schedule/components/LearnerScheduleScreen"; + +export const metadata: Metadata = { + title: "Lịch học của tôi | BeeWise LMS", + description: "Theo dõi lịch học và các buổi học sắp tới trên BeeWise LMS.", +}; + +export default function LearnerSchedulePage() { + return ; +} + diff --git a/apps/lms/app/lms/learner/tuition-fee/[classId]/loading.tsx b/apps/lms/app/lms/learner/tuition-fee/[classId]/loading.tsx new file mode 100644 index 0000000..b2696fe --- /dev/null +++ b/apps/lms/app/lms/learner/tuition-fee/[classId]/loading.tsx @@ -0,0 +1,5 @@ +import { TuitionFeeSkeleton } from "@/features/learner-tuition-fee/components/TuitionFeeSkeleton"; + +export default function LearnerTuitionClassLoading() { + return ; +} diff --git a/apps/lms/app/lms/learner/tuition-fee/[classId]/page.tsx b/apps/lms/app/lms/learner/tuition-fee/[classId]/page.tsx new file mode 100644 index 0000000..c78ac52 --- /dev/null +++ b/apps/lms/app/lms/learner/tuition-fee/[classId]/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { TuitionClassDetailScreen } from "@/features/learner-tuition-fee/components/TuitionClassDetailScreen"; + +export const metadata: Metadata = { + title: "Chi tiết học phí | BeeWise Learner", + description: "Theo dõi phân bổ học phí theo từng buổi học.", +}; + +export default async function LearnerTuitionClassPage({ + params, +}: { + params: Promise<{ classId: string }>; +}) { + const { classId } = await params; + return ; +} diff --git a/apps/lms/app/lms/learner/tuition-fee/loading.tsx b/apps/lms/app/lms/learner/tuition-fee/loading.tsx new file mode 100644 index 0000000..c0b7062 --- /dev/null +++ b/apps/lms/app/lms/learner/tuition-fee/loading.tsx @@ -0,0 +1,5 @@ +import { TuitionFeeSkeleton } from "@/features/learner-tuition-fee/components/TuitionFeeSkeleton"; + +export default function LearnerTuitionFeeLoading() { + return ; +} diff --git a/apps/lms/app/lms/learner/tuition-fee/page.tsx b/apps/lms/app/lms/learner/tuition-fee/page.tsx new file mode 100644 index 0000000..775607e --- /dev/null +++ b/apps/lms/app/lms/learner/tuition-fee/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { TuitionFeeScreen } from "@/features/learner-tuition-fee/components/TuitionFeeScreen"; + +export const metadata: Metadata = { + title: "Theo dõi học phí | BeeWise Learner", + description: "Theo dõi học phí đã thanh toán theo lớp học.", +}; + +export default function LearnerTuitionFeePage() { + return ; +} diff --git a/apps/lms/app/lms/tutor/earnings/loading.tsx b/apps/lms/app/lms/tutor/earnings/loading.tsx new file mode 100644 index 0000000..1129c38 --- /dev/null +++ b/apps/lms/app/lms/tutor/earnings/loading.tsx @@ -0,0 +1,33 @@ +function SkeletonBlock({ className }: { className: string }) { + return
; +} + +export default function EarningsLoading() { + return ( +
+
+
+ + + +
+
+ {[0, 1, 2, 3].map((item) => ( +
+ + + +
+ ))} +
+
+ +
+
+ + +
+
+
+ ); +} diff --git a/apps/lms/app/lms/tutor/earnings/page.tsx b/apps/lms/app/lms/tutor/earnings/page.tsx new file mode 100644 index 0000000..29d5062 --- /dev/null +++ b/apps/lms/app/lms/tutor/earnings/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import { EarningsDashboard } from "@/features/tutor-earnings/components/EarningsDashboard"; + +export const metadata: Metadata = { + title: "Thu nhập & Thanh toán | BeeWise LMS", + description: "Theo dõi thu nhập và trạng thái quyết toán dành cho gia sư BeeWise.", +}; + +export default function TutorEarningsPage() { + return ; +} + diff --git a/apps/lms/app/lms/tutor/materials/[lessonId]/preview/page.tsx b/apps/lms/app/lms/tutor/materials/[lessonId]/preview/page.tsx index 7c6c2dd..b580e0d 100644 --- a/apps/lms/app/lms/tutor/materials/[lessonId]/preview/page.tsx +++ b/apps/lms/app/lms/tutor/materials/[lessonId]/preview/page.tsx @@ -5,20 +5,10 @@ export const metadata = { title: "Bản xem trước tài liệu | BeeWise Tutor", }; -// Next.js App Router dynamic route props -interface PageProps { - params: Promise<{ lessonId: string }>; -} - -export default async function PreviewPage({ params }: PageProps) { - const resolvedParams = await params; - +export default function PreviewPage() { return (
- +
); } diff --git a/apps/lms/app/lms/tutor/materials/learner/[learnerId]/loading.tsx b/apps/lms/app/lms/tutor/materials/learner/[learnerId]/loading.tsx new file mode 100644 index 0000000..f9a36da --- /dev/null +++ b/apps/lms/app/lms/tutor/materials/learner/[learnerId]/loading.tsx @@ -0,0 +1,15 @@ +function Skeleton({ className }: { className: string }) { + return
; +} + +export default function LearnerMaterialsLoading() { + return ( +
+
+ + +
+
+
+ ); +} diff --git a/apps/lms/app/lms/tutor/materials/learner/[learnerId]/page.tsx b/apps/lms/app/lms/tutor/materials/learner/[learnerId]/page.tsx new file mode 100644 index 0000000..e5be046 --- /dev/null +++ b/apps/lms/app/lms/tutor/materials/learner/[learnerId]/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { LearnerMaterialsScreen } from "@/features/tutor-materials/components/LearnerMaterialsScreen"; + +export const metadata: Metadata = { + title: "Thư viện học viên | BeeWise Tutor", +}; + +interface PageProps { + params: Promise<{ learnerId: string }>; +} + +export default async function LearnerMaterialsPage({ params }: PageProps) { + const { learnerId } = await params; + return ; +} + diff --git a/apps/lms/app/lms/tutor/materials/loading.tsx b/apps/lms/app/lms/tutor/materials/loading.tsx new file mode 100644 index 0000000..5a123ea --- /dev/null +++ b/apps/lms/app/lms/tutor/materials/loading.tsx @@ -0,0 +1,18 @@ +function Skeleton({ className }: { className: string }) { + return
; +} + +export default function MaterialsLoading() { + return ( +
+
+
+ +
+ +
{[0, 1, 2, 3].map((item) => )}
+
+
+
+ ); +} diff --git a/apps/lms/app/lms/tutor/materials/page.tsx b/apps/lms/app/lms/tutor/materials/page.tsx index 5a2fa41..cd89cbe 100644 --- a/apps/lms/app/lms/tutor/materials/page.tsx +++ b/apps/lms/app/lms/tutor/materials/page.tsx @@ -1,14 +1,9 @@ -import React from "react"; -import { MaterialsTable } from "@/features/tutor-materials/components/MaterialsTable"; +import { TutorMaterialsScreen } from "@/features/tutor-materials/components/TutorMaterialsScreen"; export const metadata = { title: "Quản lý tài liệu | BeeWise Tutor", }; export default function TutorMaterialsPage() { - return ( -
- -
- ); + return ; } diff --git a/apps/lms/features/auth/components/FormField.tsx b/apps/lms/features/auth/components/FormField.tsx index e160d49..0da8757 100644 --- a/apps/lms/features/auth/components/FormField.tsx +++ b/apps/lms/features/auth/components/FormField.tsx @@ -1,6 +1,5 @@ "use client"; -import { useId } from "react"; import type { FieldError } from "react-hook-form"; interface FormFieldProps { diff --git a/apps/lms/features/auth/components/LoginForm.tsx b/apps/lms/features/auth/components/LoginForm.tsx index 939dcf6..dd1cae9 100644 --- a/apps/lms/features/auth/components/LoginForm.tsx +++ b/apps/lms/features/auth/components/LoginForm.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import Image from "next/image"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Eye, EyeSlash, ArrowRight } from "@phosphor-icons/react"; +import { Eye, EyeSlash } from "@phosphor-icons/react"; import { loginSchema, type LoginFormValues } from "../schemas/auth.schema"; import { FormField, Input } from "./FormField"; import { useLogin } from "@workspace/core/hooks/useLogin"; diff --git a/apps/lms/features/learner-exercises/components/ExerciseBadges.tsx b/apps/lms/features/learner-exercises/components/ExerciseBadges.tsx new file mode 100644 index 0000000..8d9d6fe --- /dev/null +++ b/apps/lms/features/learner-exercises/components/ExerciseBadges.tsx @@ -0,0 +1,30 @@ +import type { + ExerciseDifficulty, + ExerciseSource, + ExerciseStatus, +} from "../types/learner-exercises.types"; + +const STATUS_LABELS: Record = { + not_started: "Chưa làm", + in_progress: "Đang làm", + submitted: "Đã nộp", + reviewed: "Đã chấm", + overdue: "Quá hạn", +}; + +const DIFFICULTY_LABELS: Record = { + easy: "Cơ bản", + medium: "Vừa sức", + hard: "Thử thách", +}; + +export function ExerciseBadges({ source, status, difficulty }: { source: ExerciseSource; status: ExerciseStatus; difficulty: ExerciseDifficulty }) { + const statusStyle = status === "reviewed" || status === "submitted" + ? "bg-[#DDF1E5] text-[#365D43]" + : status === "overdue" + ? "bg-[#F7E5E3] text-[#8A3730]" + : status === "in_progress" + ? "bg-[#FFF3CB] text-[#805512]" + : "bg-slate-100 text-slate-600"; + return
{source === "ai" ? "Tạo bằng AI" : "Gia sư tải lên"}{DIFFICULTY_LABELS[difficulty]}{STATUS_LABELS[status]}
; +} diff --git a/apps/lms/features/learner-exercises/components/ExerciseClassList.tsx b/apps/lms/features/learner-exercises/components/ExerciseClassList.tsx new file mode 100644 index 0000000..39e4dd6 --- /dev/null +++ b/apps/lms/features/learner-exercises/components/ExerciseClassList.tsx @@ -0,0 +1,75 @@ +import Link from "next/link"; +import { ArrowRight, Books } from "@phosphor-icons/react"; +import type { LearnerExerciseClassStatus, LearnerExerciseClassSummary } from "../types/learner-exercises.types"; +import { formatExerciseDay } from "../utils/learner-exercises.utils"; + +const CLASS_STATUS: Record = { + active: { label: "Đang học", className: "bg-[#DDF1E5] text-[#365D43]" }, + paused: { label: "Tạm dừng", className: "bg-[#FFF3CB] text-[#805512]" }, + completed: { label: "Đã hoàn thành", className: "bg-slate-100 text-slate-600" }, +}; + +export function ExerciseClassList({ classes }: { classes: LearnerExerciseClassSummary[] }) { + if (classes.length === 0) { + return ; + } + + return ( +
+
+

Lớp học của bạn

+

Chọn lớp để tiếp tục đến danh sách buổi học.

+
+
+ {classes.map((summary) => )} +
+
+ ); +} + +function ExerciseClassCard({ summary }: { summary: LearnerExerciseClassSummary }) { + const classInfo = summary.classInfo; + const status = CLASS_STATUS[classInfo.status]; + + return ( +
+
+ {classInfo.tutorInitials} + {status.label} +
+ +

Mã lớp: {classInfo.classCode}

+

{classInfo.name}

+

{classInfo.tutorName}

+ +
+ + + + +
+ +
+ + + 0} /> +
+ + + Xem buổi học + +
+ ); +} + +function Info({ label, value, wide = false }: { label: string; value: string; wide?: boolean }) { + return
{label}
{value}
; +} + +function Metric({ label, value, accent = false }: { label: string; value: number; accent?: boolean }) { + return
{label}
{value}
; +} + +function EmptyState() { + return

Không tìm thấy lớp học

Hãy thay đổi từ khóa hoặc môn học đang lọc.

; +} diff --git a/apps/lms/features/learner-exercises/components/ExerciseClassSelectionScreen.tsx b/apps/lms/features/learner-exercises/components/ExerciseClassSelectionScreen.tsx new file mode 100644 index 0000000..b16d6a0 --- /dev/null +++ b/apps/lms/features/learner-exercises/components/ExerciseClassSelectionScreen.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { MagnifyingGlass } from "@phosphor-icons/react"; +import { useExerciseClasses } from "../hooks/useLearnerExercises"; +import { ExerciseClassList } from "./ExerciseClassList"; + +export function ExerciseClassSelectionScreen() { + const library = useExerciseClasses(); + + return ( +
+
+
+

Làm bài tập

+

Chọn lớp học để xem bài tập được giao theo từng buổi.

+
+ +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/apps/lms/features/learner-exercises/components/ExerciseLibraryScreen.tsx b/apps/lms/features/learner-exercises/components/ExerciseLibraryScreen.tsx new file mode 100644 index 0000000..c100231 --- /dev/null +++ b/apps/lms/features/learner-exercises/components/ExerciseLibraryScreen.tsx @@ -0,0 +1,54 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, MagnifyingGlass, WarningCircle } from "@phosphor-icons/react"; +import { useSessionExercises } from "../hooks/useLearnerExercises"; +import type { + ExerciseSource, + ExerciseStatusFilter, +} from "../types/learner-exercises.types"; +import { formatExerciseDate } from "../utils/learner-exercises.utils"; +import { ExerciseList } from "./ExerciseList"; + +export function ExerciseLibraryScreen({ classId, sessionId }: { classId: string; sessionId: string }) { + const library = useSessionExercises(classId, sessionId); + + if (!library.classInfo || !library.session) { + return ; + } + + return ( +
+
+ Danh sách buổi học +
+

{library.classInfo.name} | Buổi {library.session.sequence}

+

{library.session.topic}

+

{formatExerciseDate(library.session.taughtAt)} | {library.session.durationMinutes} phút | {library.classInfo.tutorName}

+
+ +
+
+ + library.setStatus(value as ExerciseStatusFilter)} options={[{ value: "all", label: "Mọi trạng thái" }, { value: "not_started", label: "Chưa làm" }, { value: "in_progress", label: "Đang làm" }, { value: "submitted", label: "Đã nộp" }, { value: "reviewed", label: "Đã chấm" }, { value: "overdue", label: "Quá hạn" }]} /> + library.setSource(value as "all" | ExerciseSource)} options={[{ value: "all", label: "Mọi nguồn" }, { value: "ai", label: "Tạo bằng AI" }, { value: "upload", label: "Gia sư tải lên" }]} /> +
+
+ + +
+
+ ); +} + +function FilterSelect({ label, value, options, onChange }: { label: string; value: string; options: { value: string; label: string }[]; onChange: (value: string) => void }) { + return ; +} + +function MissingState({ classId }: { classId: string }) { + return

Không tìm thấy buổi học

Quay lại danh sách buổi học
; +} diff --git a/apps/lms/features/learner-exercises/components/ExerciseList.tsx b/apps/lms/features/learner-exercises/components/ExerciseList.tsx new file mode 100644 index 0000000..fa1a0fe --- /dev/null +++ b/apps/lms/features/learner-exercises/components/ExerciseList.tsx @@ -0,0 +1,34 @@ +import Link from "next/link"; +import { ArrowRight, Clock, Notebook } from "@phosphor-icons/react"; +import type { LearnerExercise } from "../types/learner-exercises.types"; +import { formatExerciseDate } from "../utils/learner-exercises.utils"; +import { ExerciseBadges } from "./ExerciseBadges"; + +export function ExerciseList({ exercises }: { exercises: LearnerExercise[] }) { + if (exercises.length === 0) { + return

Không có bài tập phù hợp

Hãy thử thay đổi từ khóa hoặc bộ lọc.

; + } + + return ( +
+

Bài tập của buổi học

Bài tập được tạo hoặc chia sẻ từ tài liệu của gia sư.

+
+ {exercises.map((exercise, index) => { + const highlighted = index === 0 && (exercise.status === "not_started" || exercise.status === "in_progress"); + const actionLabel = exercise.status === "reviewed" ? "Xem kết quả" : exercise.status === "submitted" ? "Xem bài đã nộp" : exercise.status === "in_progress" ? "Tiếp tục làm" : exercise.status === "overdue" ? "Xem bài tập" : "Bắt đầu làm"; + return
+ +

{exercise.title}

+

{exercise.description}

+
+
Hạn hoàn thành
{formatExerciseDate(exercise.dueAt)}
+
Thời gian
{exercise.estimatedMinutes} phút
+ {exercise.score !== undefined ?
Kết quả gần nhất
{exercise.score}/10 điểm
: null} +
+ {actionLabel} +
; + })} +
+
+ ); +} diff --git a/apps/lms/features/learner-exercises/components/ExerciseQuizWorkspace.tsx b/apps/lms/features/learner-exercises/components/ExerciseQuizWorkspace.tsx new file mode 100644 index 0000000..52a533b --- /dev/null +++ b/apps/lms/features/learner-exercises/components/ExerciseQuizWorkspace.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useState } from "react"; +import { ArrowLeft, ArrowRight, Flag } from "@phosphor-icons/react"; +import type { + ExerciseQuestion, + LearnerExercise, +} from "../types/learner-exercises.types"; +import { ExerciseWorkspaceHeader } from "./ExerciseWorkspaceHeader"; +import { QuestionNavigator } from "./QuestionNavigator"; +import { SubmitExerciseDialog } from "./SubmitExerciseDialog"; + +interface ExerciseQuizWorkspaceProps { + exercise: LearnerExercise; + answers: Record; + flaggedQuestionIds: Set; + currentQuestionIndex: number; + currentQuestion: ExerciseQuestion; + answeredCount: number; + onAnswer: (questionId: string, answer: string) => void; + onToggleFlag: (questionId: string) => void; + onQuestionChange: (index: number) => void; + onSubmit: () => void; +} + +export function ExerciseQuizWorkspace(props: ExerciseQuizWorkspaceProps) { + const [confirmOpen, setConfirmOpen] = useState(false); + const unansweredCount = props.exercise.questions.length - props.answeredCount; + const isFlagged = props.flaggedQuestionIds.has(props.currentQuestion.id); + return
+ +
+
+
+

Câu {props.currentQuestionIndex + 1} / {props.exercise.questions.length}

{props.currentQuestion.prompt}

{props.currentQuestion.points} điểm

+ props.onAnswer(props.currentQuestion.id, answer)} /> +
+
{props.currentQuestionIndex < props.exercise.questions.length - 1 ? : }
+
+
+
+
0 ? `Bạn còn ${unansweredCount} câu chưa trả lời. Bạn vẫn có thể nộp bài hoặc quay lại kiểm tra.` : "Bạn đã trả lời tất cả câu hỏi. Hãy nộp bài để xem kết quả và lời giải."} onConfirm={props.onSubmit} />
; +} + +function QuestionAnswer({ question, answer, onChange }: { question: ExerciseQuestion; answer: string; onChange: (answer: string) => void }) { + if (question.type === "short_answer") return ; + return
Chọn một đáp án{question.options?.map((option) => )}
; +} diff --git a/apps/lms/features/learner-exercises/components/ExerciseResultScreen.tsx b/apps/lms/features/learner-exercises/components/ExerciseResultScreen.tsx new file mode 100644 index 0000000..fd41fa7 --- /dev/null +++ b/apps/lms/features/learner-exercises/components/ExerciseResultScreen.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import { ArrowCounterClockwise, ArrowLeft, CheckCircle, XCircle } from "@phosphor-icons/react"; +import type { + ExerciseQuestion, + ExerciseResultSummary, + LearnerExercise, +} from "../types/learner-exercises.types"; +import { getExerciseSessionHref, normalizeExerciseAnswer } from "../utils/learner-exercises.utils"; + +export function ExerciseResultScreen({ exercise, answers, result, onRetry }: { exercise: LearnerExercise; answers: Record; result: ExerciseResultSummary; onRetry: () => void }) { + const message = result.percentage >= 80 ? "Bạn nắm bài khá chắc rồi" : result.percentage >= 60 ? "Bạn đang đi đúng hướng" : "Mình cùng ôn lại một chút nhé"; + return
+ Bài tập của buổi học +

{message}

{exercise.title}

{result.earnedPoints}/{result.totalPoints}

{result.correctCount}/{result.questionCount} câu đúng

Về bài tập của buổi học
+

Xem lại đáp án

{exercise.questions.map((question, index) => )}
+
; +} + +function AnswerReview({ question, answer, index }: { question: ExerciseQuestion; answer: string; index: number }) { + const correct = normalizeExerciseAnswer(answer) === normalizeExerciseAnswer(question.correctAnswer); + const optionLabel = (value: string) => question.options?.find((option) => option.id === value)?.label ?? value; + return
{correct ? : }

Câu {index + 1}: {question.prompt}

Bạn trả lời
{answer ? optionLabel(answer) : "Chưa trả lời"}
{!correct ?
Đáp án đúng
{optionLabel(question.correctAnswer)}
: null}

{question.explanation}

; +} diff --git a/apps/lms/features/learner-exercises/components/ExerciseSessionList.tsx b/apps/lms/features/learner-exercises/components/ExerciseSessionList.tsx new file mode 100644 index 0000000..2b50629 --- /dev/null +++ b/apps/lms/features/learner-exercises/components/ExerciseSessionList.tsx @@ -0,0 +1,45 @@ +import Link from "next/link"; +import { ArrowRight, CalendarBlank } from "@phosphor-icons/react"; +import { LEARNER_EXERCISES } from "../data/learner-exercises.mock"; +import type { LearnerExerciseSession } from "../types/learner-exercises.types"; +import { formatExerciseDate } from "../utils/learner-exercises.utils"; + +export function ExerciseSessionList({ classId, sessions }: { classId: string; sessions: LearnerExerciseSession[] }) { + if (sessions.length === 0) { + return

Không có buổi học phù hợp

Hãy thay đổi bộ lọc để xem các buổi học khác.

; + } + + return ( +
+
+

Danh sách buổi học

+

Chọn buổi học để xem bài tập gia sư đã giao.

+
+
+ {sessions.map((session) => { + const exercises = LEARNER_EXERCISES.filter((exercise) => exercise.sessionId === session.id); + const pendingCount = exercises.filter((exercise) => exercise.status === "not_started" || exercise.status === "in_progress").length; + const hasExercises = exercises.length > 0; + return ( +
+
+
+

Buổi {session.sequence}

+

{session.topic}

+

{formatExerciseDate(session.taughtAt)} | {session.durationMinutes} phút

+
+
+
+

{hasExercises ? `${exercises.length} bài tập` : "Chưa có bài tập"}

+ {pendingCount > 0 ?

{pendingCount} bài cần hoàn thành

: null} +
+ {hasExercises ? Xem bài tập : } +
+
+
+ ); + })} +
+
+ ); +} diff --git a/apps/lms/features/learner-exercises/components/ExerciseSessionSelectionScreen.tsx b/apps/lms/features/learner-exercises/components/ExerciseSessionSelectionScreen.tsx new file mode 100644 index 0000000..de067ea --- /dev/null +++ b/apps/lms/features/learner-exercises/components/ExerciseSessionSelectionScreen.tsx @@ -0,0 +1,55 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, MagnifyingGlass, WarningCircle } from "@phosphor-icons/react"; +import { useExerciseSessions } from "../hooks/useLearnerExercises"; +import type { ExerciseAvailabilityFilter } from "../types/learner-exercises.types"; +import { ExerciseSessionList } from "./ExerciseSessionList"; + +export function ExerciseSessionSelectionScreen({ classId }: { classId: string }) { + const library = useExerciseSessions(classId); + + if (!library.classInfo) { + return ; + } + + return ( +
+
+ Danh sách lớp +
+ {library.classInfo.tutorInitials} +
+

{library.classInfo.subject}

+

{library.classInfo.name}

+

{library.classInfo.tutorName} | {library.classInfo.scheduleLabel}

+
+
+ +
+
+ + +
+
+ + +
+
+ ); +} + +function MissingState() { + return

Không tìm thấy lớp học

Quay lại danh sách lớp
; +} diff --git a/apps/lms/features/learner-exercises/components/ExerciseSkeleton.tsx b/apps/lms/features/learner-exercises/components/ExerciseSkeleton.tsx new file mode 100644 index 0000000..419adf4 --- /dev/null +++ b/apps/lms/features/learner-exercises/components/ExerciseSkeleton.tsx @@ -0,0 +1,7 @@ +function Skeleton({ className }: { className: string }) { + return
; +} + +export function ExerciseSkeleton() { + return
; +} diff --git a/apps/lms/features/learner-exercises/components/ExerciseWorkspaceHeader.tsx b/apps/lms/features/learner-exercises/components/ExerciseWorkspaceHeader.tsx new file mode 100644 index 0000000..5acec96 --- /dev/null +++ b/apps/lms/features/learner-exercises/components/ExerciseWorkspaceHeader.tsx @@ -0,0 +1,9 @@ +import Link from "next/link"; +import { ArrowLeft, Info } from "@phosphor-icons/react"; +import type { LearnerExercise } from "../types/learner-exercises.types"; +import { formatExerciseDate, getExerciseSessionHref } from "../utils/learner-exercises.utils"; +import { ExerciseBadges } from "./ExerciseBadges"; + +export function ExerciseWorkspaceHeader({ exercise, helperText }: { exercise: LearnerExercise; helperText: string }) { + return

{exercise.title}

{exercise.className} | {exercise.lessonTopic}

Thông tin bài tập

{exercise.description}

Gia sư
{exercise.tutorName}
Hạn hoàn thành
{formatExerciseDate(exercise.dueAt)}
Thời gian dự kiến
{exercise.estimatedMinutes} phút

{helperText}

; +} diff --git a/apps/lms/features/learner-exercises/components/ExerciseWorkspaceScreen.tsx b/apps/lms/features/learner-exercises/components/ExerciseWorkspaceScreen.tsx new file mode 100644 index 0000000..b113593 --- /dev/null +++ b/apps/lms/features/learner-exercises/components/ExerciseWorkspaceScreen.tsx @@ -0,0 +1,21 @@ +"use client"; + +import Link from "next/link"; +import { WarningCircle } from "@phosphor-icons/react"; +import { useExerciseAttempt } from "../hooks/useExerciseAttempt"; +import { ExerciseQuizWorkspace } from "./ExerciseQuizWorkspace"; +import { ExerciseResultScreen } from "./ExerciseResultScreen"; +import { FileExerciseWorkspace } from "./FileExerciseWorkspace"; + +export function ExerciseWorkspaceScreen({ exerciseId }: { exerciseId: string }) { + const attempt = useExerciseAttempt(exerciseId); + if (!attempt.exercise) return ; + if (attempt.exercise.mode === "file_submission") return ; + if (attempt.showResult && attempt.result) return ; + if (!attempt.currentQuestion) return ; + return ; +} + +function MissingExercise() { + return

Không tìm thấy bài tập

Quay lại danh sách
; +} diff --git a/apps/lms/features/learner-exercises/components/FileExerciseWorkspace.tsx b/apps/lms/features/learner-exercises/components/FileExerciseWorkspace.tsx new file mode 100644 index 0000000..0354176 --- /dev/null +++ b/apps/lms/features/learner-exercises/components/FileExerciseWorkspace.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useState, type ChangeEvent } from "react"; +import { DownloadSimple, FileArrowUp, CheckCircle } from "@phosphor-icons/react"; +import { toast } from "@workspace/ui/components/ui/bee-toast"; +import type { LearnerExercise } from "../types/learner-exercises.types"; +import { downloadMockExercise } from "../utils/learner-exercises.utils"; +import { ExerciseWorkspaceHeader } from "./ExerciseWorkspaceHeader"; +import { SubmitExerciseDialog } from "./SubmitExerciseDialog"; + +export function FileExerciseWorkspace({ exercise, submittedFileName, fileSubmitted, onSubmit }: { exercise: LearnerExercise; submittedFileName?: string; fileSubmitted: boolean; onSubmit: (fileName: string) => void }) { + const [selectedFile, setSelectedFile] = useState(); + const [error, setError] = useState(""); + const [confirmOpen, setConfirmOpen] = useState(false); + const overdue = exercise.status === "overdue"; + + function handleFileChange(event: ChangeEvent) { + const file = event.target.files?.[0]; + setError(""); + if (!file) return; + if (file.size > 10 * 1024 * 1024) { setError("Tệp bài làm không được vượt quá 10 MB."); event.target.value = ""; return; } + setSelectedFile(file); + } + + function handleDownload() { + downloadMockExercise(exercise); + toast.success("Đã tải đề bài mock", { description: exercise.attachmentName }); + } + + return
+ +
+

Đề bài từ gia sư

{exercise.attachmentName}

Tài liệu gắn với buổi học: {exercise.lessonTopic}

+ {fileSubmitted ?

Bài làm đã được nộp

{submittedFileName ?? "Tệp bài làm"}

Gia sư sẽ nhận xét sau khi xem bài. Bạn vẫn có thể tải đề để luyện lại.

: overdue ?

Đã hết hạn nộp bài

Hãy nhắn cho gia sư nếu bạn cần được mở lại thời hạn.

:

Nộp bài làm

{selectedFile ?

Đã chọn: {selectedFile.name}

: null}{error ?

{error}

: null}
} +
+
selectedFile && onSubmit(selectedFile.name)} />
; +} diff --git a/apps/lms/features/learner-exercises/components/QuestionNavigator.tsx b/apps/lms/features/learner-exercises/components/QuestionNavigator.tsx new file mode 100644 index 0000000..270b133 --- /dev/null +++ b/apps/lms/features/learner-exercises/components/QuestionNavigator.tsx @@ -0,0 +1,6 @@ +import { Flag } from "@phosphor-icons/react"; +import type { ExerciseQuestion } from "../types/learner-exercises.types"; + +export function QuestionNavigator({ questions, answers, flaggedQuestionIds, currentIndex, onSelect }: { questions: ExerciseQuestion[]; answers: Record; flaggedQuestionIds: Set; currentIndex: number; onSelect: (index: number) => void }) { + return ; +} diff --git a/apps/lms/features/learner-exercises/components/SubmitExerciseDialog.tsx b/apps/lms/features/learner-exercises/components/SubmitExerciseDialog.tsx new file mode 100644 index 0000000..ae8a40b --- /dev/null +++ b/apps/lms/features/learner-exercises/components/SubmitExerciseDialog.tsx @@ -0,0 +1,12 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/ui/dialog"; + +export function SubmitExerciseDialog({ open, title, description, confirmLabel = "Nộp bài", onOpenChange, onConfirm }: { open: boolean; title: string; description: string; confirmLabel?: string; onOpenChange: (open: boolean) => void; onConfirm: () => void }) { + return {title}{description}; +} diff --git a/apps/lms/features/learner-exercises/data/learner-exercises.mock.ts b/apps/lms/features/learner-exercises/data/learner-exercises.mock.ts new file mode 100644 index 0000000..fa583c5 --- /dev/null +++ b/apps/lms/features/learner-exercises/data/learner-exercises.mock.ts @@ -0,0 +1,152 @@ +import type { + ExerciseAttempt, + LearnerExerciseClass, + LearnerExerciseSession, + LearnerExercise, +} from "../types/learner-exercises.types"; + +export const LEARNER_EXERCISE_CLASSES: LearnerExerciseClass[] = [ + { id: "class-math-10", classCode: "BW-MATH-1042", name: "Toán 10 - Nền tảng", subject: "Toán", level: "Lớp 10", tutorName: "Cô Nguyễn Thu Hà", tutorInitials: "TH", scheduleLabel: "Thứ Hai và Thứ Bảy", startedAt: "2026-06-02T09:00:00+07:00", status: "active" }, + { id: "class-physics-10", classCode: "BW-PHY-0821", name: "Vật lý 10 - Chuyển động", subject: "Vật lý", level: "Lớp 10", tutorName: "Thầy Lê Minh Đức", tutorInitials: "MĐ", scheduleLabel: "Thứ Ba hằng tuần", startedAt: "2026-07-05T09:00:00+07:00", status: "active" }, + { id: "class-ielts-65", classCode: "BW-ENG-0635", name: "IELTS 6.5 - Writing", subject: "Tiếng Anh", level: "IELTS 6.5", tutorName: "Cô Phạm Anh Thư", tutorInitials: "AT", scheduleLabel: "Thứ Năm và Chủ Nhật", startedAt: "2026-05-18T09:00:00+07:00", status: "active" }, + { id: "class-literature-10", classCode: "BW-LIT-1014", name: "Ngữ văn 10 - Đọc hiểu", subject: "Ngữ văn", level: "Lớp 10", tutorName: "Cô Vũ Mai Lan", tutorInitials: "ML", scheduleLabel: "Thứ Tư hằng tuần", startedAt: "2026-04-12T09:00:00+07:00", status: "active" }, +]; + +export const LEARNER_EXERCISE_SESSIONS: LearnerExerciseSession[] = [ + { id: "math-session-01", classId: "class-math-10", sequence: 12, topic: "Hệ phương trình bậc nhất", taughtAt: "2026-08-20T18:00:00+07:00", durationMinutes: 90 }, + { id: "math-session-02", classId: "class-math-10", sequence: 13, topic: "Bất phương trình bậc hai", taughtAt: "2026-08-24T18:00:00+07:00", durationMinutes: 90 }, + { id: "math-session-03", classId: "class-math-10", sequence: 14, topic: "Dấu của tam thức bậc hai", taughtAt: "2026-08-29T09:00:00+07:00", durationMinutes: 90 }, + { id: "physics-session-01", classId: "class-physics-10", sequence: 7, topic: "Đồ thị chuyển động", taughtAt: "2026-08-18T19:30:00+07:00", durationMinutes: 90 }, + { id: "physics-session-02", classId: "class-physics-10", sequence: 8, topic: "Chuyển động thẳng biến đổi đều", taughtAt: "2026-08-25T19:30:00+07:00", durationMinutes: 90 }, + { id: "english-session-03", classId: "class-ielts-65", sequence: 17, topic: "Writing Task 1 - Bar chart", taughtAt: "2026-08-10T20:00:00+07:00", durationMinutes: 90 }, + { id: "literature-session-04", classId: "class-literature-10", sequence: 4, topic: "Biện pháp tu từ", taughtAt: "2026-05-01T08:00:00+07:00", durationMinutes: 90 }, +]; + +export const LEARNER_EXERCISES: LearnerExercise[] = [ + { + id: "exercise-math-quadratic", + materialId: "learner-material-03", + sessionId: "math-session-02", + classId: "class-math-10", + className: "Toán 10 - Nền tảng", + subject: "Toán", + lessonTopic: "Bất phương trình bậc hai", + tutorName: "Cô Nguyễn Thu Hà", + title: "Luyện tập bất phương trình bậc hai", + description: "Ôn bảng xét dấu và các trường hợp nghiệm thường gặp.", + source: "ai", + mode: "interactive", + difficulty: "medium", + status: "not_started", + assignedAt: "2026-08-24T20:10:00+07:00", + dueAt: "2026-08-27T22:00:00+07:00", + estimatedMinutes: 18, + questions: [ + { id: "math-q1", type: "multiple_choice", prompt: "Tam thức f(x) = x² - 5x + 6 có hai nghiệm là cặp nào?", options: [{ id: "a", label: "1 và 6" }, { id: "b", label: "2 và 3" }, { id: "c", label: "-2 và -3" }, { id: "d", label: "-1 và -6" }], correctAnswer: "b", explanation: "Ta có x² - 5x + 6 = (x - 2)(x - 3), nên hai nghiệm là 2 và 3.", points: 2 }, + { id: "math-q2", type: "true_false", prompt: "Nếu hệ số a > 0 thì tam thức bậc hai dương ở ngoài khoảng giữa hai nghiệm phân biệt.", options: [{ id: "true", label: "Đúng" }, { id: "false", label: "Sai" }], correctAnswer: "true", explanation: "Với a > 0, parabol quay lên nên tam thức dương ở phía ngoài hai nghiệm.", points: 2 }, + { id: "math-q3", type: "multiple_choice", prompt: "Tập nghiệm của x² - 5x + 6 < 0 là gì?", options: [{ id: "a", label: "(-∞; 2)" }, { id: "b", label: "(3; +∞)" }, { id: "c", label: "(2; 3)" }, { id: "d", label: "(-∞; 2) ∪ (3; +∞)" }], correctAnswer: "c", explanation: "Tam thức có a > 0 và âm trong khoảng giữa hai nghiệm 2 và 3.", points: 2 }, + { id: "math-q4", type: "short_answer", prompt: "Điền nghiệm lớn hơn của phương trình x² - 7x + 12 = 0.", correctAnswer: "4", explanation: "x² - 7x + 12 = (x - 3)(x - 4), nghiệm lớn hơn là 4.", points: 2 }, + { id: "math-q5", type: "multiple_choice", prompt: "Bất phương trình x² + 1 > 0 đúng với những giá trị x nào?", options: [{ id: "a", label: "x > 0" }, { id: "b", label: "x < 0" }, { id: "c", label: "x ≠ 0" }, { id: "d", label: "Mọi số thực" }], correctAnswer: "d", explanation: "Vì x² ≥ 0 nên x² + 1 luôn lớn hơn 0 với mọi số thực.", points: 2 }, + ], + }, + { + id: "exercise-physics-graph", + materialId: "learner-material-04", + sessionId: "physics-session-01", + classId: "class-physics-10", + className: "Vật lý 10 - Chuyển động", + subject: "Vật lý", + lessonTopic: "Đồ thị chuyển động", + tutorName: "Thầy Lê Minh Đức", + title: "Phiếu bài tập đồ thị chuyển động", + description: "Tải đề, trình bày lời giải và nộp lại một tệp PDF hoặc DOCX.", + source: "upload", + mode: "file_submission", + difficulty: "medium", + status: "in_progress", + assignedAt: "2026-08-19T08:15:00+07:00", + dueAt: "2026-08-26T22:00:00+07:00", + estimatedMinutes: 35, + attachmentName: "phieu-bai-tap-do-thi-chuyen-dong.pdf", + questions: [], + }, + { + id: "exercise-ielts-writing", + materialId: "learner-material-06", + sessionId: "english-session-03", + classId: "class-ielts-65", + className: "IELTS 6.5 - Writing", + subject: "Tiếng Anh", + lessonTopic: "Writing Task 1 - Bar chart", + tutorName: "Cô Phạm Anh Thư", + title: "Kiểm tra từ vựng mô tả biểu đồ", + description: "Ôn nhóm từ chỉ xu hướng và cách viết câu so sánh.", + source: "ai", + mode: "interactive", + difficulty: "easy", + status: "reviewed", + assignedAt: "2026-08-11T08:40:00+07:00", + dueAt: "2026-08-14T22:00:00+07:00", + estimatedMinutes: 10, + score: 8, + questions: [ + { id: "ielts-q1", type: "multiple_choice", prompt: "Từ nào mô tả một mức tăng mạnh?", options: [{ id: "a", label: "remain stable" }, { id: "b", label: "rise sharply" }, { id: "c", label: "decline slightly" }, { id: "d", label: "fluctuate" }], correctAnswer: "b", explanation: "Rise sharply diễn tả mức tăng nhanh và rõ rệt.", points: 8 }, + { id: "ielts-q2", type: "multiple_choice", prompt: "Cấu trúc nào phù hợp để so sánh hai nhóm dữ liệu?", options: [{ id: "a", label: "In contrast, ..." }, { id: "b", label: "Once upon a time, ..." }, { id: "c", label: "For instance only, ..." }, { id: "d", label: "I strongly believe ..." }], correctAnswer: "a", explanation: "In contrast được dùng để đặt hai xu hướng hoặc số liệu đối lập cạnh nhau.", points: 2 }, + ], + }, + { + id: "exercise-math-system", + materialId: "learner-material-01", + sessionId: "math-session-01", + classId: "class-math-10", + className: "Toán 10 - Nền tảng", + subject: "Toán", + lessonTopic: "Hệ phương trình bậc nhất", + tutorName: "Cô Nguyễn Thu Hà", + title: "Ôn nhanh phương pháp thế", + description: "Bài ngắn giúp kiểm tra lại quy trình giải hệ phương trình.", + source: "ai", + mode: "interactive", + difficulty: "easy", + status: "in_progress", + assignedAt: "2026-08-20T20:20:00+07:00", + dueAt: "2026-08-25T22:00:00+07:00", + estimatedMinutes: 12, + questions: [ + { id: "system-q1", type: "multiple_choice", prompt: "Bước đầu tiên của phương pháp thế thường là gì?", options: [{ id: "a", label: "Biểu diễn một ẩn theo ẩn còn lại" }, { id: "b", label: "Nhân hai phương trình với nhau" }, { id: "c", label: "Cộng tất cả hệ số" }, { id: "d", label: "Đổi dấu mọi số hạng" }], correctAnswer: "a", explanation: "Ta rút một ẩn từ một phương trình rồi thế vào phương trình còn lại.", points: 5 }, + { id: "system-q2", type: "short_answer", prompt: "Nếu x + y = 5 và x = 2 thì y bằng bao nhiêu?", correctAnswer: "3", explanation: "Thế x = 2 vào x + y = 5, ta được y = 3.", points: 5 }, + ], + }, + { + id: "exercise-literature-reading", + materialId: "literature-upload-01", + sessionId: "literature-session-04", + classId: "class-literature-10", + className: "Ngữ văn 10 - Đọc hiểu", + subject: "Ngữ văn", + lessonTopic: "Biện pháp tu từ", + tutorName: "Cô Vũ Mai Lan", + title: "Phiếu luyện đọc hiểu số 4", + description: "Hoàn thành đề đọc hiểu và nộp bài trước thời hạn.", + source: "upload", + mode: "file_submission", + difficulty: "hard", + status: "overdue", + assignedAt: "2026-05-01T08:00:00+07:00", + dueAt: "2026-05-06T22:00:00+07:00", + estimatedMinutes: 45, + attachmentName: "phieu-doc-hieu-so-4.docx", + questions: [], + }, +]; + +export const EXERCISE_ATTEMPTS: Record = { + "exercise-ielts-writing": { + exerciseId: "exercise-ielts-writing", + answers: { "ielts-q1": "b", "ielts-q2": "d" }, + }, + "exercise-math-system": { + exerciseId: "exercise-math-system", + answers: { "system-q1": "a" }, + }, +}; diff --git a/apps/lms/features/learner-exercises/hooks/useExerciseAttempt.ts b/apps/lms/features/learner-exercises/hooks/useExerciseAttempt.ts new file mode 100644 index 0000000..48237b0 --- /dev/null +++ b/apps/lms/features/learner-exercises/hooks/useExerciseAttempt.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + EXERCISE_ATTEMPTS, + LEARNER_EXERCISES, +} from "../data/learner-exercises.mock"; +import { gradeExercise } from "../utils/learner-exercises.utils"; + +export function useExerciseAttempt(exerciseId: string) { + const exercise = useMemo( + () => LEARNER_EXERCISES.find((item) => item.id === exerciseId), + [exerciseId], + ); + const savedAttempt = EXERCISE_ATTEMPTS[exerciseId]; + const [answers, setAnswers] = useState>(() => savedAttempt?.answers ?? {}); + const [flaggedQuestionIds, setFlaggedQuestionIds] = useState>(() => new Set()); + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [showResult, setShowResult] = useState(exercise?.status === "reviewed"); + const [submittedFileName, setSubmittedFileName] = useState(savedAttempt?.submittedFileName); + const [fileSubmitted, setFileSubmitted] = useState(exercise?.status === "submitted"); + + const answeredCount = Object.values(answers).filter((answer) => answer.trim()).length; + const currentQuestion = exercise?.questions[currentQuestionIndex]; + const result = useMemo( + () => exercise ? gradeExercise(exercise, answers) : undefined, + [answers, exercise], + ); + + function setAnswer(questionId: string, answer: string) { + setAnswers((current) => ({ ...current, [questionId]: answer })); + } + + function toggleFlag(questionId: string) { + setFlaggedQuestionIds((current) => { + const next = new Set(current); + if (next.has(questionId)) next.delete(questionId); + else next.add(questionId); + return next; + }); + } + + function submitInteractiveExercise() { + setShowResult(true); + } + + function retryExercise() { + setAnswers({}); + setFlaggedQuestionIds(new Set()); + setCurrentQuestionIndex(0); + setShowResult(false); + } + + function submitFile(fileName: string) { + setSubmittedFileName(fileName); + setFileSubmitted(true); + } + + return { + exercise, + answers, + flaggedQuestionIds, + currentQuestionIndex, + currentQuestion, + answeredCount, + showResult, + result, + submittedFileName, + fileSubmitted, + setAnswer, + toggleFlag, + setCurrentQuestionIndex, + submitInteractiveExercise, + retryExercise, + submitFile, + }; +} diff --git a/apps/lms/features/learner-exercises/hooks/useLearnerExercises.ts b/apps/lms/features/learner-exercises/hooks/useLearnerExercises.ts new file mode 100644 index 0000000..c7cf641 --- /dev/null +++ b/apps/lms/features/learner-exercises/hooks/useLearnerExercises.ts @@ -0,0 +1,81 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + LEARNER_EXERCISES, + LEARNER_EXERCISE_CLASSES, + LEARNER_EXERCISE_SESSIONS, +} from "../data/learner-exercises.mock"; +import type { + ExerciseAvailabilityFilter, + ExerciseSource, + ExerciseStatusFilter, + LearnerExerciseClassSummary, +} from "../types/learner-exercises.types"; + +const PENDING_STATUSES = new Set(["not_started", "in_progress"]); + +export function useExerciseClasses() { + const [search, setSearch] = useState(""); + const [subject, setSubject] = useState("all"); + + const classes = useMemo(() => LEARNER_EXERCISE_CLASSES.map((classInfo) => { + const classExercises = LEARNER_EXERCISES.filter((exercise) => exercise.classId === classInfo.id); + return { + classInfo, + sessionCount: LEARNER_EXERCISE_SESSIONS.filter((session) => session.classId === classInfo.id).length, + exerciseCount: classExercises.length, + pendingExerciseCount: classExercises.filter((exercise) => PENDING_STATUSES.has(exercise.status)).length, + }; + }), []); + const subjects = useMemo(() => [...new Set(LEARNER_EXERCISE_CLASSES.map((item) => item.subject))], []); + const filteredClasses = useMemo(() => { + const query = search.trim().toLocaleLowerCase("vi"); + return classes.filter(({ classInfo }) => { + const content = `${classInfo.classCode} ${classInfo.name} ${classInfo.subject} ${classInfo.level} ${classInfo.tutorName}`.toLocaleLowerCase("vi"); + return content.includes(query) && (subject === "all" || classInfo.subject === subject); + }); + }, [classes, search, subject]); + + return { search, subject, subjects, filteredClasses, setSearch, setSubject }; +} + +export function useExerciseSessions(classId: string) { + const [search, setSearch] = useState(""); + const [availability, setAvailability] = useState("all"); + const classInfo = LEARNER_EXERCISE_CLASSES.find((item) => item.id === classId); + const sessions = useMemo(() => LEARNER_EXERCISE_SESSIONS.filter((session) => session.classId === classId), [classId]); + const filteredSessions = useMemo(() => { + const query = search.trim().toLocaleLowerCase("vi"); + return sessions.filter((session) => { + const exerciseCount = LEARNER_EXERCISES.filter((exercise) => exercise.sessionId === session.id).length; + const matchesAvailability = availability === "all" + || (availability === "with_exercises" && exerciseCount > 0) + || (availability === "without_exercises" && exerciseCount === 0); + return session.topic.toLocaleLowerCase("vi").includes(query) && matchesAvailability; + }); + }, [availability, search, sessions]); + + return { classInfo, search, availability, filteredSessions, setSearch, setAvailability }; +} + +export function useSessionExercises(classId: string, sessionId: string) { + const [search, setSearch] = useState(""); + const [status, setStatus] = useState("all"); + const [source, setSource] = useState<"all" | ExerciseSource>("all"); + const classInfo = LEARNER_EXERCISE_CLASSES.find((item) => item.id === classId); + const session = LEARNER_EXERCISE_SESSIONS.find((item) => item.id === sessionId && item.classId === classId); + const filteredExercises = useMemo(() => { + const query = search.trim().toLocaleLowerCase("vi"); + return LEARNER_EXERCISES.filter((exercise) => { + const content = `${exercise.title} ${exercise.description} ${exercise.lessonTopic}`.toLocaleLowerCase("vi"); + return exercise.classId === classId + && exercise.sessionId === sessionId + && content.includes(query) + && (status === "all" || exercise.status === status) + && (source === "all" || exercise.source === source); + }); + }, [classId, search, sessionId, source, status]); + + return { classInfo, session, search, status, source, filteredExercises, setSearch, setStatus, setSource }; +} diff --git a/apps/lms/features/learner-exercises/types/learner-exercises.types.ts b/apps/lms/features/learner-exercises/types/learner-exercises.types.ts new file mode 100644 index 0000000..b50cc0c --- /dev/null +++ b/apps/lms/features/learner-exercises/types/learner-exercises.types.ts @@ -0,0 +1,89 @@ +export type ExerciseSource = "ai" | "upload"; +export type ExerciseMode = "interactive" | "file_submission"; +export type ExerciseDifficulty = "easy" | "medium" | "hard"; +export type ExerciseStatus = "not_started" | "in_progress" | "submitted" | "reviewed" | "overdue"; +export type ExerciseStatusFilter = "all" | ExerciseStatus; +export type ExerciseQuestionType = "multiple_choice" | "true_false" | "short_answer"; +export type ExerciseAvailabilityFilter = "all" | "with_exercises" | "without_exercises"; +export type LearnerExerciseClassStatus = "active" | "paused" | "completed"; + +export interface LearnerExerciseClass { + id: string; + classCode: string; + name: string; + subject: string; + level: string; + tutorName: string; + tutorInitials: string; + scheduleLabel: string; + startedAt: string; + status: LearnerExerciseClassStatus; +} + +export interface LearnerExerciseSession { + id: string; + classId: string; + sequence: number; + topic: string; + taughtAt: string; + durationMinutes: number; +} + +export interface LearnerExerciseClassSummary { + classInfo: LearnerExerciseClass; + sessionCount: number; + exerciseCount: number; + pendingExerciseCount: number; +} + +export interface ExerciseOption { + id: string; + label: string; +} + +export interface ExerciseQuestion { + id: string; + type: ExerciseQuestionType; + prompt: string; + options?: ExerciseOption[]; + correctAnswer: string; + explanation: string; + points: number; +} + +export interface LearnerExercise { + id: string; + materialId: string; + sessionId: string; + classId: string; + className: string; + subject: string; + lessonTopic: string; + tutorName: string; + title: string; + description: string; + source: ExerciseSource; + mode: ExerciseMode; + difficulty: ExerciseDifficulty; + status: ExerciseStatus; + assignedAt: string; + dueAt: string; + estimatedMinutes: number; + attachmentName?: string; + questions: ExerciseQuestion[]; + score?: number; +} + +export interface ExerciseAttempt { + exerciseId: string; + answers: Record; + submittedFileName?: string; +} + +export interface ExerciseResultSummary { + earnedPoints: number; + totalPoints: number; + correctCount: number; + questionCount: number; + percentage: number; +} diff --git a/apps/lms/features/learner-exercises/utils/learner-exercises.utils.ts b/apps/lms/features/learner-exercises/utils/learner-exercises.utils.ts new file mode 100644 index 0000000..18917f6 --- /dev/null +++ b/apps/lms/features/learner-exercises/utils/learner-exercises.utils.ts @@ -0,0 +1,59 @@ +import type { + ExerciseResultSummary, + LearnerExercise, +} from "../types/learner-exercises.types"; + +export function formatExerciseDate(value: string) { + return new Intl.DateTimeFormat("vi-VN", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function formatExerciseDay(value: string) { + return new Intl.DateTimeFormat("vi-VN", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }).format(new Date(value)); +} + +export function getExerciseSessionHref(exercise: LearnerExercise) { + return `/lms/learner/exercises/classes/${exercise.classId}/sessions/${exercise.sessionId}`; +} + +export function normalizeExerciseAnswer(value: string) { + return value.trim().toLocaleLowerCase("vi"); +} + +export function gradeExercise( + exercise: LearnerExercise, + answers: Record, +): ExerciseResultSummary { + const totalPoints = exercise.questions.reduce((total, question) => total + question.points, 0); + const correctQuestions = exercise.questions.filter( + (question) => normalizeExerciseAnswer(answers[question.id] ?? "") === normalizeExerciseAnswer(question.correctAnswer), + ); + const earnedPoints = correctQuestions.reduce((total, question) => total + question.points, 0); + return { + earnedPoints, + totalPoints, + correctCount: correctQuestions.length, + questionCount: exercise.questions.length, + percentage: totalPoints > 0 ? Math.round((earnedPoints / totalPoints) * 100) : 0, + }; +} + +export function downloadMockExercise(exercise: LearnerExercise) { + const documentContent = `${exercise.title}

${exercise.title}

Lớp: ${exercise.className}

Chủ đề: ${exercise.lessonTopic}

Gia sư: ${exercise.tutorName}

${exercise.description}

Đây là tệp đề bài minh họa của bản mock UI. Nội dung file gốc sẽ được tải từ kho tài liệu khi kết nối backend.
`; + const blob = new Blob([documentContent], { type: "text/html;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `de-bai-mock-${exercise.id}.html`; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/lms/features/learner-materials/components/ClassLibraryList.tsx b/apps/lms/features/learner-materials/components/ClassLibraryList.tsx new file mode 100644 index 0000000..ae4af05 --- /dev/null +++ b/apps/lms/features/learner-materials/components/ClassLibraryList.tsx @@ -0,0 +1,46 @@ +import Link from "next/link"; +import { ArrowRight, FolderOpen } from "@phosphor-icons/react"; +import type { LearnerClassSummary } from "../types/learner-materials.types"; +import { formatLibraryDate } from "../utils/learner-materials.utils"; + +export function ClassLibraryList({ classes }: { classes: LearnerClassSummary[] }) { + if (classes.length === 0) { + return ( +
+

Không tìm thấy lớp học

Hãy đổi từ khóa hoặc môn học đang lọc.

+
+ ); + } + + return ( +
+

Lớp học của tôi

Chọn một lớp để xem các buổi học và tài liệu đã được chia sẻ.

+
+ {classes.map((summary) => ( +
+
+
+ {summary.classInfo.tutorInitials} +

{summary.classInfo.subject}

{summary.classInfo.level}

{summary.classInfo.tutorName}

{summary.classInfo.scheduleLabel}

+
+
+ + + +
+
+ Xem buổi học + {summary.latestMaterialAt &&

Cập nhật {formatLibraryDate(summary.latestMaterialAt)}

} +
+
+
+ ))} +
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return
{label}
{value}
; +} + diff --git a/apps/lms/features/learner-materials/components/ClassLibraryScreen.tsx b/apps/lms/features/learner-materials/components/ClassLibraryScreen.tsx new file mode 100644 index 0000000..680ebf6 --- /dev/null +++ b/apps/lms/features/learner-materials/components/ClassLibraryScreen.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { MagnifyingGlass } from "@phosphor-icons/react"; +import { useClassLibrary } from "../hooks/useLearnerMaterials"; +import { ClassLibraryList } from "./ClassLibraryList"; + +export function ClassLibraryScreen() { + const library = useClassLibrary(); + + return ( +
+
+

Kho tài liệu

Xem tài liệu được gia sư tạo bằng AI hoặc tải lên theo từng lớp và buổi học.

+ +
+
+ + +
+
+ + +
+
+ ); +} + diff --git a/apps/lms/features/learner-materials/components/ClassSessionList.tsx b/apps/lms/features/learner-materials/components/ClassSessionList.tsx new file mode 100644 index 0000000..b715e23 --- /dev/null +++ b/apps/lms/features/learner-materials/components/ClassSessionList.tsx @@ -0,0 +1,57 @@ +import Link from "next/link"; +import { ArrowRight, FolderOpen } from "@phosphor-icons/react"; +import type { + LearnerClassSession, + LearnerSharedMaterial, +} from "../types/learner-materials.types"; +import { formatLibraryDate } from "../utils/learner-materials.utils"; + +const SESSION_STATUS = { + COMPLETED: { label: "Đã hoàn thành", className: "bg-[#447353]/10 text-[#447353]" }, + UPCOMING: { label: "Sắp diễn ra", className: "bg-[#FFC500]/15 text-[#905B0F]" }, + CANCELED: { label: "Đã hủy", className: "bg-[#E1ABA7]/20 text-[#9B5E5A]" }, +} as const; + +interface ClassSessionListProps { + classId: string; + sessions: LearnerClassSession[]; + materials: LearnerSharedMaterial[]; +} + +export function ClassSessionList({ classId, sessions, materials }: ClassSessionListProps) { + if (sessions.length === 0) { + return ( +
+

Không có buổi học phù hợp

Hãy thay đổi bộ lọc để xem các buổi học khác.

+
+ ); + } + + return ( +
+

Danh sách buổi học

Tài liệu chỉ hiển thị sau khi gia sư chia sẻ với lớp.

+
+ {sessions.map((session) => { + const materialCount = materials.filter((material) => material.sessionId === session.id).length; + const status = SESSION_STATUS[session.status]; + return ( +
+
+
Buổi {session.sequence}{status.label}

{session.topic}

{formatLibraryDate(session.taughtAt)} • {session.durationMinutes} phút

+
+ {materialCount > 0 ? {materialCount} tài liệu : Chưa có tài liệu} + {materialCount > 0 ? ( + Xem tài liệu + ) : ( + + )} +
+
+
+ ); + })} +
+
+ ); +} + diff --git a/apps/lms/features/learner-materials/components/ClassSessionsScreen.tsx b/apps/lms/features/learner-materials/components/ClassSessionsScreen.tsx new file mode 100644 index 0000000..ab91a41 --- /dev/null +++ b/apps/lms/features/learner-materials/components/ClassSessionsScreen.tsx @@ -0,0 +1,49 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, MagnifyingGlass, WarningCircle } from "@phosphor-icons/react"; +import { useClassSessions } from "../hooks/useLearnerMaterials"; +import type { + LearnerClassSessionStatus, + SessionMaterialAvailability, +} from "../types/learner-materials.types"; +import { ClassSessionList } from "./ClassSessionList"; + +export function ClassSessionsScreen({ classId }: { classId: string }) { + const library = useClassSessions(classId); + + if (!library.classInfo) { + return ; + } + + return ( +
+
+ Kho tài liệu +
+ {library.classInfo.tutorInitials} +

{library.classInfo.level}

{library.classInfo.subject}

{library.classInfo.tutorName} • {library.classInfo.scheduleLabel}

+
+ +
+
+ + library.setStatus(value as "all" | LearnerClassSessionStatus)} options={[{ value: "all", label: "Mọi trạng thái" }, { value: "COMPLETED", label: "Đã hoàn thành" }, { value: "UPCOMING", label: "Sắp diễn ra" }, { value: "CANCELED", label: "Đã hủy" }]} /> + library.setAvailability(value as SessionMaterialAvailability)} options={[{ value: "all", label: "Mọi tình trạng" }, { value: "available", label: "Đã có tài liệu" }, { value: "empty", label: "Chưa có tài liệu" }]} /> +
+
+ + +
+
+ ); +} + +function FilterSelect({ label, value, options, onChange }: { label: string; value: string; options: { value: string; label: string }[]; onChange: (value: string) => void }) { + return ; +} + +function MissingState({ message, href }: { message: string; href: string }) { + return

{message}

Quay lại
; +} + diff --git a/apps/lms/features/learner-materials/components/LearnerMaterialBadges.tsx b/apps/lms/features/learner-materials/components/LearnerMaterialBadges.tsx new file mode 100644 index 0000000..d96e940 --- /dev/null +++ b/apps/lms/features/learner-materials/components/LearnerMaterialBadges.tsx @@ -0,0 +1,41 @@ +import type { + LearnerMaterialFileType, + LearnerMaterialSource, +} from "../types/learner-materials.types"; + +const SOURCE_LABELS: Record = { + ai: "Tạo bằng AI", + upload: "Gia sư tải lên", +}; + +export function LearnerMaterialBadges({ + source, + fileType, + isNew, +}: { + source: LearnerMaterialSource; + fileType: LearnerMaterialFileType; + isNew?: boolean; +}) { + return ( +
+ + {SOURCE_LABELS[source]} + + + {fileType} + + {isNew ? ( + + Mới + + ) : null} +
+ ); +} diff --git a/apps/lms/features/learner-materials/components/LearnerMaterialsSkeleton.tsx b/apps/lms/features/learner-materials/components/LearnerMaterialsSkeleton.tsx new file mode 100644 index 0000000..c506b40 --- /dev/null +++ b/apps/lms/features/learner-materials/components/LearnerMaterialsSkeleton.tsx @@ -0,0 +1,24 @@ +function Skeleton({ className }: { className: string }) { + return
; +} + +export function LearnerMaterialsSkeleton() { + return ( +
+
+ +
+ + +
+ +
+ +
+ {[0, 1, 2].map((item) => )} +
+
+
+
+ ); +} diff --git a/apps/lms/features/learner-materials/components/MaterialDetailDialog.tsx b/apps/lms/features/learner-materials/components/MaterialDetailDialog.tsx new file mode 100644 index 0000000..d64f54c --- /dev/null +++ b/apps/lms/features/learner-materials/components/MaterialDetailDialog.tsx @@ -0,0 +1,52 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/ui/dialog"; +import type { LearnerSharedMaterial } from "../types/learner-materials.types"; +import { formatLibraryDate } from "../utils/learner-materials.utils"; +import { LearnerMaterialBadges } from "./LearnerMaterialBadges"; + +export function MaterialDetailDialog({ + material, + onClose, +}: { + material?: LearnerSharedMaterial; + onClose: () => void; +}) { + return ( + !open && onClose()}> + + {material ? ( + <> + + + {material.title} + + Được chia sẻ lúc {formatLibraryDate(material.sharedAt)} + {material.fileSize ? ` • ${material.fileSize}` : ""} + + +
+
+

Mô tả tài liệu

+

{material.description}

+
+
+ {material.source === "ai" + ? "Tài liệu BeeWise sẽ hiển thị nội dung học tập trực tiếp tại đây khi kết nối dữ liệu thực tế." + : "Bản mock hiển thị thông tin tệp đã chia sẻ. Tính năng mở hoặc tải tệp sẽ được nối với API tài liệu."} +
+
+ + ) : null} +
+
+ ); +} diff --git a/apps/lms/features/learner-materials/components/SessionMaterialsScreen.tsx b/apps/lms/features/learner-materials/components/SessionMaterialsScreen.tsx new file mode 100644 index 0000000..c07ec22 --- /dev/null +++ b/apps/lms/features/learner-materials/components/SessionMaterialsScreen.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { ArrowLeft, MagnifyingGlass, WarningCircle } from "@phosphor-icons/react"; +import { useSessionMaterials } from "../hooks/useLearnerMaterials"; +import type { + LearnerMaterialFileType, + LearnerMaterialSource, + LearnerSharedMaterial, +} from "../types/learner-materials.types"; +import { formatLibraryDate } from "../utils/learner-materials.utils"; +import { MaterialDetailDialog } from "./MaterialDetailDialog"; +import { SharedMaterialList } from "./SharedMaterialList"; + +export function SessionMaterialsScreen({ classId, sessionId }: { classId: string; sessionId: string }) { + const library = useSessionMaterials(classId, sessionId); + const [selectedMaterial, setSelectedMaterial] = useState(); + + if (!library.classInfo || !library.session) { + return ; + } + + return ( +
+
+ + Danh sách buổi học + +
+

Buổi {library.session.sequence} • {library.classInfo.subject} {library.classInfo.level}

+

{library.session.topic}

+

+ {formatLibraryDate(library.session.taughtAt)} • {library.session.durationMinutes} phút • {library.classInfo.tutorName} +

+
+ +
+
+ + library.setSource(value as "all" | LearnerMaterialSource)} options={[{ value: "all", label: "Mọi nguồn" }, { value: "ai", label: "Tạo bằng AI" }, { value: "upload", label: "Gia sư tải lên" }]} /> + library.setFileType(value as "all" | LearnerMaterialFileType)} options={[{ value: "all", label: "Mọi loại tệp" }, { value: "BEEWISE", label: "BeeWise" }, { value: "PDF", label: "PDF" }, { value: "DOCX", label: "DOCX" }, { value: "PPTX", label: "PPTX" }]} /> +
+
+ + +
+ setSelectedMaterial(undefined)} /> +
+ ); +} + +function FilterSelect({ label, value, options, onChange }: { label: string; value: string; options: { value: string; label: string }[]; onChange: (value: string) => void }) { + return ; +} + +function MissingState({ href }: { href: string }) { + return

Không tìm thấy buổi học

Quay lại
; +} diff --git a/apps/lms/features/learner-materials/components/SharedMaterialList.tsx b/apps/lms/features/learner-materials/components/SharedMaterialList.tsx new file mode 100644 index 0000000..f8fc178 --- /dev/null +++ b/apps/lms/features/learner-materials/components/SharedMaterialList.tsx @@ -0,0 +1,53 @@ +import { Eye, Files } from "@phosphor-icons/react"; +import type { LearnerSharedMaterial } from "../types/learner-materials.types"; +import { formatLibraryDate } from "../utils/learner-materials.utils"; +import { LearnerMaterialBadges } from "./LearnerMaterialBadges"; + +export function SharedMaterialList({ + materials, + onView, +}: { + materials: LearnerSharedMaterial[]; + onView: (material: LearnerSharedMaterial) => void; +}) { + if (materials.length === 0) { + return ( +
+ +

Không có tài liệu phù hợp

+

Hãy thử thay đổi từ khóa hoặc bộ lọc.

+
+ ); + } + + return ( +
+
+

Tài liệu được chia sẻ

+

{materials.length} tài liệu trong kết quả hiện tại

+
+
+ {materials.map((material) => ( +
+
+ +

{material.title}

+

{material.description}

+

+ Chia sẻ {formatLibraryDate(material.sharedAt)}{material.fileSize ? ` • ${material.fileSize}` : ""} +

+
+ +
+ ))} +
+
+ ); +} diff --git a/apps/lms/features/learner-materials/data/learner-materials.mock.ts b/apps/lms/features/learner-materials/data/learner-materials.mock.ts new file mode 100644 index 0000000..47d22c3 --- /dev/null +++ b/apps/lms/features/learner-materials/data/learner-materials.mock.ts @@ -0,0 +1,109 @@ +import type { + LearnerClass, + LearnerClassSession, + LearnerSharedMaterial, +} from "../types/learner-materials.types"; + +export const LEARNER_CLASSES: LearnerClass[] = [ + { + id: "class-math-10", + subject: "Toán", + level: "Lớp 10", + tutorName: "Cô Nguyễn Thu Hà", + tutorInitials: "TH", + scheduleLabel: "Thứ Hai và Thứ Bảy", + startedAt: "2026-06-02T09:00:00+07:00", + }, + { + id: "class-physics-10", + subject: "Vật lý", + level: "Lớp 10", + tutorName: "Thầy Lê Minh Đức", + tutorInitials: "MĐ", + scheduleLabel: "Thứ Ba hằng tuần", + startedAt: "2026-07-05T09:00:00+07:00", + }, + { + id: "class-ielts-65", + subject: "Tiếng Anh", + level: "IELTS 6.5", + tutorName: "Cô Phạm Anh Thư", + tutorInitials: "AT", + scheduleLabel: "Thứ Năm và Chủ Nhật", + startedAt: "2026-05-18T09:00:00+07:00", + }, +]; + +export const LEARNER_CLASS_SESSIONS: LearnerClassSession[] = [ + { id: "math-session-01", classId: "class-math-10", sequence: 12, topic: "Hệ phương trình bậc nhất", taughtAt: "2026-08-20T18:00:00+07:00", durationMinutes: 90, status: "COMPLETED" }, + { id: "math-session-02", classId: "class-math-10", sequence: 13, topic: "Bất phương trình bậc hai", taughtAt: "2026-08-24T18:00:00+07:00", durationMinutes: 90, status: "COMPLETED" }, + { id: "math-session-03", classId: "class-math-10", sequence: 14, topic: "Dấu của tam thức bậc hai", taughtAt: "2026-08-29T09:00:00+07:00", durationMinutes: 90, status: "UPCOMING" }, + { id: "physics-session-01", classId: "class-physics-10", sequence: 7, topic: "Đồ thị chuyển động", taughtAt: "2026-08-18T19:30:00+07:00", durationMinutes: 90, status: "COMPLETED" }, + { id: "physics-session-02", classId: "class-physics-10", sequence: 8, topic: "Chuyển động thẳng biến đổi đều", taughtAt: "2026-08-25T19:30:00+07:00", durationMinutes: 90, status: "UPCOMING" }, + { id: "english-session-01", classId: "class-ielts-65", sequence: 18, topic: "Speaking Part 1", taughtAt: "2026-08-16T20:00:00+07:00", durationMinutes: 60, status: "CANCELED" }, + { id: "english-session-02", classId: "class-ielts-65", sequence: 19, topic: "Speaking Part 2 - Describe a person", taughtAt: "2026-08-27T20:00:00+07:00", durationMinutes: 60, status: "UPCOMING" }, + { id: "english-session-03", classId: "class-ielts-65", sequence: 17, topic: "Writing Task 1 - Bar chart", taughtAt: "2026-08-10T20:00:00+07:00", durationMinutes: 90, status: "COMPLETED" }, +]; + +// Learner data only contains documents already published by the tutor. +export const LEARNER_SHARED_MATERIALS: LearnerSharedMaterial[] = [ + { + id: "learner-material-01", + sessionId: "math-session-01", + title: "Tóm tắt hệ phương trình bậc nhất", + description: "Tóm tắt lý thuyết, phương pháp thế và bộ câu hỏi luyện tập do BeeWise AI tạo.", + source: "ai", + fileType: "BEEWISE", + sharedAt: "2026-08-20T20:15:00+07:00", + }, + { + id: "learner-material-02", + sessionId: "math-session-01", + title: "Bài tập tự luyện hệ phương trình", + description: "Phiếu bài tập gồm 12 câu từ cơ bản đến vận dụng.", + source: "upload", + fileType: "PDF", + fileSize: "1,8 MB", + sharedAt: "2026-08-20T20:22:00+07:00", + }, + { + id: "learner-material-03", + sessionId: "math-session-02", + title: "Bất phương trình bậc hai - Ghi nhớ nhanh", + description: "Sơ đồ xét dấu và các lỗi thường gặp khi giải bất phương trình.", + source: "ai", + fileType: "BEEWISE", + sharedAt: "2026-08-24T20:05:00+07:00", + isNew: true, + }, + { + id: "learner-material-04", + sessionId: "physics-session-01", + title: "Đồ thị tọa độ - thời gian", + description: "Slide minh họa cách đọc và phân tích các dạng đồ thị chuyển động.", + source: "upload", + fileType: "PPTX", + fileSize: "4,6 MB", + sharedAt: "2026-08-19T08:10:00+07:00", + }, + { + id: "learner-material-05", + sessionId: "english-session-03", + title: "IELTS Writing Task 1 templates", + description: "Mẫu cấu trúc câu và từ vựng mô tả xu hướng trong biểu đồ cột.", + source: "upload", + fileType: "DOCX", + fileSize: "840 KB", + sharedAt: "2026-08-11T08:20:00+07:00", + }, + { + id: "learner-material-06", + sessionId: "english-session-03", + title: "Bài luyện viết có hướng dẫn", + description: "Đề luyện tập kèm dàn ý và tiêu chí tự đánh giá bài viết.", + source: "ai", + fileType: "BEEWISE", + sharedAt: "2026-08-11T08:35:00+07:00", + }, +]; + diff --git a/apps/lms/features/learner-materials/hooks/useLearnerMaterials.ts b/apps/lms/features/learner-materials/hooks/useLearnerMaterials.ts new file mode 100644 index 0000000..893fa3a --- /dev/null +++ b/apps/lms/features/learner-materials/hooks/useLearnerMaterials.ts @@ -0,0 +1,106 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + LEARNER_CLASSES, + LEARNER_CLASS_SESSIONS, + LEARNER_SHARED_MATERIALS, +} from "../data/learner-materials.mock"; +import type { + LearnerClassSessionStatus, + LearnerMaterialFileType, + LearnerMaterialSource, + SessionMaterialAvailability, +} from "../types/learner-materials.types"; +import { buildClassSummaries } from "../utils/learner-materials.utils"; + +export function useClassLibrary() { + const [search, setSearch] = useState(""); + const [subject, setSubject] = useState("all"); + const summaries = useMemo( + () => buildClassSummaries(LEARNER_CLASSES, LEARNER_CLASS_SESSIONS, LEARNER_SHARED_MATERIALS), + [], + ); + const subjects = useMemo(() => [...new Set(LEARNER_CLASSES.map((item) => item.subject))], []); + const filteredClasses = useMemo(() => { + const query = search.trim().toLocaleLowerCase("vi"); + return summaries.filter((summary) => { + const content = `${summary.classInfo.subject} ${summary.classInfo.level} ${summary.classInfo.tutorName}`.toLocaleLowerCase("vi"); + return content.includes(query) && (subject === "all" || summary.classInfo.subject === subject); + }); + }, [search, subject, summaries]); + + return { summaries, filteredClasses, subjects, search, subject, setSearch, setSubject }; +} + +export function useClassSessions(classId: string) { + const [search, setSearch] = useState(""); + const [status, setStatus] = useState<"all" | LearnerClassSessionStatus>("all"); + const [availability, setAvailability] = useState("all"); + const classInfo = LEARNER_CLASSES.find((item) => item.id === classId); + const classSessions = useMemo( + () => LEARNER_CLASS_SESSIONS.filter((session) => session.classId === classId), + [classId], + ); + const filteredSessions = useMemo(() => { + const query = search.trim().toLocaleLowerCase("vi"); + return classSessions.filter((session) => { + const materialCount = LEARNER_SHARED_MATERIALS.filter((material) => material.sessionId === session.id).length; + const matchesSearch = session.topic.toLocaleLowerCase("vi").includes(query); + const matchesStatus = status === "all" || session.status === status; + const matchesAvailability = + availability === "all" || + (availability === "available" && materialCount > 0) || + (availability === "empty" && materialCount === 0); + return matchesSearch && matchesStatus && matchesAvailability; + }); + }, [availability, classSessions, search, status]); + + return { + classInfo, + classSessions, + filteredSessions, + materials: LEARNER_SHARED_MATERIALS, + search, + status, + availability, + setSearch, + setStatus, + setAvailability, + }; +} + +export function useSessionMaterials(classId: string, sessionId: string) { + const [search, setSearch] = useState(""); + const [source, setSource] = useState<"all" | LearnerMaterialSource>("all"); + const [fileType, setFileType] = useState<"all" | LearnerMaterialFileType>("all"); + const classInfo = LEARNER_CLASSES.find((item) => item.id === classId); + const session = LEARNER_CLASS_SESSIONS.find( + (item) => item.id === sessionId && item.classId === classId, + ); + const filteredMaterials = useMemo(() => { + const query = search.trim().toLocaleLowerCase("vi"); + return LEARNER_SHARED_MATERIALS.filter((material) => { + const matchesSession = material.sessionId === sessionId; + const matchesSearch = `${material.title} ${material.description}` + .toLocaleLowerCase("vi") + .includes(query); + const matchesSource = source === "all" || material.source === source; + const matchesType = fileType === "all" || material.fileType === fileType; + return matchesSession && matchesSearch && matchesSource && matchesType; + }); + }, [fileType, search, sessionId, source]); + + return { + classInfo, + session, + filteredMaterials, + search, + source, + fileType, + setSearch, + setSource, + setFileType, + }; +} + diff --git a/apps/lms/features/learner-materials/types/learner-materials.types.ts b/apps/lms/features/learner-materials/types/learner-materials.types.ts new file mode 100644 index 0000000..a789e7e --- /dev/null +++ b/apps/lms/features/learner-materials/types/learner-materials.types.ts @@ -0,0 +1,45 @@ +export type LearnerMaterialSource = "ai" | "upload"; +export type LearnerMaterialFileType = "PDF" | "DOCX" | "PPTX" | "BEEWISE"; +export type LearnerClassSessionStatus = "COMPLETED" | "UPCOMING" | "CANCELED"; +export type SessionMaterialAvailability = "all" | "available" | "empty"; + +export interface LearnerClass { + id: string; + subject: string; + level: string; + tutorName: string; + tutorInitials: string; + scheduleLabel: string; + startedAt: string; +} + +export interface LearnerClassSession { + id: string; + classId: string; + sequence: number; + topic: string; + taughtAt: string; + durationMinutes: number; + status: LearnerClassSessionStatus; +} + +export interface LearnerSharedMaterial { + id: string; + sessionId: string; + title: string; + description: string; + source: LearnerMaterialSource; + fileType: LearnerMaterialFileType; + fileSize?: string; + sharedAt: string; + isNew?: boolean; +} + +export interface LearnerClassSummary { + classInfo: LearnerClass; + sessionCount: number; + completedSessionCount: number; + materialCount: number; + latestMaterialAt?: string; +} + diff --git a/apps/lms/features/learner-materials/utils/learner-materials.utils.ts b/apps/lms/features/learner-materials/utils/learner-materials.utils.ts new file mode 100644 index 0000000..4fc28ff --- /dev/null +++ b/apps/lms/features/learner-materials/utils/learner-materials.utils.ts @@ -0,0 +1,39 @@ +import type { + LearnerClass, + LearnerClassSession, + LearnerClassSummary, + LearnerSharedMaterial, +} from "../types/learner-materials.types"; + +export function formatLibraryDate(value: string) { + return new Intl.DateTimeFormat("vi-VN", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +export function buildClassSummaries( + classes: LearnerClass[], + sessions: LearnerClassSession[], + materials: LearnerSharedMaterial[], +): LearnerClassSummary[] { + return classes.map((classInfo) => { + const classSessions = sessions.filter((session) => session.classId === classInfo.id); + const sessionIds = new Set(classSessions.map((session) => session.id)); + const classMaterials = materials.filter((material) => sessionIds.has(material.sessionId)); + const latestMaterialAt = classMaterials + .map((material) => material.sharedAt) + .sort((a, b) => b.localeCompare(a))[0]; + return { + classInfo, + sessionCount: classSessions.length, + completedSessionCount: classSessions.filter((session) => session.status === "COMPLETED").length, + materialCount: classMaterials.length, + latestMaterialAt, + }; + }); +} + diff --git a/apps/lms/features/learner-messages/components/ConversationListItem.tsx b/apps/lms/features/learner-messages/components/ConversationListItem.tsx new file mode 100644 index 0000000..3b4c349 --- /dev/null +++ b/apps/lms/features/learner-messages/components/ConversationListItem.tsx @@ -0,0 +1,45 @@ +import Link from "next/link"; +import type { LearnerConversation } from "../types/learner-messages.types"; +import { formatConversationTime } from "../utils/learner-messages.utils"; + +export function ConversationListItem({ + conversation, + selected, +}: { + conversation: LearnerConversation; + selected: boolean; +}) { + const isActive = conversation.status === "ACTIVE"; + const isHighlighted = isActive && conversation.unreadCount > 0; + + return ( + + + {conversation.tutor.initials} + + + + {conversation.tutor.name} + + + {conversation.className} + + {conversation.lastMessage} + {conversation.unreadCount > 0 ? {conversation.unreadCount} : null} + + + + ); +} diff --git a/apps/lms/features/learner-messages/components/ConversationSidebar.tsx b/apps/lms/features/learner-messages/components/ConversationSidebar.tsx new file mode 100644 index 0000000..30a1f3c --- /dev/null +++ b/apps/lms/features/learner-messages/components/ConversationSidebar.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { ChatCircleDots, MagnifyingGlass } from "@phosphor-icons/react"; +import { useLearnerMessages } from "../hooks/useLearnerMessages"; +import type { LearnerConversation } from "../types/learner-messages.types"; +import { ConversationListItem } from "./ConversationListItem"; + +export function ConversationSidebar({ selectedId }: { selectedId?: string }) { + const messages = useLearnerMessages(); + const resultCount = messages.activeConversations.length + messages.closedConversations.length; + + return ( + + ); +} + +function ConversationSection({ title, conversations, selectedId, muted = false }: { title: string; conversations: LearnerConversation[]; selectedId?: string; muted?: boolean }) { + if (conversations.length === 0) return null; + return ( +
+
+

{title}

+ {conversations.length} +
+
{conversations.map((conversation) => )}
+
+ ); +} diff --git a/apps/lms/features/learner-messages/components/LearnerChatPanel.tsx b/apps/lms/features/learner-messages/components/LearnerChatPanel.tsx new file mode 100644 index 0000000..6643664 --- /dev/null +++ b/apps/lms/features/learner-messages/components/LearnerChatPanel.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import Link from "next/link"; +import { ArrowLeft, LockSimple, PaperPlaneTilt, WarningCircle } from "@phosphor-icons/react"; +import { useLearnerConversation } from "../hooks/useLearnerConversation"; +import { CLOSE_REASON_LABELS } from "../utils/learner-messages.utils"; +import { LearnerMessageBubble } from "./LearnerMessageBubble"; + +export function LearnerChatPanel({ conversationId }: { conversationId: string }) { + const { + conversation, + messages, + sending, + messagesContainerRef, + sendMessage, + } = useLearnerConversation(conversationId); + const [text, setText] = useState(""); + + if (!conversation) { + return ; + } + + const isClosed = conversation.status === "CLOSED"; + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!text.trim()) return; + void sendMessage(text); + setText(""); + }; + + return ( +
+
+ + {conversation.tutor.initials} +
+
+

{conversation.tutor.name}

+ {!isClosed && conversation.tutor.isOnline ? Đang online : null} +
+

{conversation.className}

+
+ {isClosed ? "Đã đóng" : "Đang học"} +
+ + {isClosed ? ( +
+ + {conversation.closeReason ? CLOSE_REASON_LABELS[conversation.closeReason] : "Cuộc trò chuyện đã đóng"}. Bạn vẫn có thể xem lại lịch sử tin nhắn. +
+ ) : null} + +
+
{messages.map((message) => )}
+
+ + {isClosed ? ( +
+

Cuộc trò chuyện đã đóng. Bạn không thể gửi thêm tin nhắn.

+
+ ) : ( +
+