diff --git a/messages/en/classroom/en_classroom.json b/messages/en/classroom/en_classroom.json
index 27ccd46c4..9e99c10eb 100644
--- a/messages/en/classroom/en_classroom.json
+++ b/messages/en/classroom/en_classroom.json
@@ -2,9 +2,13 @@
"classroom": {
"list": {
"header": "Classroom List",
+ "description": "Manage all your existing classrooms, including class details, schedules, teachers, and enrolled students.",
"searchPlaceholder": "Search...",
"selectCoursePlaceholder": "Select course",
- "courses": "courses"
+ "selectStatusPlaceholder": "Filter by status",
+ "courses": "courses",
+ "noClassroom": "No classrooms found.",
+ "noClassroomSubtext": "Try adjusting your search or filter to find what you're looking for."
},
"update": {
"basicInfo": {
diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json
index 10e06a34d..e739a691e 100644
--- a/messages/en/common/en_common.json
+++ b/messages/en/common/en_common.json
@@ -1,5 +1,20 @@
{
"common": {
+ "noData": "Not found",
+ "breadcrumb": {
+ "home": "Home",
+ "resource": "Resources",
+ "lesson": "Lesson",
+ "lessons": "Lessons",
+ "activities": "Activities",
+ "course": "Course",
+ "courses": "Courses",
+ "courseDetail": "Course Detail",
+ "classroom": "Classroom",
+ "classrooms": "Classrooms",
+ "classroomDetail": "Classroom Detail",
+ "createClassroom": "Create Classroom"
+ },
"search": {
"placeholder": "Search...",
"noResults": "No results found."
@@ -115,7 +130,12 @@
"continueLearning": "Continue Learning",
"markAsComplete": "Mark as Complete",
"startQuiz": "Start Quiz",
- "contact": "Contact Us"
+ "contact": "Contact Us",
+ "menu": "Open Menu",
+ "exportRSA": "Export RSA",
+ "exporting": "Exporting...",
+ "downloadAndPrint": "Download & Print",
+ "upgrade": "Upgrade Plan"
},
"message": {
"courseCreateSuccess": "Course created successfully!",
@@ -198,7 +218,16 @@
"course": "Course",
"joinedAt": "Joined At",
"subscription": "Subscription",
- "student": "Student(s)"
+ "student": "Student(s)",
+ "user": "User",
+ "license": "License",
+ "groupName": "Group",
+ "className": "Class Name",
+ "menu": "Open Menu",
+ "score": "Score",
+ "correctAnswer": "Correct Answer",
+ "submissionDate": "Submission Date",
+ "studentGroup": "Student Group"
},
"paging": {
"previous": "Previous",
diff --git a/messages/en/lesson/en_lessonDetails.json b/messages/en/lesson/en_lessonDetails.json
index 60d4903c5..7d3fc440f 100644
--- a/messages/en/lesson/en_lessonDetails.json
+++ b/messages/en/lesson/en_lessonDetails.json
@@ -5,6 +5,7 @@
"learningOutcome": "Learning Outcomes",
"requirements": "Requirements",
"sections": "Sections",
+ "mins": "mins",
"lesson": {
"title": "Lesson",
"about": "About this lesson",
diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json
index 74801d551..169dd9ad4 100644
--- a/messages/en/organization/en_organization.json
+++ b/messages/en/organization/en_organization.json
@@ -5,6 +5,8 @@
"description": "Description",
"image": "Organization Image",
"imageSize": "Image must be less than 5MB",
+ "lesson": "Lesson",
+ "classroom": "Class",
"detail": {
"noData": "No organization data available.",
"noSubscription": "No subscriptions found for this organization.",
@@ -244,6 +246,17 @@
"showing": "Showing",
"results": "results",
"all": "All"
+ },
+ "userTable": {
+ "title": "Manage Users in the Organization",
+ "description": "Browse and manage all organization members registered on the platform.",
+ "student": "Student",
+ "teacher": "Teacher",
+ "admin": "Organization Admin",
+ "placeholder": {
+ "email": "Search by email...",
+ "license": "Select License"
+ }
}
}
}
diff --git a/messages/en/quiz/en_quiz.json b/messages/en/quiz/en_quiz.json
index 25f2c4b80..d9db4e1a9 100644
--- a/messages/en/quiz/en_quiz.json
+++ b/messages/en/quiz/en_quiz.json
@@ -74,6 +74,19 @@
"timeLimit": "Time Limit",
"mins": "mins",
"length": "Length",
+ "noData": "No quiz data available",
+ "yourFinalScore": "Your final score",
+ "attemptHistory": "Attempt History",
+ "noAttempts": "No attempts made yet.",
+ "startedAt": "Started At",
+ "completedAt": "Completed At",
+ "correctAnswers": "Correct Answers",
+ "score": "Score",
+ "duration": "Duration",
+ "status": "Status",
+ "resultDetail": "Result Detail",
+ "complete": "Completed",
+ "incomplete": "Incomplete",
"question": {
"question": "Question",
"singlechoice": "Single Choice",
diff --git a/messages/vi/classroom/vi_classroom.json b/messages/vi/classroom/vi_classroom.json
index 3a6ba6b4d..66f8ada17 100644
--- a/messages/vi/classroom/vi_classroom.json
+++ b/messages/vi/classroom/vi_classroom.json
@@ -2,9 +2,13 @@
"classroom": {
"list": {
"header": "Danh sách lớp học",
+ "description": "Quản lý tất cả các lớp học hiện có của bạn, bao gồm chi tiết lớp học, lịch trình, giáo viên và học sinh đã đăng ký.",
"searchPlaceholder": "Tìm kiếm...",
"selectCoursePlaceholder": "Chọn chương trình học",
- "courses": "khóa học"
+ "selectStatusPlaceholder": "Lọc theo trạng thái",
+ "courses": "khóa học",
+ "noClassroom": "Không tìm thấy lớp học nào.",
+ "noClassroomSubtext": "Thử điều chỉnh tìm kiếm hoặc bộ lọc để tìm những gì bạn đang tìm kiếm."
},
"update": {
"basicInfo": {
diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json
index 748c43460..7031b9556 100644
--- a/messages/vi/common/vi_common.json
+++ b/messages/vi/common/vi_common.json
@@ -1,5 +1,20 @@
{
"common": {
+ "noData": "Không tìm thấy",
+ "breadcrumb": {
+ "home": "Trang Chủ",
+ "course": "Khóa Học",
+ "courses": "Khóa Học",
+ "resource": "Tài Nguyên",
+ "lesson": "Bài Học",
+ "lessons": "Bài Học",
+ "activities": "Hoạt Động",
+ "courseDetail": "Chi Tiết Khóa Học",
+ "classroom": "Lớp Học",
+ "classrooms": "Lớp Học",
+ "classroomDetail": "Chi Tiết Lớp Học",
+ "createClassroom": "Tạo Lớp Học"
+ },
"search": {
"placeholder": "Tìm kiếm...",
"noResults": "Không tìm thấy kết quả."
@@ -115,7 +130,12 @@
"continueLearning": "Tiếp tục Học",
"markAsComplete": "Đánh dấu hoàn thành",
"startQuiz": "Bắt Đầu Bài Kiểm Tra",
- "contact": "Liên Hệ Ngay"
+ "contact": "Liên Hệ Ngay",
+ "actions": "Thao tác",
+ "exportRSA": "Xuất RSA",
+ "exporting": "Đang Xuất...",
+ "downloadAndPrint": "Tải Xuống & In",
+ "upgrade": "Nâng Cấp Gói"
},
"message": {
"courseCreateSuccess": "Khóa học được tạo thành công!",
@@ -193,11 +213,20 @@
"numberOfStudents": "Số Học Sinh",
"numberOfLessons": "Số Bài Học",
"accountType": "Loại Tài Khoản",
- "assignedDate": "Ngày Gán",
+ "assignedDate": "Ngày Tạo",
"course": "Khóa Học",
"joinedAt": "Ngày tham gia",
"subscription": "Gói đăng ký",
- "student": "Học sinh"
+ "student": "Học sinh",
+ "className": "Lớp",
+ "user": "Người dùng",
+ "license": "Vai trò",
+ "groupName": "Nhóm",
+ "menu": "Mở Menu",
+ "score": "Điểm",
+ "correctAnswer": "Đáp án đúng",
+ "submissionDate": "Ngày nộp bài",
+ "studentGroup": "Nhóm học sinh"
},
"paging": {
"previous": "Trước",
@@ -251,6 +280,7 @@
"active": "Đã xác thực",
"inactive": "Chưa xác thực"
},
+
"accountType": {
"accountTypeLabel": "Loại Tài Khoản",
"admin": "Quản Trị Viên",
diff --git a/messages/vi/lesson/vi_lessonDetails.json b/messages/vi/lesson/vi_lessonDetails.json
index 83e024808..08abce40e 100644
--- a/messages/vi/lesson/vi_lessonDetails.json
+++ b/messages/vi/lesson/vi_lessonDetails.json
@@ -5,6 +5,7 @@
"learningOutcome": "Mục Tiêu Học Tập",
"requirements": "Yêu Cầu",
"sections": "Chuyên mục",
+ "mins": "phút",
"lesson": {
"title": "Bài Học",
"about": "Giới thiệu về bài học này",
diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json
index d105108e5..e8a317c1c 100644
--- a/messages/vi/organization/vi_organization.json
+++ b/messages/vi/organization/vi_organization.json
@@ -5,6 +5,8 @@
"description": "Mô tả",
"image": "Ảnh tổ chức",
"imageSize": "Ảnh phải nhỏ hơn 5MB",
+ "lesson": "Bài học",
+ "classroom": "Lớp học",
"detail": {
"noData": "Không có dữ liệu tổ chức nào.",
"noSubscription": "Không có gói đăng ký nào cho tổ chức này.",
@@ -243,6 +245,17 @@
"showing": "Hiển thị",
"results": "kết quả",
"all": "Tất cả"
+ },
+ "userTable": {
+ "title": "Quản lý người dùng trong Tổ chức",
+ "description": "Duyệt và quản lý tất cả các thành viên tổ chức đã đăng ký trên nền tảng.",
+ "student": "Học sinh",
+ "teacher": "Giáo viên",
+ "admin": "Quản trị viên",
+ "placeholder": {
+ "email": "Tìm kiếm theo email...",
+ "license": "Chọn vai trò"
+ }
}
}
}
diff --git a/messages/vi/quiz/vi_quiz.json b/messages/vi/quiz/vi_quiz.json
index 1c080ab6e..751408f73 100644
--- a/messages/vi/quiz/vi_quiz.json
+++ b/messages/vi/quiz/vi_quiz.json
@@ -74,6 +74,20 @@
"timeLimit": "Giới hạn thời gian",
"mins": "phút",
"length": "Độ dài",
+ "noData": "Không có dữ liệu quiz",
+ "startedAt": "Bắt đầu lúc",
+ "completedAt": "Kết thúc lúc",
+ "duration": "Thời gian thực hiện",
+ "status": "Trạng thái",
+ "correctAnswers": "Số câu đúng",
+ "score": "Điểm số",
+ "yourFinalScore": "Điểm cuối cùng của bạn",
+ "attemptHistory": "Lịch sử làm bài",
+ "submissionDate": "Ngày nộp bài",
+ "noAttempts": "Chưa có lần làm bài nào.",
+ "resultDetail": "Chi tiết kết quả",
+ "complete": "Hoàn thành",
+ "incomplete": "Chưa hoàn thành",
"question": {
"question": "Câu hỏi",
"singlechoice": "Chọn một đáp án",
diff --git a/package-lock.json b/package-lock.json
index adf27485e..4c34baecb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -144,6 +144,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/three": "^0.181.0",
"@types/w3c-web-serial": "^1.0.8",
"@types/w3c-web-usb": "^1.0.13",
"autoprefixer": "^10.4.21",
@@ -5131,10 +5132,9 @@
"license": "MIT"
},
"node_modules/@types/three": {
- "version": "0.180.0",
- "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
- "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
- "license": "MIT",
+ "version": "0.181.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.181.0.tgz",
+ "integrity": "sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
diff --git a/package.json b/package.json
index 4c02cec30..78b8f7432 100644
--- a/package.json
+++ b/package.json
@@ -150,6 +150,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/three": "^0.181.0",
"@types/w3c-web-serial": "^1.0.8",
"@types/w3c-web-usb": "^1.0.13",
"autoprefixer": "^10.4.21",
diff --git a/src/components/layout/header/header-action/AuthStatusMenu.tsx b/src/components/layout/header/header-action/AuthStatusMenu.tsx
index 8e5c046fe..17a58c347 100644
--- a/src/components/layout/header/header-action/AuthStatusMenu.tsx
+++ b/src/components/layout/header/header-action/AuthStatusMenu.tsx
@@ -59,6 +59,7 @@ function MenuItem({
export default function AuthStatusMenu() {
const t = useTranslations('Header')
+ const tc = useTranslations('common')
const { data: session, status } = useSession()
const router = useRouter()
const locale = useLocale()
@@ -158,7 +159,7 @@ export default function AuthStatusMenu() {
onClick={() => router.push(`/${locale}/plans`)}
>
- Upgrade
+ {tc('button.upgrade')}
diff --git a/src/components/shared/SBreadcrumb.tsx b/src/components/shared/SBreadcrumb.tsx
index c54e7da7a..7cc05a0dd 100644
--- a/src/components/shared/SBreadcrumb.tsx
+++ b/src/components/shared/SBreadcrumb.tsx
@@ -9,7 +9,7 @@ import {
} from '@/components/shadcn/breadcrumb'
import { textVariants } from '@/utils/shadcn/variants'
import { VariantProps } from 'class-variance-authority'
-import { useLocale } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Fragment } from 'react'
@@ -33,15 +33,36 @@ function resolveHref(href: string): string {
return href
}
+function isIdSegment(segment: string) {
+ return /^\d+$/.test(segment)
+}
+
+const IGNORE_SEGMENTS = ['learn', 'edit', 'view', 'preview']
+
export default function SBreadcrumb({ title, size = 'md', color, weight }: SBreadcrumbProps) {
+ const tc = useTranslations('common.breadcrumb')
const pathname = usePathname()
const locale = useLocale()
const segments = pathname
.split('/')
.filter((segment) => segment !== locale)
.filter(Boolean)
+ .filter(
+ (segment) =>
+ !isIdSegment(segment) && // ✅ bỏ id
+ !IGNORE_SEGMENTS.includes(segment) // ✅ bỏ learn
+ )
function formatLabel(segment: string): string {
+ const key = segment.replace(/-/g, '_') // nếu muốn hỗ trợ kebab-case
+
+ // thử dịch
+ const translated = tc(key)
+
+ // next-intl: nếu không có key -> trả về chính key
+ if (translated !== key) return translated
+
+ // fallback: format thủ công
return segment.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase())
}
@@ -52,25 +73,31 @@ export default function SBreadcrumb({ title, size = 'md', color, weight }: SBrea
href
}
})
- const allItems = [{ label: 'Home', href: `/${locale}` }, ...items]
+ const allItems = [{ label: tc('home'), href: `/${locale}` }, ...items]
return (
- {allItems.map((item) => (
+ {allItems.map((item, index) => (
- {item.href === pathname ? (
- {title || item.label}
- ) : (
-
- {item.label}
-
- )}
+
+ {item.label}
+
- {item.href !== pathname && }
+
+ {index < allItems.length - 1 && }
))}
+
+ {title && (
+ <>
+
+
+ {title}
+
+ >
+ )}
)
diff --git a/src/components/shared/button/ExportRSAButton.tsx b/src/components/shared/button/ExportRSAButton.tsx
index 6924cbcad..3bafec45c 100644
--- a/src/components/shared/button/ExportRSAButton.tsx
+++ b/src/components/shared/button/ExportRSAButton.tsx
@@ -2,7 +2,12 @@ import { Button } from '@/components/shadcn/button'
import { useLazyExportToRSAQuery } from '@/features/resource/export/api/exportApi'
import { useEffect } from 'react'
-export default function ExportRSAButton({ courseId }: { courseId: number }) {
+type ExportRSAButtonProps = {
+ courseId: number
+ className?: string
+}
+
+export default function ExportRSAButton({ courseId, className = 'w-full' }: ExportRSAButtonProps) {
const [triggerExport, { data: exportData, isLoading: isExporting }] = useLazyExportToRSAQuery()
useEffect(() => {
@@ -35,7 +40,12 @@ export default function ExportRSAButton({ courseId }: { courseId: number }) {
}, [exportData])
return (
-
-
{tClassroom('detail.classCode.description')}
+ {/* {tClassroom('detail.classCode.description')}
*/}
@@ -355,7 +355,7 @@ export default function OrganizationClassroomDetail() {
)}
{/* Google Meet Card */}
-
+ {/*
@@ -380,7 +380,7 @@ export default function OrganizationClassroomDetail() {
-
+ */}
{/* Quick Stats Card */}
@@ -403,11 +403,13 @@ export default function OrganizationClassroomDetail() {
{tClassroom('detail.quickStats.duration')}
- {Math.ceil(
- (new Date(classroom.endDate).getTime() - new Date(classroom.startDate).getTime()) /
- (1000 * 60 * 60 * 24)
- )}{' '}
- days
+
+
+
+ {formatDate(classroom.startDate, { locale: locale as 'en' | 'vi' })} -{' '}
+ {formatDate(classroom.endDate, { locale: locale as 'en' | 'vi' })}
+
+
diff --git a/src/features/classroom/components/detail/StudentClassroomDetails.tsx b/src/features/classroom/components/detail/StudentClassroomDetails.tsx
index cadffa88e..aa38d7a16 100644
--- a/src/features/classroom/components/detail/StudentClassroomDetails.tsx
+++ b/src/features/classroom/components/detail/StudentClassroomDetails.tsx
@@ -212,7 +212,6 @@ export default function StudentClassroomDetail({ courseEnrollment }: StudentClas
- {tClassroom('detail.classCode.description')}
@@ -248,7 +247,7 @@ export default function StudentClassroomDetail({ courseEnrollment }: StudentClas
)}
{/* Google Meet Card */}
-
+ {/*
@@ -265,7 +264,7 @@ export default function StudentClassroomDetail({ courseEnrollment }: StudentClas
-
+ */}
diff --git a/src/features/classroom/components/list/ClassroomList.tsx b/src/features/classroom/components/list/ClassroomList.tsx
index 1d7d93716..80c05bc70 100644
--- a/src/features/classroom/components/list/ClassroomList.tsx
+++ b/src/features/classroom/components/list/ClassroomList.tsx
@@ -5,10 +5,10 @@ import { ClassroomStatus } from '@/features/classroom/types/classroom.type'
import { Badge } from '@/components/shadcn/badge'
import { Card, CardContent } from '@/components/shadcn/card'
import { Users, BookOpen, Clock, GraduationCap } from 'lucide-react'
-import React, { useState } from 'react'
+import React, { useEffect, useState } from 'react'
import { getStatusBadgeClass } from '@/utils/badgeColor'
import Link from 'next/link'
-import { useAppSelector } from '@/hooks/redux-hooks'
+import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks'
import SEmpty from '@/components/shared/empty/SEmpty'
import { SkeletonCard } from '@/components/shared/skeleton/SkeletonCard'
import SearchBar from '@/components/shared/search/SearchBar'
@@ -16,37 +16,37 @@ import SearchBar from '@/components/shared/search/SearchBar'
import SSelect from '@/components/shared/SSelect'
import { useLocale, useTranslations } from 'next-intl'
import { formatDate, useStatusTranslation } from '@/utils/index'
+import { resetParams } from '@/features/classroom/slice/classroomSlice'
export default function ClassroomList() {
const locale = useLocale()
- const statusTranslations = useStatusTranslation()
- const tClassroom = useTranslations('classroom.myLearning')
+ const statusTranslation = useStatusTranslation()
+ const tClassroom = useTranslations('classroom')
+ const dispatch = useAppDispatch()
+ const queryParams = useAppSelector((state) => state.classroom)
const { selectedOrgUserId } = useAppSelector((state) => state.selectedOrganization)
- const queryParams = useAppSelector((state) => state.classroom)
- const [selectedStatus, setSelectedStatus] = useState('')
+ const [search, setSearch] = useState('')
+ const [statusFilter, setStatusFilter] = useState('all')
+ const statusQuery = statusFilter === 'all' ? undefined : statusFilter
const { data, isLoading, error } = useSearchClassroomsQuery(
{
...queryParams,
- studentId: selectedOrgUserId
+ studentId: selectedOrgUserId,
+ search: search || undefined,
+ status: statusQuery as ClassroomStatus | undefined
},
{ skip: !selectedOrgUserId }
)
const classrooms = data?.data.items || []
- if (isLoading) {
- return (
-
-
-
-
-
- )
- }
+ useEffect(() => {
+ dispatch(resetParams())
+ }, [dispatch])
- if (error || !classrooms || classrooms.length === 0) {
+ if (error) {
return (
@@ -56,49 +56,47 @@ export default function ClassroomList() {
const statusOptions = Object.values(ClassroomStatus).map((status) => ({
value: status,
- label: status
+ label: statusTranslation(status)
}))
return (
- {/* Header */}
-
-
+ setSearch(val)}
+ />
setSelectedStatus(value)}
- options={statusOptions}
- className='w-fit'
+ placeholder={tClassroom('list.selectStatusPlaceholder')}
+ value={statusFilter}
+ onChange={(value) => setStatusFilter(value)}
+ options={statusOptions.filter(
+ (option) => option.value !== ClassroomStatus.DELETED && option.value !== ClassroomStatus.PENDING
+ )}
+ className='w-48'
/>
{/* Classroom Grid */}
-
+
{classrooms.map((classroom) => (
-
+
{/* Image Header */}
-
+
{classroom.course?.imageUrl ? (

) : (
)}
-
- {/* Status Badge */}
-
-
- {statusTranslations(classroom.status)}
-
-
@@ -106,12 +104,14 @@ export default function ClassroomList() {
{/* Title & Grade */}
{classroom.name}
-
- {tClassroom('grade')} {classroom.grade}
+ {/* Status Badge */}
+
+ {statusTranslation(classroom.status)}
- {/* Curriculum */}
{classroom.course && (
@@ -123,8 +123,7 @@ export default function ClassroomList() {
- {formatDate(classroom.startDate, { locale: locale })} -{' '}
- {formatDate(classroom.endDate, { locale: locale })}
+ {formatDate(classroom.startDate, { locale })} - {formatDate(classroom.endDate, { locale })}
diff --git a/src/features/classroom/components/list/TeacherClassroomList.tsx b/src/features/classroom/components/list/TeacherClassroomList.tsx
index 0c19381f3..dbb3bcbd6 100644
--- a/src/features/classroom/components/list/TeacherClassroomList.tsx
+++ b/src/features/classroom/components/list/TeacherClassroomList.tsx
@@ -5,7 +5,6 @@ import { ClassroomStatus } from '@/features/classroom/types/classroom.type'
import { Badge } from '@/components/shadcn/badge'
import { Card, CardContent } from '@/components/shadcn/card'
import { Users, BookOpen, Clock, GraduationCap } from 'lucide-react'
-import { format } from 'date-fns'
import React, { useEffect, useState } from 'react'
import { getStatusBadgeClass } from '@/utils/badgeColor'
import Link from 'next/link'
@@ -13,131 +12,154 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks'
import SEmpty from '@/components/shared/empty/SEmpty'
import { SkeletonCard } from '@/components/shared/skeleton/SkeletonCard'
import SearchBar from '@/components/shared/search/SearchBar'
-
import SSelect from '@/components/shared/SSelect'
+import { useLocale, useTranslations } from 'next-intl'
+import { formatDate, useStatusTranslation } from '@/utils/index'
+import BreadcrumbPageLayout from '@/components/shared/layout/BreadcrumbPageLayout'
import { resetParams } from '@/features/classroom/slice/classroomSlice'
export default function TeacherClassroomList() {
- const user = useAppSelector((state) => state.auth?.user)
- const queryParams = useAppSelector((state) => state.classroom)
- const [selectedStatus, setSelectedStatus] = useState<'all' | ClassroomStatus>('all')
+ const locale = useLocale()
+ const t = useTranslations('classroom')
+ const statusTranslation = useStatusTranslation()
+
const dispatch = useAppDispatch()
+ const queryParams = useAppSelector((state) => state.classroom)
+
+ const [search, setSearch] = useState('')
+ const [statusFilter, setStatusFilter] = useState('all')
+
+ const statusQuery = statusFilter === 'all' ? undefined : statusFilter
- const { data, isLoading, error } = useSearchClassroomsQuery({
- ...queryParams,
- teacherId: user?.userId
- })
+ const { selectedOrgUserId } = useAppSelector((state) => state.selectedOrganization)
+
+ const { data, isLoading, error } = useSearchClassroomsQuery(
+ {
+ ...queryParams,
+ teacherId: selectedOrgUserId ?? undefined,
+ search: search || undefined,
+ status: statusQuery as ClassroomStatus | undefined
+ },
+ { skip: !selectedOrgUserId }
+ )
const classrooms = data?.data.items || []
- // reset classroom store filters first time load
+
useEffect(() => {
dispatch(resetParams())
}, [dispatch])
- if (isLoading) {
+ if (error) {
return (
-
-
-
-
+
+
)
}
- if (error || !classrooms) {
- return
- }
-
const statusOptions = Object.values(ClassroomStatus).map((status) => ({
- value: status,
- label: status
+ label: statusTranslation(status),
+ value: status
}))
return (
-
+
{/* Header */}
-
-
-
- setSelectedStatus(value as ClassroomStatus | 'all')}
- options={statusOptions}
- className='w-fit'
- />
-
-
- {classrooms.length === 0 && (
-
- )}
-
- {/* Classroom Grid */}
-
- {classrooms.map((classroom) => (
-
-
- {/* Image Header */}
-
- {classroom.course?.imageUrl ? (
-

- ) : (
-
-
-
- )}
-
- {/* Status Badge */}
-
-
- {classroom.status}
-
+
+
+
{t('list.header')}
+
{t('list.description')}
+
+
+
+ setSearch(val)}
+ />
+ setStatusFilter(value)}
+ options={statusOptions.filter((option) => option.value !== ClassroomStatus.DELETED)}
+ />
+
+
+ {isLoading && (
+
+
+
+
+
+ \
+
+
+
+ )}
+
+ {/* Classroom Grid */}
+
+ {classrooms.map((classroom) => (
+
+
+ {/* Image Header */}
+
+ {classroom.course?.imageUrl ? (
+

+ ) : (
+
+
+
+ )}
-
-
-
-
- {/* Title & Grade */}
-
-
{classroom.name}
-
- {classroom.grade}
-
-
- {classroom.course && (
-
-
-
{classroom.course.title}
+
+
+ {/* Title & Grade */}
+
+
{classroom.name}
+ {/* Status Badge */}
+
+ {statusTranslation(classroom.status)}
+
- )}
- {/* Date */}
-
-
-
- {format(new Date(classroom.startDate), 'MMM dd')} -{' '}
- {format(new Date(classroom.endDate), 'MMM dd, yyyy')}
-
-
+ {classroom.course && (
+
+
+ {classroom.course.title}
+
+ )}
+
+ {/* Date */}
+
+
+
+ {formatDate(classroom.startDate, { locale })} - {formatDate(classroom.endDate, { locale })}
+
+
- {/* Students */}
-
-
-
-
{classroom.numberOfStudents}
+ {/* Students */}
+
+
+
+ {classroom.numberOfStudents}
+
-
-
-
-
- ))}
+
+
+
+ ))}
+
-
+
)
}
diff --git a/src/features/classroom/components/list/table/ClassroomColumn.tsx b/src/features/classroom/components/list/table/ClassroomColumn.tsx
index f5a315486..a781292e4 100644
--- a/src/features/classroom/components/list/table/ClassroomColumn.tsx
+++ b/src/features/classroom/components/list/table/ClassroomColumn.tsx
@@ -57,10 +57,10 @@ export function useGetClassroomColumn(): ColumnDef[] {
}
},
{
- accessorKey: 'classCode',
- header: tc('tableHeader.classCode'),
+ accessorKey: 'name',
+ header: () => {tc('tableHeader.className')}
,
cell: ({ row }) => {
- return {row.original.classCode}
+ return {row.original.name}
}
},
{
@@ -69,11 +69,6 @@ export function useGetClassroomColumn(): ColumnDef[] {
cell: ({ row }) => {}
},
- {
- accessorKey: 'grade',
- header: tc('tableHeader.grade')
- },
-
{
accessorKey: 'teacherNameAndEmail',
header: () => {tc('tableHeader.teacher')}
,
diff --git a/src/features/classroom/components/schedule/ClassroomSchedule.tsx b/src/features/classroom/components/schedule/ClassroomSchedule.tsx
index bf06b79c2..4c3d40a58 100644
--- a/src/features/classroom/components/schedule/ClassroomSchedule.tsx
+++ b/src/features/classroom/components/schedule/ClassroomSchedule.tsx
@@ -1,6 +1,7 @@
import { useGetClassroomScheduleQuery } from '@/features/classroom/api/classroomApi'
import { cn } from '@/utils/shadcn/utils'
import { useTranslations } from 'next-intl'
+import { useRouter } from 'next/navigation'
interface ClassroomScheduleProps {
classroomId: number
@@ -8,8 +9,9 @@ interface ClassroomScheduleProps {
}
export function ClassroomSchedule({ classroomId, className }: ClassroomScheduleProps) {
- const t = useTranslations('dashboard.classroom.course')
+ const router = useRouter()
const tc = useTranslations('common')
+ const t = useTranslations('dashboard.classroom.course')
const { data, isLoading, error } = useGetClassroomScheduleQuery({ classroomId })
if (isLoading) {
@@ -42,7 +44,10 @@ export function ClassroomSchedule({ classroomId, className }: ClassroomScheduleP
{schedule.courseSchedule.map((courseSchedule) => (
{/* Course Header */}
-
+
router.push(`/resource/course/${courseSchedule.courseId}/learn`)}
+ >
{courseSchedule.courseTitle}
diff --git a/src/features/classroom/components/ui/ClassroomSubHeader.tsx b/src/features/classroom/components/ui/ClassroomSubHeader.tsx
index d92ebbf4b..634cd298a 100644
--- a/src/features/classroom/components/ui/ClassroomSubHeader.tsx
+++ b/src/features/classroom/components/ui/ClassroomSubHeader.tsx
@@ -1,27 +1,32 @@
'use client'
-import Link from 'next/link'
-import { usePathname } from 'next/navigation'
import { cn } from '@/utils/shadcn/utils'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/shadcn/avatar'
-import { Button } from '@/components/shadcn/button'
-import { ArrowLeft, Calendar, GraduationCap } from 'lucide-react'
+import { Calendar, GraduationCap } from 'lucide-react'
import { Badge } from '@/components/shadcn/badge'
import { getStatusBadgeClass } from '@/utils/badgeColor'
import { Classroom } from '@/features/classroom/types/classroom.type'
-import { format } from 'date-fns'
import { ClassroomNavItems } from 'app/[locale]/classroom/[classroomId]/page'
+import { formatDate, useStatusTranslation } from '@/utils/index'
+import BackButton from '@/components/shared/button/BackButton'
interface Props {
- curriculumId?: number
classroom: Classroom
currentTab: ClassroomNavItems
setCurrentTab: (tab: ClassroomNavItems) => void
}
-export default function ClassroomSubHeader({ classroom, curriculumId, currentTab, setCurrentTab }: Props) {
+export default function ClassroomSubHeader({ classroom, currentTab, setCurrentTab }: Props) {
+ const locale = useLocale()
const t = useTranslations('Header')
+ const statusTranslation = useStatusTranslation()
+ const tClassroom = useTranslations('classroom.detail')
+
+ const MAX_VISIBLE = 2
+ const totalStudents = classroom.students.length
+ const visibleStudents = classroom.students.slice(0, MAX_VISIBLE)
+ const remaining = totalStudents - MAX_VISIBLE
const subNavItems: { name: string; currentTab: ClassroomNavItems }[] = [
{ name: 'overview', currentTab: 'overview' },
@@ -41,14 +46,7 @@ export default function ClassroomSubHeader({ classroom, curriculumId, currentTab
{/* Left - Classroom Info */}
-
window.history.back()}
- >
-
-
+
{classroom.name?.charAt(0).toUpperCase() ?? 'C'}
@@ -56,37 +54,46 @@ export default function ClassroomSubHeader({ classroom, curriculumId, currentTab
{classroom.name ?? 'Classroom'}
- {classroom.status}
+
+ {statusTranslation(classroom.status)}
+
- {classroom.grade}
+
+ {tClassroom('grade')} {classroom.grade}
+
-
Students:
-
-
-
- ST
+ {tClassroom('students.label')}:
+ {visibleStudents.map((student, index) => (
+ 0 && '-ml-2.5')}
+ >
+
+
+ {student.name
+ .split(' ')
+ .map((n: string) => n[0])
+ .join('')
+ .slice(0, 2)
+ .toUpperCase()}
+
-
-
- AI
+ ))}
+
+ {remaining > 0 && (
+
+ +{remaining}
-
- +
-
-
+ )}
+
- {format(new Date(classroom.startDate), 'MMM dd, yyyy')} -{' '}
- {format(new Date(classroom.endDate), 'MMM dd, yyyy')}
+ {formatDate(classroom.startDate, { locale })} - {formatDate(classroom.endDate, { locale })}
diff --git a/src/features/classroom/components/upsert/CreateClassroom.tsx b/src/features/classroom/components/upsert/CreateClassroom.tsx
index a3c03f6af..a50188b63 100644
--- a/src/features/classroom/components/upsert/CreateClassroom.tsx
+++ b/src/features/classroom/components/upsert/CreateClassroom.tsx
@@ -192,7 +192,7 @@ export default function CreateClassroom() {
-
setSelectedGroups(groups)} />
+ setSelectedGroups(groups)} />
{/* Basic Information Section */}
diff --git a/src/features/classroom/types/classroom.type.ts b/src/features/classroom/types/classroom.type.ts
index d0bc13881..55a576511 100644
--- a/src/features/classroom/types/classroom.type.ts
+++ b/src/features/classroom/types/classroom.type.ts
@@ -22,7 +22,12 @@ export type Classroom = {
classCode: string
status: ClassroomStatus
numberOfStudents: number
- students: any[]
+ students: {
+ id: string
+ name: string
+ email: string
+ imageUrl: string
+ }[]
course: Course
// curriculum: Pick
organizationSubscriptionOrderId: number
@@ -30,14 +35,16 @@ export type Classroom = {
export type ClassroomSliceParams = {
teacherId?: string
- status?: 'upcoming' | 'inprogress' | 'completed' | 'endsoon'
+ status?: ClassroomStatus
courseId?: number
} & SliceQueryParams
// Pending, InProgress, Completed, Deleted
export enum ClassroomStatus {
+ ALL = 'all', // for filter purpose
PENDING = 'Pending',
IN_PROGRESS = 'InProgress',
+ UPCOMING = 'Upcoming',
COMPLETED = 'Completed',
DELETED = 'Deleted'
}
@@ -199,17 +206,25 @@ export type ClassroomStudentGroup = {
studentIds: string[]
}
-// AI Analyses
+// =============== AI ANALYSIS TYPES ===============
+
+export type AiAnalysisRequest = {
+ classroom_id: number
+ force_mock: boolean
+ analysis_period_days: number
+}
+
+export type AiStudentAnalysisResult = {
+ studentId: string
+ progressPercent: number
+ currentStatus: string
+ statusText: string
+ currentSection: string | null
+ interventionText: string
+}
+
export type AiAnalysisResponse = {
- classOverview: string;
- atRiskCount: number;
- atRiskStudents: AtRiskStudentAnalysis[];
-}
-
-export type AtRiskStudentAnalysis = {
- studentId: string;
- studentName: string;
- severity: 'High' | 'Medium' | 'Low';
- reason: string;
- recommendation: string;
-}
\ No newline at end of file
+ overviewText: string
+ students: AiStudentAnalysisResult[]
+ aiInsightsText: string
+}
diff --git a/src/features/creator-3d/components/creator3d/Creator3D.tsx b/src/features/creator-3d/components/creator3d/Creator3D.tsx
index fd7712519..64b5745c4 100644
--- a/src/features/creator-3d/components/creator3d/Creator3D.tsx
+++ b/src/features/creator-3d/components/creator3d/Creator3D.tsx
@@ -27,6 +27,8 @@ import { useParams } from 'next/navigation'
import { useUpdateEmulatorMutation } from '@/features/emulator/api/emulatorApi'
import { ApiSuccessResponse } from '@/types/baseModel'
import { Emulator } from '@/features/emulator/types/emulator.type'
+import { buildSceneFromAssembly } from '@/features/creator-3d/hooks/buildSceneFromAssembly'
+import { exportGLB } from '@/features/creator-3d/hooks/exportGlb'
type Creator3DProps = {
emulatorData: ApiSuccessResponse | undefined
}
@@ -531,6 +533,16 @@ export default function Creator3D({ emulatorData }: Creator3DProps) {
[dispatch]
)
+ const handleExportGLB = async () => {
+ const assembly = exportAssemblyFn({
+ title: `Assembly ${workspaceId}`,
+ description: 'Exported from workspace',
+ author: 'STEMify User'
+ })
+
+ await exportGLB(assembly, 'workspace.glb')
+ }
+
// Thêm vào Creator3D component
const handleFileSelect = useCallback(
async (e: React.ChangeEvent) => {
@@ -599,6 +611,7 @@ export default function Creator3D({ emulatorData }: Creator3DProps) {
0}
/>
diff --git a/src/features/creator-3d/components/creator3d/SceneActions.tsx b/src/features/creator-3d/components/creator3d/SceneActions.tsx
index 947913ea7..514251d44 100644
--- a/src/features/creator-3d/components/creator3d/SceneActions.tsx
+++ b/src/features/creator-3d/components/creator3d/SceneActions.tsx
@@ -4,9 +4,10 @@ interface SceneActionsProps {
onSave: () => void
onImportJSON?: () => void
hasObjects: boolean
+ onExportGLB?: () => void
}
-export function SceneActions({ onSave, onImportJSON, hasObjects }: SceneActionsProps) {
+export function SceneActions({ onSave, onImportJSON, onExportGLB }: SceneActionsProps) {
const t3d = useTranslations('creator3D.main_content')
return (
@@ -23,6 +24,13 @@ export function SceneActions({ onSave, onImportJSON, hasObjects }: SceneActionsP
>
{t3d('import_assembly')}
+
+
+ {t3d('export_glb')}
+
)
}
diff --git a/src/features/creator-3d/hooks/buildSceneFromAssembly.ts b/src/features/creator-3d/hooks/buildSceneFromAssembly.ts
new file mode 100644
index 000000000..8b29d61a3
--- /dev/null
+++ b/src/features/creator-3d/hooks/buildSceneFromAssembly.ts
@@ -0,0 +1,59 @@
+// buildSceneFromAssembly.ts
+import * as THREE from 'three'
+import { Assembly, ExportedAssembly } from '@/features/assembly/types/assembly.types'
+import { GLTFLoader } from 'three-stdlib'
+
+export async function buildSceneFromAssembly(assembly: ExportedAssembly): Promise
{
+ const scene = new THREE.Scene()
+
+ /* ================= ENVIRONMENT ================= */
+ scene.background = new THREE.Color(assembly.scene.environment.background)
+
+ const ambient = new THREE.AmbientLight(assembly.scene.environment.lighting.ambient)
+ scene.add(ambient)
+
+ const dir = assembly.scene.environment.lighting.directional
+ const directional = new THREE.DirectionalLight(dir.color, dir.intensity)
+ directional.position.set(dir.position.x, dir.position.y, dir.position.z)
+ scene.add(directional)
+
+ /* ================= STRAWS ================= */
+ for (const strawGroup of assembly.instances.straws) {
+ for (const instance of strawGroup.instances) {
+ const geometry = new THREE.CylinderGeometry(0.3, 0.3, 10, 16)
+ const material = new THREE.MeshStandardMaterial({ color: '#4f46e5' })
+
+ const mesh = new THREE.Mesh(geometry, material)
+
+ mesh.position.set(instance.transform.position.x, instance.transform.position.y, instance.transform.position.z)
+
+ mesh.rotation.set(instance.transform.rotation.x, instance.transform.rotation.y, instance.transform.rotation.z)
+
+ mesh.scale.set(1,1,1)
+
+ mesh.name = instance.id
+ scene.add(mesh)
+ }
+ }
+
+ /* ================= CONNECTORS ================= */
+ const loader = new GLTFLoader()
+
+ for (const connectorGroup of assembly.instances.connectors) {
+ for (const instance of connectorGroup.instances) {
+ const gltf = await loader.loadAsync('/models/connector_3legs.glb')
+ const mesh = gltf.scene.clone()
+
+ mesh.position.set(instance.transform.position.x, instance.transform.position.y, instance.transform.position.z)
+
+ mesh.rotation.set(instance.transform.rotation.x, instance.transform.rotation.y, instance.transform.rotation.z)
+
+ mesh.scale.set(1,1,1)
+
+ mesh.name = instance.id
+ scene.add(mesh)
+ }
+ }
+
+ return scene
+}
diff --git a/src/features/creator-3d/hooks/exportGlb.ts b/src/features/creator-3d/hooks/exportGlb.ts
new file mode 100644
index 000000000..8e74ff399
--- /dev/null
+++ b/src/features/creator-3d/hooks/exportGlb.ts
@@ -0,0 +1,32 @@
+// exportGlb.ts
+import { buildSceneFromAssembly } from './buildSceneFromAssembly'
+import { ExportedAssembly } from '@/features/assembly/types/assembly.types'
+import { GLTFExporter } from 'three-stdlib'
+
+export async function exportGLB(assembly: ExportedAssembly, fileName = 'assembly.glb') {
+ const scene = await buildSceneFromAssembly(assembly)
+
+ const exporter = new GLTFExporter()
+
+ exporter.parse(
+ scene,
+ (glb) => {
+ const blob = new Blob([glb as ArrayBuffer], {
+ type: 'model/gltf-binary'
+ })
+
+ const url = URL.createObjectURL(blob)
+
+ const a = document.createElement('a')
+ a.href = url
+ a.download = fileName
+ a.click()
+
+ URL.revokeObjectURL(url)
+ },
+ (error) => {
+ console.error('GLB export error:', error)
+ },
+ { binary: true }
+ )
+}
diff --git a/src/features/dashboard/components/table/StudentProgressStatistic.tsx b/src/features/dashboard/components/table/StudentProgressStatistic.tsx
index 84e50415a..9f4e760ce 100644
--- a/src/features/dashboard/components/table/StudentProgressStatistic.tsx
+++ b/src/features/dashboard/components/table/StudentProgressStatistic.tsx
@@ -1,35 +1,26 @@
'use client'
import * as React from 'react'
-import { Download, CheckCircle2, Circle, Clock, Bot, AlertTriangle, Sparkles, X, BrainCircuit } from 'lucide-react'
+import { Download, CheckCircle2, Circle, Clock, Bot, AlertTriangle, Sparkles, BrainCircuit } from 'lucide-react'
import { Button } from '@/components/shadcn/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/shadcn/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/shadcn/table'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/shadcn/accordion'
-import { useGetClassroomByIdQuery, useGetClassroomStudentProgressQuery } from '@/features/classroom/api/classroomApi'
-import { StudentProgressItem } from '@/features/classroom/types/classroom.type'
+import {
+ useAnalyzeClassroomProgressMutation,
+ useGetClassroomByIdQuery,
+ useGetClassroomStudentProgressQuery,
+} from '@/features/classroom/api/classroomApi'
+import { StudentProgressItem, AiStudentAnalysisResult } from '@/features/classroom/types/classroom.type'
import { useTranslations } from 'next-intl'
import Loading from 'app/[locale]/loading'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/shadcn/tooltip'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/shadcn/card'
import { Badge } from '@/components/shadcn/badge'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/shadcn/dialog'
-import { ScrollArea } from '@/components/shadcn/scroll-area'
-
-// --- TYPES MOCK ---
-type AtRiskStudentAnalysis = {
- studentId: string
- severity: 'High' | 'Medium'
- reason: string
- recommendation: string
-}
-
-type AiAnalysisResponse = {
- classOverview: string
- atRiskStudents: AtRiskStudentAnalysis[]
-}
+import { toast } from 'sonner' // Thêm toast để báo lỗi nếu cần
interface CourseType {
id: number
@@ -51,10 +42,14 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre
const [currentLessonId, setCurrentLessonId] = React.useState('')
// AI States
- const [isAnalyzing, setIsAnalyzing] = React.useState(false)
- const [aiData, setAiData] = React.useState(null)
- const [filterAtRisk, setFilterAtRisk] = React.useState(false) // State để toggle filter học sinh yếu
- const [selectedAnalysisStudent, setSelectedAnalysisStudent] = React.useState(null) // State cho modal chi tiết
+ const [aiData, setAiData] = React.useState<{
+ overviewText: string;
+ students: AiStudentAnalysisResult[];
+ atRiskCount: number;
+ } | null>(null)
+
+ const [filterAtRisk, setFilterAtRisk] = React.useState(false)
+ const [selectedAnalysisStudent, setSelectedAnalysisStudent] = React.useState(null)
// --- QUERIES ---
const { data: classroomRes } = useGetClassroomByIdQuery(classroomId, {
@@ -62,6 +57,8 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre
})
const curriculum = classroomRes?.data?.course
+ const [analyzeTrigger, { isLoading: isAnalyzing }] = useAnalyzeClassroomProgressMutation()
+
React.useEffect(() => {
if (courses.length > 0 && !selectedCourseId) {
setSelectedCourseId(String(courses[0].id))
@@ -88,32 +85,33 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre
const currentLesson = lessons.find((l) => String(l.lessonId) === currentLessonId)
- // --- MOCK API CALL ---
const handleAnalyzeClassroom = async () => {
- if (students.length === 0) return
- setIsAnalyzing(true)
-
- // delay API
- setTimeout(() => {
- const atRiskMock: AtRiskStudentAnalysis[] = students.slice(0, 2).map((s, index) => ({
- studentId: s.studentId,
- severity: index === 0 ? 'High' : 'Medium',
- reason: index === 0
- ? 'Học sinh chưa hoàn thành 3 bài tập liên tiếp và điểm Quiz trung bình dưới 50%.'
- : 'Học sinh có xu hướng nộp bài muộn và thời gian tương tác với bài học thấp.',
- recommendation: index === 0
- ? 'Cần tổ chức buổi phụ đạo 1-1 về kiến thức căn bản của bài Lesson 3.'
- : 'Giáo viên nên nhắc nhở về kỷ luật nộp bài và khuyến khích tham gia thảo luận nhóm.'
- }))
-
- const mockResponse: AiAnalysisResponse = {
- classOverview: `Lớp học đang có tiến độ tốt với 80% học sinh hoàn thành đúng hạn. Tuy nhiên, mức độ hiểu bài ở phần "Advanced Concepts" có vẻ thấp hơn trung bình. Cần chú ý nhóm học sinh có nguy cơ tụt hậu.`,
- atRiskStudents: atRiskMock
+ try {
+ const response = await analyzeTrigger({
+ classroom_id: classroomId,
+ force_mock: false,
+ analysis_period_days: 7
+ }).unwrap()
+
+ if (response.data) {
+ const atRiskStudents = response.data.students.filter(s => s.currentStatus === 'AtRisk')
+
+ setAiData({
+ overviewText: response.data.overviewText || response.data.aiInsightsText,
+ students: atRiskStudents,
+ atRiskCount: atRiskStudents.length
+ })
+
+ if (atRiskStudents.length > 0) {
+ toast.success(`AI found ${atRiskStudents.length} students at risk.`)
+ } else {
+ toast.info("AI analysis complete. Great job! No students currently at risk.")
+ }
}
-
- setAiData(mockResponse)
- setIsAnalyzing(false)
- }, 1500)
+ } catch (error) {
+ console.error("AI Analysis Failed:", error)
+ toast.error("Failed to analyze progress. Please try again later.")
+ }
}
// --- HELPERS ---
@@ -137,10 +135,11 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre
}
}
- // Filter students if toggle is on
const displayedStudents = React.useMemo(() => {
if (!filterAtRisk || !aiData) return students
- const atRiskIds = aiData.atRiskStudents.map(s => s.studentId)
+
+ const atRiskIds = aiData.students.map(s => s.studentId)
+
return students.filter(s => atRiskIds.includes(s.studentId))
}, [students, filterAtRisk, aiData])
@@ -154,7 +153,7 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre
{isAnalyzing ? (
@@ -214,7 +213,7 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre
- "{aiData.classOverview}"
+ "{aiData.overviewText}"
@@ -228,7 +227,7 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre
Students at risk
- {aiData.atRiskStudents.length}
+ {aiData.atRiskCount}
{filterAtRisk && (
@@ -308,8 +307,7 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre
{displayedStudents.length > 0 ? (
displayedStudents.map((student) => {
- // Check if this student is marked as at-risk by AI
- const atRiskInfo = aiData?.atRiskStudents.find(s => s.studentId === student.studentId);
+ const atRiskInfo = aiData?.students.find(s => s.studentId === student.studentId);
return (
- AI Assessment for {selectedAnalysisStudent?.studentId ? students.find(s => s.studentId === selectedAnalysisStudent.studentId)?.studentName : ''}
+ AI Assessment for student ID: {selectedAnalysisStudent?.studentId.substring(0,8)}...
@@ -401,8 +399,8 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre
Risk Severity
-
- {selectedAnalysisStudent.severity} Priority
+
+ {selectedAnalysisStudent.currentStatus === 'AtRisk' ? 'High' : 'Medium'} Priority
@@ -412,7 +410,7 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre
Identified Issues
- {selectedAnalysisStudent.reason}
+ {selectedAnalysisStudent.statusText}
@@ -422,7 +420,7 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre
Recommended Action
- {selectedAnalysisStudent.recommendation}
+ {selectedAnalysisStudent.interventionText}
diff --git a/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx b/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx
index 43cd39ab2..aebeb84d9 100644
--- a/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx
+++ b/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx
@@ -22,6 +22,8 @@ import { EmulatorStatus, EmulatorWithThumbnail } from '@/features/emulator/types
import { useAppSelector } from '@/hooks/redux-hooks'
import { useModal } from '@/providers/ModalProvider'
import { UserRole } from '@/types/userRole'
+import SearchBar from '@/components/shared/search/SearchBar'
+import SSelect from '@/components/shared/SSelect'
export default function Workspace3dLibrary() {
const { openModal } = useModal()
@@ -31,15 +33,30 @@ export default function Workspace3dLibrary() {
const tt = useTranslations('toast')
const t3d = useTranslations('workspace3D')
+ const [search, setSearch] = useState('')
+ const [statusFilter, setStatusFilter] = useState('all')
+
const userRole = useAppSelector((state) => state.auth.user?.userRole)
const allowRoles = [UserRole.STAFF, UserRole.ADMIN]
+ const statusQuery = statusFilter === 'all' ? undefined : statusFilter
- const { data, isLoading } = useSearchEmulationsQuery({ page: 1 })
+ const { data, isLoading } = useSearchEmulationsQuery({
+ page: 1,
+ search,
+ status: statusQuery as EmulatorStatus | undefined
+ })
const [updateEmulation] = useUpdateEmulatorMutation()
const [deleteEmulation] = useDeleteEmulatorMutation()
const emulations = data?.data.items || []
+ const emulationOtptions = [
+ { label: t('status.all'), value: 'all' },
+ { label: t('status.published'), value: EmulatorStatus.PUBLISHED },
+ { label: t('status.draft'), value: EmulatorStatus.DRAFT },
+ { label: t('status.archived'), value: EmulatorStatus.ARCHIVED }
+ ]
+
// === Handlers ===
const handleNavigate = (id: string) => router.push(`/${locale}/lab/workspace-3d/${id}`)
@@ -81,11 +98,27 @@ export default function Workspace3dLibrary() {
if (emulations.length === 0) {
return (
-
+
+
+
+
{t3d('list.title')}
+
openModal('upsertEmulator')}>
- {t3d('button.create')}
+ {t('button.create')}
+
+ setSearch(query)} className='w-96' />
+
+ {/* Placeholder for future filters */}
+ setStatusFilter(value)}
+ className='w-64'
+ />
+
+
+ setSearch(query)} className='w-96' />
+
+ {/* Placeholder for future filters */}
+ setStatusFilter(value)}
+ className='w-64 bg-transparent'
+ />
+
{/* Model list */}
@@ -128,43 +173,50 @@ export default function Workspace3dLibrary() {
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 25vw'
/>
-
-
-
+
+ e.stopPropagation()}
+ >
+
+
+
+
+ {/* Popover menu */}
+ e.stopPropagation()}
>
-
-
-
-
- {/* Popover menu */}
- e.stopPropagation()}>
-
-
openModal('upsertEmulator', { emulationId: e.emulationId })}
- >
- {t('button.update')}
-
- {e.status !== EmulatorStatus.PUBLISHED && userRole && allowRoles.includes(userRole) && (
+
handlePublishEmulation(e.emulationId)}
+ onClick={() => openModal('upsertEmulator', { emulationId: e.emulationId })}
>
- {t('button.publish')}
+ {t('button.update')}
- )}
- handleDeleteEmulation(e)}
- >
- {t('button.delete')}
-
-
-
-
+ {e.status !== EmulatorStatus.PUBLISHED && userRole && allowRoles.includes(userRole) && (
+
handlePublishEmulation(e.emulationId)}
+ >
+ {t('button.publish')}
+
+ )}
+
handleDeleteEmulation(e)}
+ >
+ {t('button.delete')}
+
+
+
+
+ )}
{/* Content */}
diff --git a/src/features/emulator/types/emulator.type.ts b/src/features/emulator/types/emulator.type.ts
index c455ad54c..667af47ec 100644
--- a/src/features/emulator/types/emulator.type.ts
+++ b/src/features/emulator/types/emulator.type.ts
@@ -4,6 +4,7 @@ export type EmulatorSearchParams = {
search?: string
difficulty?: string
userId?: string
+ status?: EmulatorStatus
}
export type Emulator = {
diff --git a/src/features/group/components/list/GroupTableWithTeacher.tsx b/src/features/group/components/list/GroupTableWithTeacher.tsx
index 796973335..942ddde43 100644
--- a/src/features/group/components/list/GroupTableWithTeacher.tsx
+++ b/src/features/group/components/list/GroupTableWithTeacher.tsx
@@ -10,8 +10,10 @@ import { Checkbox } from '@/components/shadcn/checkbox'
import { Group } from '@/features/group/types/group.type'
import { useTranslations } from 'next-intl'
import { LicenseType } from '@/types/userRole'
+import { Users2 } from 'lucide-react'
type GroupTableWithTeacherProps = {
+ grade: string
onGroupsChange: (
groups: {
groupCode: string
@@ -22,14 +24,17 @@ type GroupTableWithTeacherProps = {
) => void
}
-export default function GroupTableWithTeacher({ onGroupsChange }: GroupTableWithTeacherProps) {
+export default function GroupTableWithTeacher({ grade, onGroupsChange }: GroupTableWithTeacherProps) {
+ const tc = useTranslations('common')
+ const to = useTranslations('organization')
+
const [selectedRows, setSelectedRows] = useState
([])
const [teacherAssignments, setTeacherAssignments] = useState>({})
const { selectedOrganizationId } = useAppSelector((state) => state.selectedOrganization)
const { data } = useSearchGroupByOrganizationIdQuery(
- { organizationId: selectedOrganizationId!, params: {} },
+ { organizationId: selectedOrganizationId!, params: { grade: Number(grade) } },
{ skip: !selectedOrganizationId }
)
@@ -102,16 +107,16 @@ export default function GroupTableWithTeacher({ onGroupsChange }: GroupTableWith
- Group Code
- Name
- Teacher
+ {tc('tableHeader.studentGroup')}
+ {tc('tableHeader.numberOfStudents')}
+ {tc('tableHeader.teacher')}
{groups.length === 0 ? (
- No groups found.
+ {tc('noData')}
) : (
@@ -124,18 +129,19 @@ export default function GroupTableWithTeacher({ onGroupsChange }: GroupTableWith
aria-label={`Select group ${group.code}`}
/>
- {group.code}
+
+ {group.name}
+ {group.code}
+
-
-
{group.name}
-
- {group.studentCount} {group.studentCount === 1 ? 'student' : 'students'}
-
+
+
+ {group.studentCount}
handleTeacherChange(group.id, val)}
diff --git a/src/features/group/types/group.type.ts b/src/features/group/types/group.type.ts
index 2a39dd482..cc0cfbd08 100644
--- a/src/features/group/types/group.type.ts
+++ b/src/features/group/types/group.type.ts
@@ -32,6 +32,7 @@ export type GroupDetailStudent = {
export type GroupQueryParams = {
includeArchived?: boolean
+ grade?: number
activeOnly?: boolean
} & SearchPaginatedRequestParams
diff --git a/src/features/organization/components/user/OrganizationUserColumns.tsx b/src/features/organization/components/user/OrganizationUserColumns.tsx
index 36f0a28a3..7bb195719 100644
--- a/src/features/organization/components/user/OrganizationUserColumns.tsx
+++ b/src/features/organization/components/user/OrganizationUserColumns.tsx
@@ -11,6 +11,7 @@ import {
} from '@/components/shadcn/dropdown-menu'
import { MoreHorizontal, Eye, Pencil, Trash2 } from 'lucide-react'
import { OrganizationUser } from '@/features/user/types/user.type'
+import { useTranslations } from 'next-intl'
export type OrganizationUserTableItem = OrganizationUser & {
id: string
@@ -58,10 +59,12 @@ export const useOrganizationUserColumns = (): ColumnDef (
@@ -72,7 +75,7 @@ export const useOrganizationUserColumns = (): ColumnDef
(
@@ -87,8 +90,8 @@ export const useOrganizationUserColumns = (): ColumnDef
(
@@ -104,7 +107,7 @@ export const useOrganizationUserColumns = (): ColumnDef
(
@@ -118,7 +121,7 @@ export const useOrganizationUserColumns = (): ColumnDef
(
@@ -130,7 +133,7 @@ export const useOrganizationUserColumns = (): ColumnDef
Action
,
+ header: () => {tc('tableHeader.actions')}
,
meta: { className: 'align-top py-3' },
enableHiding: false,
cell: ({ row }) => {
@@ -140,24 +143,24 @@ export const useOrganizationUserColumns = (): ColumnDef
- Open menu
+ {tc('tableHeader.menu')}
- Thao tác
+ {tc('button.actions')}
handleViewDetail(user)}>
- Xem chi tiết
+ {tc('button.view')}
handleUpdate(user)}>
- Cập nhật
+ {tc('button.update')}
handleDelete(user)}
className='text-red-600 focus:bg-red-50 focus:text-red-600'
>
- Xóa người dùng
+ {tc('button.delete')}
diff --git a/src/features/organization/components/user/OrganizationUserTable.tsx b/src/features/organization/components/user/OrganizationUserTable.tsx
index 815a1cb2b..dc681a4d6 100644
--- a/src/features/organization/components/user/OrganizationUserTable.tsx
+++ b/src/features/organization/components/user/OrganizationUserTable.tsx
@@ -1,39 +1,68 @@
'use client'
-import React, { useMemo } from 'react'
+import React, { useMemo, useState, useEffect } from 'react'
import { useAppSelector, useAppDispatch } from '@/hooks/redux-hooks'
import { useGetOrganizationUserQuery } from '@/features/user/api/userApi'
import { OrganizationUserQueryParams } from '@/features/user/types/user.type'
import { DataTable } from '@/components/shared/data-table/data-table'
-import { setPageIndex, setParam } from '@/features/organization/slice/organizationSlice'
+import { setPageIndex } from '@/features/organization/slice/organizationSlice'
import { useOrganizationUserColumns, OrganizationUserTableItem } from './OrganizationUserColumns'
import { useTranslations } from 'next-intl'
-import { Button } from '@/components/shadcn/button'
-import { Building2 } from 'lucide-react'
+import { Input } from '@/components/shadcn/input'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/shadcn/select'
+import { Search } from 'lucide-react'
+import { LicenseType } from '@/types/userRole'
+
+// Hook debounce
+function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value)
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value)
+ }, delay)
+ return () => {
+ clearTimeout(handler)
+ }
+ }, [value, delay])
+ return debouncedValue
+}
export default function OrganizationUserTable() {
- const t = useTranslations('subscription')
- const tc = useTranslations('common')
+ const t = useTranslations('organization.userTable')
const dispatch = useAppDispatch()
-
+
const organizationId = useAppSelector((state) => state.selectedOrganization.selectedOrganizationId) ?? 1
const userParams = useAppSelector((state) => state.user)
+ const [searchTerm, setSearchTerm] = useState('')
+
+ const [selectedRole, setSelectedRole] = useState(LicenseType.STUDENT)
+
+ const debouncedSearchTerm = useDebounce(searchTerm, 500)
+
const searchParams: OrganizationUserQueryParams = {
organizationId,
pageNumber: userParams.pageNumber ?? 1,
- pageSize: userParams.pageSize ?? 10
+ pageSize: userParams.pageSize ?? 10,
+ role: selectedRole,
+ email: debouncedSearchTerm || undefined
}
- const { data, isLoading } = useGetOrganizationUserQuery(searchParams, {
- skip: !organizationId
+ const { data, isLoading } = useGetOrganizationUserQuery(searchParams, {
+ skip: !organizationId
})
const columns = useOrganizationUserColumns()
+ const visibleColumns = useMemo(() => {
+ if (selectedRole !== LicenseType.STUDENT) {
+ return columns.filter((col) => col.id !== 'groupName')
+ }
+ return columns
+ }, [columns, selectedRole])
+
const rows: OrganizationUserTableItem[] = useMemo(() => {
if (!data?.data?.items) return []
-
return data.data.items.map((user) => ({
...user,
id: user.userId
@@ -41,26 +70,56 @@ export default function OrganizationUserTable() {
}, [data])
const handlePageChange = (page: number) => {
- dispatch(setPageIndex(page))
+ dispatch(setPageIndex(page))
}
return (
-
-
-
-
Quản lý người dùng trong Tổ chức
-
Duyệt và quản lý tất cả các thành viên tổ chức đã đăng ký trên nền tảng.
-
-
+
+ {/* Header */}
+
+
+
{t('title')}
+
{t('description')}
+
+
+
+ {/* Filter Bar */}
+
+ {/* Search Input */}
+
+
+ setSearchTerm(e.target.value)}
+ className='pl-8'
+ />
+
+
+ {/* Role Select */}
+
+
+
+
+ {/* Table */}
)
-}
\ No newline at end of file
+}
diff --git a/src/features/resource/course/components/detail/AdminCourseDetail.tsx b/src/features/resource/course/components/detail/AdminCourseDetail.tsx
index 94989dc21..8e0bb9cec 100644
--- a/src/features/resource/course/components/detail/AdminCourseDetail.tsx
+++ b/src/features/resource/course/components/detail/AdminCourseDetail.tsx
@@ -139,7 +139,7 @@ export default function AdminCourseDetail() {
- By {course.data.createdByUserName || 'STEMify'}
+ Tạo bởi {course.data.createdByUserName || 'STEMify'}
Ngày tạo: {createdAt}
Chỉnh sửa gần nhất: {updatedAt}
diff --git a/src/features/resource/course/components/detail/enrolled/CourseDetailDescription.tsx b/src/features/resource/course/components/detail/enrolled/CourseDetailDescription.tsx
index d0c27f027..92cf3bc14 100644
--- a/src/features/resource/course/components/detail/enrolled/CourseDetailDescription.tsx
+++ b/src/features/resource/course/components/detail/enrolled/CourseDetailDescription.tsx
@@ -8,7 +8,7 @@ import LoadingComponent from '@/components/shared/loading/LoadingComponent'
import { formatDate } from '@/utils/index'
import { Calendar, Clock } from 'lucide-react'
import CourseAction from '@/features/resource/course/components/detail/enrolled/CourseAction'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import { Course } from '@/features/resource/course/types/course.type'
type CourseDetailDescriptionProps = {
@@ -17,6 +17,7 @@ type CourseDetailDescriptionProps = {
export default function CourseDetailDescription({ courseData }: CourseDetailDescriptionProps) {
const t = useTranslations('course')
+ const locale = useLocale()
return (
@@ -52,7 +53,7 @@ export default function CourseDetailDescription({ courseData }: CourseDetailDesc
- {formatDate(courseData.createdDate)}
+ {formatDate(courseData.createdDate, { locale })}
{/* Age Range */}
{courseData.ageRangeLabel} {t('details.tags.age_unit')}
diff --git a/src/features/resource/course/components/detail/enrolled/CourseDetailEnrolled.tsx b/src/features/resource/course/components/detail/enrolled/CourseDetailEnrolled.tsx
index f68903f4c..6685c4129 100644
--- a/src/features/resource/course/components/detail/enrolled/CourseDetailEnrolled.tsx
+++ b/src/features/resource/course/components/detail/enrolled/CourseDetailEnrolled.tsx
@@ -2,10 +2,12 @@
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/shadcn/resizable'
import SBreadcrumb from '@/components/shared/SBreadcrumb'
import BackButton from '@/components/shared/button/BackButton'
+import SEmpty from '@/components/shared/empty/SEmpty'
import LoadingComponent from '@/components/shared/loading/LoadingComponent'
import { useGetCourseByIdQuery } from '@/features/resource/course/api/courseApi'
import CourseDetailContent from '@/features/resource/course/components/detail/enrolled/CourseDetailContent'
import CourseDetailDescription from '@/features/resource/course/components/detail/enrolled/CourseDetailDescription'
+import { useTranslations } from 'next-intl'
type CourseDetailEnrolledProps = {
courseId: number
@@ -22,7 +24,7 @@ export default function CourseDetailEnrolled({ courseId, enrollmentId }: CourseD
)
- if (!data) return
No Course Data
+ if (!data) return
return (
diff --git a/src/features/resource/course/components/detail/organization/OrganizationCourseDetail.tsx b/src/features/resource/course/components/detail/organization/OrganizationCourseDetail.tsx
index b58be56cd..0bc55f128 100644
--- a/src/features/resource/course/components/detail/organization/OrganizationCourseDetail.tsx
+++ b/src/features/resource/course/components/detail/organization/OrganizationCourseDetail.tsx
@@ -20,6 +20,7 @@ export default function OrganizationCourseDetail() {
const auth = useAppSelector((state) => state.auth)
const studentId = auth?.user?.userId
const tc = useTranslations('common.message')
+ const to = useTranslations('organization')
const { courseId } = useParams()
@@ -77,7 +78,7 @@ export default function OrganizationCourseDetail() {
className={`py-2 text-lg font-medium transition-all ${activeTab === 'lesson' ? 'text-blue-600' : 'text-gray-500'} relative`}
onClick={() => setActiveTab('lesson')}
>
- Lesson
+ {to('lesson')}
{activeTab === 'lesson' && (
)}
@@ -87,7 +88,7 @@ export default function OrganizationCourseDetail() {
className={`py-2 text-lg font-medium transition-all ${activeTab === 'classroom' ? 'text-blue-600' : 'text-gray-500'} relative`}
onClick={() => setActiveTab('classroom')}
>
- Classroom
+ {to('classroom')}
{activeTab === 'classroom' && (
)}
diff --git a/src/features/resource/course/components/list/CourseList.tsx b/src/features/resource/course/components/list/CourseList.tsx
index 4af231f87..aff00649d 100644
--- a/src/features/resource/course/components/list/CourseList.tsx
+++ b/src/features/resource/course/components/list/CourseList.tsx
@@ -8,7 +8,7 @@ export default function CourseList() {
const t = useTranslations('course')
return (
-
+
diff --git a/src/features/resource/course/components/my-learning/MyLearningList.tsx b/src/features/resource/course/components/my-learning/MyLearningList.tsx
index 242a6467b..98386b3bc 100644
--- a/src/features/resource/course/components/my-learning/MyLearningList.tsx
+++ b/src/features/resource/course/components/my-learning/MyLearningList.tsx
@@ -103,7 +103,8 @@ export function MyLearningList({ studentId }: MyLearningListProps) {
{/* Sidebar - Right Column */}
-
+ {/* TODO */}
+ {/* */}
diff --git a/src/features/resource/lesson/components/detail/LessonAction.tsx b/src/features/resource/lesson/components/detail/LessonAction.tsx
index 264109463..9833dd562 100644
--- a/src/features/resource/lesson/components/detail/LessonAction.tsx
+++ b/src/features/resource/lesson/components/detail/LessonAction.tsx
@@ -9,10 +9,14 @@ import { useTranslations } from 'next-intl'
import { LicenseType, UserRole } from '@/types/userRole'
import { useModal } from '@/providers/ModalProvider'
import { setIsPrintModalOpen } from '@/features/resource/lesson/slice/lessonDetailSlice'
+import ExportRSAButton from '@/components/shared/button/ExportRSAButton'
+import { useParams } from 'next/navigation'
export default function LessonAction({ lessonId }: { lessonId: number }) {
+ const { courseId } = useParams()
const t = useTranslations('LessonDetails')
const tt = useTranslations('toast')
+ const tc = useTranslations('common')
const { openModal } = useModal()
const dispatch = useAppDispatch()
const userRole = useAppSelector((state) => state.selectedOrganization.currentRole)
@@ -35,21 +39,14 @@ export default function LessonAction({ lessonId }: { lessonId: number }) {
- {userRole === LicenseType.TEACHER && (
- {
- // openModal('pacingGuide')
- // }}
- onClick={() => dispatch(setIsPrintModalOpen(true))}
- >
- DOWNLOAD AND PRINT
+
+ dispatch(setIsPrintModalOpen(true))}>
+ {tc('button.downloadAndPrint')}
- )}
+
+
- {lessonStatus === ProgressStatus.NOT_STARTED && (
+ {/* {lessonStatus === ProgressStatus.NOT_STARTED && (
- )}
+ )} */}
{/* Secondary actions */}
-
-
-
-
- {t('action.favor')}
-
-
-
- {t('action.share')}
-
-
)
}
diff --git a/src/features/resource/lesson/components/detail/LessonContent.tsx b/src/features/resource/lesson/components/detail/LessonContent.tsx
index acf0542e0..87422884d 100644
--- a/src/features/resource/lesson/components/detail/LessonContent.tsx
+++ b/src/features/resource/lesson/components/detail/LessonContent.tsx
@@ -84,18 +84,22 @@ export default function LessonContent({ token, lessonId, sectionStatus, enrollme
if (lastItem.contentType === ContentType.QUIZ) {
return (
-
+
+
+
)
} else if (lastItem.contentType === ContentType.ASSIGNMENT) {
return (
-
+
+
+
)
}
diff --git a/src/features/resource/lesson/components/detail/LessonDescription.tsx b/src/features/resource/lesson/components/detail/LessonDescription.tsx
index 677a4ddae..f92a9f27e 100644
--- a/src/features/resource/lesson/components/detail/LessonDescription.tsx
+++ b/src/features/resource/lesson/components/detail/LessonDescription.tsx
@@ -9,7 +9,9 @@ import { formatDate } from '@/utils/index'
import { Calendar, Clock } from 'lucide-react'
import { ApiSuccessResponse } from '@/types/baseModel'
import { Lesson } from '@/features/resource/lesson/types/lesson.type'
-import { useTranslations } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
+import { LicenseType } from '@/types/userRole'
+import { useAppSelector } from '@/hooks/redux-hooks'
type LessonDescriptionProps = {
lessonData?: ApiSuccessResponse
@@ -17,6 +19,11 @@ type LessonDescriptionProps = {
}
export default function LessonDescription({ lessonData, lessonLoading }: LessonDescriptionProps) {
+ const locale = useLocale()
+ const userRole = useAppSelector((state) => state.selectedOrganization.currentRole)
+
+ const isTeacher = userRole === LicenseType.TEACHER
+
const t = useTranslations('LessonDetails')
if (lessonLoading)
return (
@@ -34,7 +41,7 @@ export default function LessonDescription({ lessonData, lessonLoading }: LessonD
}
return (
-
+
- {formatDate(lessonData.data.createdDate)}
+ {formatDate(lessonData.data.createdDate, { locale })}
{/* Age Range */}
{lessonData.data.ageRangeLabel} {t('lesson.age_unit')}
@@ -129,7 +136,7 @@ export default function LessonDescription({ lessonData, lessonLoading }: LessonD
-
+ {isTeacher && }
)
}
diff --git a/src/features/resource/lesson/components/detail/LessonOutline.tsx b/src/features/resource/lesson/components/detail/LessonOutline.tsx
index c06f7c087..9a3dac583 100644
--- a/src/features/resource/lesson/components/detail/LessonOutline.tsx
+++ b/src/features/resource/lesson/components/detail/LessonOutline.tsx
@@ -95,7 +95,7 @@ export default function LessonOutline({ sectionData, sectionStatus }: LessonOutl
{/* ✅ Duration with appropriate styling */}
- {sec.duration} mins
+ {sec.duration} {t('mins')}
)
diff --git a/src/features/resource/lesson/components/list/LessonList.tsx b/src/features/resource/lesson/components/list/LessonList.tsx
index c3cf12fc2..22a109485 100644
--- a/src/features/resource/lesson/components/list/LessonList.tsx
+++ b/src/features/resource/lesson/components/list/LessonList.tsx
@@ -6,13 +6,11 @@ import { useTranslations } from 'next-intl'
export default function LessonList() {
const t = useTranslations('LessonList')
+ const tc = useTranslations('common.breadcrumb')
return (
-
+
-
+
diff --git a/src/features/resource/quiz/components/player/QuizResult.tsx b/src/features/resource/quiz/components/player/QuizResult.tsx
index b4fefa3d2..9d63475af 100644
--- a/src/features/resource/quiz/components/player/QuizResult.tsx
+++ b/src/features/resource/quiz/components/player/QuizResult.tsx
@@ -11,6 +11,8 @@ import { Progress } from '@/components/shadcn/progress'
import { useCreateQuizAttemptMutation, useGetQuizByIdQuery } from '@/features/resource/quiz/api/quizApi'
import { useGetStudentQuizByIdQuery } from '@/features/quiz/api/studentQuizApi'
import LoadingComponent from '@/components/shared/loading/LoadingComponent'
+import { formatDate } from '@/utils/index'
+import { useLocale, useTranslations } from 'next-intl'
type QuizResultProps = {
quizId: number
@@ -18,6 +20,10 @@ type QuizResultProps = {
}
export default function QuizResult({ quizId, studentQuizAttempt }: QuizResultProps) {
+ const locale = useLocale()
+
+ const tq = useTranslations('quiz.detail')
+
const dispatch = useAppDispatch()
const [reAttemptQuiz] = useCreateQuizAttemptMutation()
@@ -57,6 +63,19 @@ export default function QuizResult({ quizId, studentQuizAttempt }: QuizResultPro
}
}
+ const calculateDuration = (startedAt: string, completedAt?: string) => {
+ if (!completedAt) return '00:00'
+
+ const start = new Date(startedAt).getTime()
+ const end = new Date(completedAt).getTime()
+
+ const totalSeconds = Math.max(0, Math.floor((end - start) / 1000))
+ const minutes = Math.floor(totalSeconds / 60)
+ const seconds = totalSeconds % 60
+
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
+ }
+
return (
@@ -64,72 +83,41 @@ export default function QuizResult({ quizId, studentQuizAttempt }: QuizResultPro
- Bắt đầu vào lúc
-
- {new Date(studentQuizAttempt.startedAt).toLocaleString('vi-VN', {
- weekday: 'long',
- day: 'numeric',
- month: 'long',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- hour12: true
- })}
-
+ {tq('startedAt')}
+ {formatDate(studentQuizAttempt.startedAt, { locale })}
- Trạng thái
- Đã xong
+ {tq('status')}
+ {tq('complete')}
- Kết thúc lúc
+ {tq('completedAt')}
- {studentQuizAttempt.completedAt
- ? new Date(studentQuizAttempt.completedAt).toLocaleString('vi-VN', {
- weekday: 'long',
- day: 'numeric',
- month: 'long',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- hour12: true
- })
- : '-'}
+ {studentQuizAttempt.completedAt ? formatDate(studentQuizAttempt.completedAt, { locale }) : '-'}
- Thời gian thực hiện
+ {tq('duration')}
- {(() => {
- if (!studentQuizAttempt.completedAt) return '-'
- const start = new Date(studentQuizAttempt.startedAt)
- const end = new Date(studentQuizAttempt.completedAt)
- const diffMs = end.getTime() - start.getTime()
- const minutes = Math.floor(diffMs / 60000)
- const seconds = Math.floor((diffMs % 60000) / 1000)
- return `${minutes} phút ${seconds} giây`
- })()}
+ {calculateDuration(studentQuizAttempt.startedAt, studentQuizAttempt.completedAt)}
- Số câu đúng
+ {tq('correctAnswers')}
{correctAnswersCount} / {questions.length}
- Điểm
-
- {((correctAnswersCount / questions.length) * 10).toFixed(2)} trên {questions.length * 10},00 (
- {scorePercent}%)
-
+ {tq('score')}
+ {scorePercent}%
{/* Results Summary */}
-
Chi tiết kết quả
+
{tq('resultDetail')}
{questions.map((question, index) => {
const questionAttempt = studentQuizAttempt.questionAttempts?.find((qa) => qa.questionId === question.id)
@@ -147,10 +135,13 @@ export default function QuizResult({ quizId, studentQuizAttempt }: QuizResultPro
-
Câu Hỏi {index + 1}
+
+ {tq('question.question')} {index + 1}
+
- {isCorrect ? 'Hoàn thành' : 'Chưa hoàn thành'}
- Đạt điểm {isCorrect ? '1,00' : '0,00'} trên 1,00
+ {isCorrect ? tq('complete') : tq('incomplete')}
+ {isCorrect ? '1' : '0'}/1
+ {/* TODO */}
diff --git a/src/features/resource/quiz/components/viewer/QuizAttempt.tsx b/src/features/resource/quiz/components/viewer/QuizAttempt.tsx
index fb413818b..5ba191a1b 100644
--- a/src/features/resource/quiz/components/viewer/QuizAttempt.tsx
+++ b/src/features/resource/quiz/components/viewer/QuizAttempt.tsx
@@ -2,23 +2,15 @@ import { useGetStudentQuizByIdQuery } from '@/features/resource/quiz/api/quizApi
import React from 'react'
import { Card, CardContent } from '@/components/shadcn/card'
import { Skeleton } from '@/components/shadcn/skeleton'
-import {
- AlertCircle,
- CheckCircle2,
- XCircle,
- Clock,
- Calendar,
- Trophy,
- Target,
- TrendingUp,
- Eye,
- ArrowLeft
-} from 'lucide-react'
+import { AlertCircle, CheckCircle2, XCircle, Clock, Eye, ArrowLeft } from 'lucide-react'
import { QuizAttemptStatus, Attempt } from '@/features/resource/quiz/types/quiz.type'
import { Badge } from '@/components/shadcn/badge'
import { Button } from '@/components/shadcn/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/shadcn/table'
import QuizResult from '@/features/resource/quiz/components/player/QuizResult'
+import { useLocale, useTranslations } from 'next-intl'
+import { formatDate } from '@/utils/index'
+import { cn } from '@/utils/shadcn/utils'
type QuizAttemptProps = {
studentQuizId: number
@@ -27,6 +19,10 @@ type QuizAttemptProps = {
}
export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAttempt }: QuizAttemptProps) {
+ const locale = useLocale()
+ const tc = useTranslations('common')
+ const tq = useTranslations('quiz.detail')
+
const { data: studentQuiz, isLoading: isLoadingStudentQuiz, refetch } = useGetStudentQuizByIdQuery(studentQuizId)
if (isLoadingStudentQuiz) {
@@ -44,7 +40,7 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt
-
No quiz attempt data available
+
{tq('noData')}
)
@@ -59,7 +55,7 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt
onSelectAttempt(null)} className='mb-4'>
- Quay lại danh sách
+ {tc('button.back')}
@@ -72,52 +68,50 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt
return (
- Passed
+ {tc('status.passed')}
)
case QuizAttemptStatus.FAILED:
return (
- Failed
+ {tc('status.failed')}
)
case QuizAttemptStatus.IN_PROGRESS:
return (
- In Progress
+ {tc('status.inProgress')}
)
}
}
- const formatDate = (dateString: string) => {
- return new Date(dateString).toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit'
- })
- }
-
const calculateDuration = (startedAt: string, completedAt?: string) => {
- if (!completedAt) return 'N/A'
+ if (!completedAt) return '00:00'
+
const start = new Date(startedAt).getTime()
const end = new Date(completedAt).getTime()
- const minutes = Math.floor((end - start) / 60000)
- const seconds = Math.floor(((end - start) % 60000) / 1000)
- return `${minutes}m ${seconds}s`
+
+ const totalSeconds = Math.max(0, Math.floor((end - start) / 1000))
+ const minutes = Math.floor(totalSeconds / 60)
+ const seconds = totalSeconds % 60
+
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
+ const isBeforeDueDate = new Date() < new Date(studentQuiz.data.dueDate)
+
return (
{/* Overall Summary Card */}
{completedAttempts.length > 0 && (
- Your final score: {quizData.finalScore}%
+
+ {tq('yourFinalScore')}: {quizData.finalScore}%
+
)}
@@ -125,7 +119,7 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt
{/* Attempts History */}
-
Attempt History
+
{tq('attemptHistory')}
{completedAttempts.length}
@@ -135,11 +129,11 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt
- Status
- Score
- Correct Answers
- Duration
- Submitted At
+ {tc('tableHeader.status')}
+ {tc('tableHeader.score')}
+ {tc('tableHeader.correctAnswer')}
+ {tc('tableHeader.duration')}
+ {tc('tableHeader.submissionDate')}
@@ -166,13 +160,21 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt
- {formatDate(attempt.completedAt)}
+ {formatDate(attempt.completedAt, { locale })}
onSelectAttempt(attempt)}
+ className={cn(
+ 'h-5 w-5',
+ isBeforeDueDate
+ ? 'cursor-not-allowed text-gray-300'
+ : 'cursor-pointer text-gray-400 hover:text-gray-600'
+ )}
+ onClick={() => {
+ if (isBeforeDueDate) return
+ onSelectAttempt(attempt)
+ }}
/>
@@ -186,7 +188,7 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt
-
No completed attempts yet
+
{tq('noAttempts')}
)}
diff --git a/src/features/resource/quiz/components/viewer/QuizViewer.tsx b/src/features/resource/quiz/components/viewer/QuizViewer.tsx
index 2c634e943..712a4daac 100644
--- a/src/features/resource/quiz/components/viewer/QuizViewer.tsx
+++ b/src/features/resource/quiz/components/viewer/QuizViewer.tsx
@@ -33,7 +33,6 @@ export default function QuizViewer({ quiz, isShowQuestionAnswer, studentQuizId,
const selectedQuiz = useAppSelector((state) => state.quizPlayer.selectedQuiz)
const quizStatus = sectionStatus?.items.find((item) => item.sectionId === selectedQuiz?.id)?.status
- console.log('Quiz Status:', quizStatus)
const { data: quizData, isLoading } = useGetQuizByIdQuery(quiz.quizId, { skip: !quiz.quizId })
const [selectedAttempt, setSelectedAttempt] = useState(null)
diff --git a/src/features/user/api/userApi.ts b/src/features/user/api/userApi.ts
index 3f2d1704e..630c26218 100644
--- a/src/features/user/api/userApi.ts
+++ b/src/features/user/api/userApi.ts
@@ -28,10 +28,10 @@ export const userApi = createCrudApi({
ApiSuccessResponse>,
OrganizationUserQueryParams
>({
- query: ({ organizationId, pageNumber, pageSize, role }) => ({
+ query: ({ organizationId, pageNumber, pageSize, role, email }) => ({
url: `/organizations/${organizationId}/users`,
method: 'GET',
- params: { pageNumber, pageSize, role }
+ params: { pageNumber, pageSize, role, email }
}),
providesTags: ['User']
})
diff --git a/src/features/user/components/table/UserOrganizationAction.tsx b/src/features/user/components/table/UserOrganizationAction.tsx
index 43febb8a1..d411339b5 100644
--- a/src/features/user/components/table/UserOrganizationAction.tsx
+++ b/src/features/user/components/table/UserOrganizationAction.tsx
@@ -10,7 +10,7 @@ import { User, UserStatus } from '@/features/user/types/user.type'
import Image from 'next/image'
import { Badge } from '@/components/shadcn/badge'
import { getStatusBadgeClass } from '@/utils/badgeColor'
-import { useStatusTranslation } from '@/utils/index'
+import { useOrgUserStatusTranslation, useStatusTranslation } from '@/utils/index'
export function useGetOrganizationUserAction(): ColumnDef[] {
const { openModal } = useModal()
@@ -80,22 +80,19 @@ export function useGetOrganizationUserAction(): ColumnDef[] {
},
{
accessorKey: 'userRole',
- header: t('userRole')
- // cell: ({ row }) => {
- // const role = row.original.userRole
- // return {tc(`accountType.${role}`)}
- // }
+ header: t('userRole'),
+ cell: ({ row }) => {
+ const role = row.original.subscriptions[0].licenseType
+ return {tc(`accountType.${role.toLowerCase()}`)}
+ }
},
{
accessorKey: 'status',
- header: t('status')
- // cell: ({ row }) => {
- // return (
- //
- // {translationStatus(row.original.status)}
- //
- // )
- // }
+ header: t('status'),
+ cell: ({ row }) => {
+ const status = row.original.isActive ? UserStatus.ACTIVE : UserStatus.INACTIVE
+ return {translationStatus(status)}
+ }
},
createActionsColumnFromItems([
{
diff --git a/src/features/user/types/user.type.ts b/src/features/user/types/user.type.ts
index 426974d93..b3d4a05e8 100644
--- a/src/features/user/types/user.type.ts
+++ b/src/features/user/types/user.type.ts
@@ -12,10 +12,16 @@ export type User = {
lastName: string
imageUrl?: string
status: UserStatus
+ isActive: boolean
organizations?: {
role: UserRole
organizations: UserOrganization[]
}
+ subscriptions: {
+ subscriptionOrderId: number
+ licenseType: LicenseType
+ joinedAt: string
+ }[]
}
export type OrganizationSubscription = {
@@ -25,6 +31,7 @@ export type OrganizationSubscription = {
export type UserOrganization = {
id: number // Organization ID
+ organizationUserId: string
roles: OrganizationSubscription[]
}
@@ -88,4 +95,5 @@ export type OrganizationUserQueryParams = {
pageNumber?: number
pageSize?: number
role?: LicenseType
+ email?: string
}
diff --git a/src/providers/AuthSessionSync.tsx b/src/providers/AuthSessionSync.tsx
index d9d61fa42..e0f36768b 100644
--- a/src/providers/AuthSessionSync.tsx
+++ b/src/providers/AuthSessionSync.tsx
@@ -68,7 +68,7 @@ export default function AuthSessionSync() {
if (activeSub) {
dispatch(setSelectedOrganizationId(firstOrg.id))
dispatch(setSelectedSubscriptionOrderId(activeSub.subscriptionId))
- dispatch(setSelectedOrgUserId('e821a170-2f5a-4a37-8d15-432a03af4a43')) //hardcode
+ dispatch(setSelectedOrgUserId(firstOrg.organizationUserId))
dispatch(setCurrentRole(activeSub.type)) // Đây là LicenseType
}
}