From 87b0ee9d853ee2355efff7e63425b2ff638a2a3b Mon Sep 17 00:00:00 2001 From: meewaldor Date: Mon, 8 Dec 2025 13:53:58 +0700 Subject: [PATCH 01/13] feat: integrate SearchBar and SSelect components in Workspace3dLibrary for enhanced search and filtering functionality --- src/components/shared/search/SearchBar.tsx | 6 ++- .../workspace-3d/Workspace3dLibrary.tsx | 53 +++++++++++++++++-- src/features/emulator/types/emulator.type.ts | 1 + 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/components/shared/search/SearchBar.tsx b/src/components/shared/search/SearchBar.tsx index e7cdc1a43..e93d290e1 100644 --- a/src/components/shared/search/SearchBar.tsx +++ b/src/components/shared/search/SearchBar.tsx @@ -3,6 +3,7 @@ import { Search } from 'lucide-react' import React, { KeyboardEvent, memo, useEffect, useState } from 'react' import useDebounce from '@/hooks/useDebounce' +import { useTranslations } from 'next-intl' type SearchBarProps = { className?: string @@ -13,12 +14,13 @@ type SearchBarProps = { const SearchBar = memo(function SearchBar({ className = '', - placeholder = 'Search STEMify', + placeholder, defaultValue = '', onDebouncedSearch }: SearchBarProps) { const [input, setInput] = useState(defaultValue) const debounced = useDebounce(input, 500) + const tc = useTranslations('common') useEffect(() => { if (onDebouncedSearch) onDebouncedSearch(debounced) @@ -38,7 +40,7 @@ const SearchBar = memo(function SearchBar({ setInput(e.target.value)} onKeyDown={handleKeyDown} className='w-full bg-transparent text-sm text-gray-700 placeholder-gray-400 focus:outline-none' diff --git a/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx b/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx index 43cd39ab2..67f4bcca2 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')}

+
+
+ 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 */} -
+
{emulations.map((e) => ( Date: Mon, 8 Dec 2025 13:58:56 +0700 Subject: [PATCH 02/13] feat: enhance emulation card layout and update popover functionality for improved user interaction --- .../workspace-3d/Workspace3dLibrary.tsx | 71 ++++++++++--------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx b/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx index 67f4bcca2..aebeb84d9 100644 --- a/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx +++ b/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx @@ -155,7 +155,7 @@ export default function Workspace3dLibrary() {
{/* Model list */} -
+
{emulations.map((e) => ( - - - + + + {/* Popover menu */} + e.stopPropagation()} > - - - - - {/* Popover menu */} - e.stopPropagation()}> -
- - {e.status !== EmulatorStatus.PUBLISHED && userRole && allowRoles.includes(userRole) && ( +
- )} - -
- - + {e.status !== EmulatorStatus.PUBLISHED && userRole && allowRoles.includes(userRole) && ( + + )} + +
+
+
+ )}
{/* Content */} From ffd2391e4e70ea664163f762f54d70e1b2124931 Mon Sep 17 00:00:00 2001 From: LeThanhNhan91 Date: Mon, 8 Dec 2025 14:10:25 +0700 Subject: [PATCH 03/13] feat: add user management features and enhance filtering options in OrganizationUserTable --- messages/en/common/en_common.json | 8 +- messages/en/organization/en_organization.json | 11 ++ messages/vi/common/vi_common.json | 6 +- messages/vi/organization/vi_organization.json | 11 ++ .../user/OrganizationUserColumns.tsx | 27 +++-- .../components/user/OrganizationUserTable.tsx | 103 ++++++++++++++---- src/features/user/api/userApi.ts | 4 +- src/features/user/types/user.type.ts | 1 + 8 files changed, 132 insertions(+), 39 deletions(-) diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 10e06a34d..bbecb3ee7 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -115,7 +115,8 @@ "continueLearning": "Continue Learning", "markAsComplete": "Mark as Complete", "startQuiz": "Start Quiz", - "contact": "Contact Us" + "contact": "Contact Us", + "menu": "Open Menu" }, "message": { "courseCreateSuccess": "Course created successfully!", @@ -198,7 +199,10 @@ "course": "Course", "joinedAt": "Joined At", "subscription": "Subscription", - "student": "Student(s)" + "student": "Student(s)", + "user": "User", + "license": "License", + "groupName": "Group" }, "paging": { "previous": "Previous", diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index 74801d551..086586358 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -244,6 +244,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", + "orgAdmin": "Organization Admin", + "placeholder": { + "email": "Search by email...", + "license": "Select License" + } } } } diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index 748c43460..4f7f82e92 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -197,7 +197,11 @@ "course": "Khóa Học", "joinedAt": "Ngày tham gia", "subscription": "Gói đăng ký", - "student": "Học sinh" + "student": "Học sinh", + "user": "Người dùng", + "license": "Vai trò", + "groupName": "Nhóm", + "menu": "Mở Menu" }, "paging": { "previous": "Trước", diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index d105108e5..03337d18c 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -243,6 +243,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", + "orgAdmin": "Quản trị viên", + "placeholder": { + "email": "Tìm kiếm theo email...", + "license": "Chọn vai trò" + } } } } diff --git a/src/features/organization/components/user/OrganizationUserColumns.tsx b/src/features/organization/components/user/OrganizationUserColumns.tsx index 36f0a28a3..2323cf3a5 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 - Thao tác + {tc('button.action')} 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/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/types/user.type.ts b/src/features/user/types/user.type.ts index 426974d93..fb9c2a412 100644 --- a/src/features/user/types/user.type.ts +++ b/src/features/user/types/user.type.ts @@ -88,4 +88,5 @@ export type OrganizationUserQueryParams = { pageNumber?: number pageSize?: number role?: LicenseType + email?: string } From d9a96ea2656bb86bea12406b25d179bfdef7f65e Mon Sep 17 00:00:00 2001 From: meewaldor Date: Mon, 8 Dec 2025 14:28:58 +0700 Subject: [PATCH 04/13] feat: add GLB export functionality and update SceneActions for exporting assemblies --- package-lock.json | 8 +-- package.json | 1 + .../components/creator3d/Creator3D.tsx | 13 ++++ .../components/creator3d/SceneActions.tsx | 10 +++- .../hooks/buildSceneFromAssembly.ts | 59 +++++++++++++++++++ src/features/creator-3d/hooks/exportGlb.ts | 32 ++++++++++ 6 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 src/features/creator-3d/hooks/buildSceneFromAssembly.ts create mode 100644 src/features/creator-3d/hooks/exportGlb.ts 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/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')} + +
) } 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 } + ) +} From f8924b22a3f48ff8a9a550e01412d0dc55d1f788 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 8 Dec 2025 17:08:19 +0700 Subject: [PATCH 05/13] feat: add status filtering and improve translations in classroom components --- messages/en/classroom/en_classroom.json | 1 + messages/en/common/en_common.json | 3 +- messages/vi/classroom/vi_classroom.json | 1 + messages/vi/common/vi_common.json | 5 +- .../detail/OrganizationClassroomDetail.tsx | 20 ++++--- .../components/list/TeacherClassroomList.tsx | 55 ++++++++++--------- .../components/list/table/ClassroomColumn.tsx | 11 +--- .../table/UserOrganizationAction.tsx | 25 ++++----- src/features/user/types/user.type.ts | 7 +++ src/providers/AuthSessionSync.tsx | 2 +- 10 files changed, 70 insertions(+), 60 deletions(-) diff --git a/messages/en/classroom/en_classroom.json b/messages/en/classroom/en_classroom.json index 27ccd46c4..4f7c835f2 100644 --- a/messages/en/classroom/en_classroom.json +++ b/messages/en/classroom/en_classroom.json @@ -4,6 +4,7 @@ "header": "Classroom List", "searchPlaceholder": "Search...", "selectCoursePlaceholder": "Select course", + "selectStatusPlaceholder": "Filter by status", "courses": "courses" }, "update": { diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 1fef69ae5..e968caa21 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -197,7 +197,8 @@ "course": "Course", "joinedAt": "Joined At", "subscription": "Subscription", - "student": "Student(s)" + "student": "Student(s)", + "className": "Class Name" }, "paging": { "previous": "Previous", diff --git a/messages/vi/classroom/vi_classroom.json b/messages/vi/classroom/vi_classroom.json index 3a6ba6b4d..d60b26cec 100644 --- a/messages/vi/classroom/vi_classroom.json +++ b/messages/vi/classroom/vi_classroom.json @@ -4,6 +4,7 @@ "header": "Danh sách lớp học", "searchPlaceholder": "Tìm kiếm...", "selectCoursePlaceholder": "Chọn chương trình học", + "selectStatusPlaceholder": "Lọc theo trạng thái", "courses": "khóa học" }, "update": { diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index 088d14e65..fcf0cbc21 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -191,11 +191,12 @@ "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" }, "paging": { "previous": "Trước", diff --git a/src/features/classroom/components/detail/OrganizationClassroomDetail.tsx b/src/features/classroom/components/detail/OrganizationClassroomDetail.tsx index e94a8b6fc..78b898f84 100644 --- a/src/features/classroom/components/detail/OrganizationClassroomDetail.tsx +++ b/src/features/classroom/components/detail/OrganizationClassroomDetail.tsx @@ -163,7 +163,7 @@ export default function OrganizationClassroomDetail() {
- {tClassroom('detail.curriculum.label')} + {tClassroom('detail.course')}
-

{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/list/TeacherClassroomList.tsx b/src/features/classroom/components/list/TeacherClassroomList.tsx index 0c19381f3..571903b5d 100644 --- a/src/features/classroom/components/list/TeacherClassroomList.tsx +++ b/src/features/classroom/components/list/TeacherClassroomList.tsx @@ -16,17 +16,25 @@ import SearchBar from '@/components/shared/search/SearchBar' import SSelect from '@/components/shared/SSelect' import { resetParams } from '@/features/classroom/slice/classroomSlice' +import { useTranslations } from 'next-intl' +import { useStatusTranslation } from '@/utils/index' export default function TeacherClassroomList() { + const t = useTranslations('classroom') + const statusTranslation = useStatusTranslation() const user = useAppSelector((state) => state.auth?.user) const queryParams = useAppSelector((state) => state.classroom) const [selectedStatus, setSelectedStatus] = useState<'all' | ClassroomStatus>('all') const dispatch = useAppDispatch() - - const { data, isLoading, error } = useSearchClassroomsQuery({ - ...queryParams, - teacherId: user?.userId - }) + const { selectedOrgUserId } = useAppSelector((state) => state.selectedOrganization) + + const { data, isLoading, error } = useSearchClassroomsQuery( + { + ...queryParams, + teacherId: selectedOrgUserId ?? undefined + }, + { skip: !selectedOrgUserId } + ) const classrooms = data?.data.items || [] // reset classroom store filters first time load @@ -50,21 +58,21 @@ export default function TeacherClassroomList() { const statusOptions = Object.values(ClassroomStatus).map((status) => ({ value: status, - label: status + label: statusTranslation(status) })) return ( -
+
{/* Header */} - -
- +

{t('list.header')}

+
+ setSelectedStatus(value as ClassroomStatus | 'all')} options={statusOptions} - className='w-fit' + // className='w-[200px]' />
@@ -73,30 +81,24 @@ export default function TeacherClassroomList() { )} {/* Classroom Grid */} -
+
{classrooms.map((classroom) => ( - + {/* Image Header */} -
+
{classroom.course?.imageUrl ? ( {classroom.name} ) : (
)} - - {/* Status Badge */} -
- - {classroom.status} - -
@@ -104,8 +106,11 @@ export default function TeacherClassroomList() { {/* Title & Grade */}

{classroom.name}

- - {classroom.grade} + {/* Status Badge */} + + {statusTranslation(classroom.status)}
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/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..a473a7eb5 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[] } 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 } } From 12ee86137de9932dc83d1b46ca2521d7639ca36b Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 8 Dec 2025 18:49:02 +0700 Subject: [PATCH 06/13] feat: enhance classroom and common translations, improve breadcrumb navigation, and update classroom list functionality --- messages/en/classroom/en_classroom.json | 5 +- messages/en/common/en_common.json | 11 + messages/vi/classroom/vi_classroom.json | 5 +- messages/vi/common/vi_common.json | 12 ++ src/components/shared/SBreadcrumb.tsx | 14 +- .../components/list/ClassroomList.tsx | 83 ++++---- .../components/list/TeacherClassroomList.tsx | 201 ++++++++++-------- .../components/ui/ClassroomSubHeader.tsx | 70 +++--- .../classroom/types/classroom.type.ts | 29 ++- .../course/components/list/CourseList.tsx | 3 +- .../components/my-learning/MyLearningList.tsx | 3 +- .../lesson/components/list/LessonList.tsx | 8 +- 12 files changed, 259 insertions(+), 185 deletions(-) diff --git a/messages/en/classroom/en_classroom.json b/messages/en/classroom/en_classroom.json index 4f7c835f2..9e99c10eb 100644 --- a/messages/en/classroom/en_classroom.json +++ b/messages/en/classroom/en_classroom.json @@ -2,10 +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", "selectStatusPlaceholder": "Filter by status", - "courses": "courses" + "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 047376026..a534058f2 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -1,5 +1,16 @@ { "common": { + "breadcrumb": { + "home": "Home", + "resource": "Resources", + "lessons": "Lessons", + "activities": "Activities", + "courses": "Courses", + "courseDetail": "Course Detail", + "classrooms": "Classrooms", + "classroomDetail": "Classroom Detail", + "createClassroom": "Create Classroom" + }, "search": { "placeholder": "Search...", "noResults": "No results found." diff --git a/messages/vi/classroom/vi_classroom.json b/messages/vi/classroom/vi_classroom.json index d60b26cec..66f8ada17 100644 --- a/messages/vi/classroom/vi_classroom.json +++ b/messages/vi/classroom/vi_classroom.json @@ -2,10 +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", "selectStatusPlaceholder": "Lọc theo trạng thái", - "courses": "khóa học" + "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 c43bf32b6..efff86b0a 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -1,5 +1,16 @@ { "common": { + "breadcrumb": { + "home": "Trang Chủ", + "courses": "Khóa Học", + "resource": "Tài Nguyên", + "lessons": "Bài Học", + "activities": "Hoạt Động", + "courseDetail": "Chi Tiết Khóa 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ả." @@ -256,6 +267,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/src/components/shared/SBreadcrumb.tsx b/src/components/shared/SBreadcrumb.tsx index c54e7da7a..f704c8f3f 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' @@ -34,6 +34,7 @@ function resolveHref(href: string): string { } export default function SBreadcrumb({ title, size = 'md', color, weight }: SBreadcrumbProps) { + const tc = useTranslations('common.breadcrumb') const pathname = usePathname() const locale = useLocale() const segments = pathname @@ -42,6 +43,15 @@ export default function SBreadcrumb({ title, size = 'md', color, weight }: SBrea .filter(Boolean) 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,7 +62,7 @@ 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 ( 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 ? ( {classroom.name} ) : (
)} - - {/* 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 571903b5d..a436c8bda 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,136 +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' -import { useTranslations } from 'next-intl' -import { useStatusTranslation } from '@/utils/index' export default function TeacherClassroomList() { + const locale = useLocale() const t = useTranslations('classroom') const statusTranslation = useStatusTranslation() - const user = useAppSelector((state) => state.auth?.user) - const queryParams = useAppSelector((state) => state.classroom) - const [selectedStatus, setSelectedStatus] = useState<'all' | ClassroomStatus>('all') + 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 { selectedOrgUserId } = useAppSelector((state) => state.selectedOrganization) const { data, isLoading, error } = useSearchClassroomsQuery( { ...queryParams, - teacherId: selectedOrgUserId ?? undefined + 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: statusTranslation(status) + label: statusTranslation(status), + value: status })) return ( -
+ {/* Header */} -

{t('list.header')}

-
- - setSelectedStatus(value as ClassroomStatus | 'all')} - options={statusOptions} - // className='w-[200px]' - /> -
+
+
+

{t('list.header')}

+

{t('list.description')}

+
- {classrooms.length === 0 && ( - - )} - - {/* Classroom Grid */} -
- {classrooms.map((classroom) => ( - - - {/* Image Header */} -
- {classroom.course?.imageUrl ? ( - {classroom.name} - ) : ( -
- -
- )} -
- - -
- {/* Title & Grade */} -
-

{classroom.name}

- {/* Status Badge */} - - {statusTranslation(classroom.status)} - -
+
+ setSearch(val)} + /> + setStatusFilter(value)} + options={statusOptions.filter((option) => option.value !== ClassroomStatus.DELETED)} + /> +
+ + {isLoading && ( +
+ + + + + \ + + +
+ )} - {classroom.course && ( -
- - {classroom.course.title} + {/* Classroom Grid */} +
+ {classrooms.map((classroom) => ( + + + {/* Image Header */} +
+ {classroom.course?.imageUrl ? ( + {classroom.name} + ) : ( +
+
)} +
- {/* Date */} -
- - - {format(new Date(classroom.startDate), 'MMM dd')} -{' '} - {format(new Date(classroom.endDate), 'MMM dd, yyyy')} - -
+ +
+ {/* Title & Grade */} +
+

{classroom.name}

+ {/* Status Badge */} + + {statusTranslation(classroom.status)} + +
+ + {classroom.course && ( +
+ + {classroom.course.title} +
+ )} - {/* Students */} -
-
- - {classroom.numberOfStudents} + {/* Date */} +
+ + + {formatDate(classroom.startDate, { locale })} - {formatDate(classroom.endDate, { locale })} + +
+ + {/* Students */} +
+
+ + {classroom.numberOfStudents} +
-
- - - - ))} + + + + ))} +
-
+ ) } diff --git a/src/features/classroom/components/ui/ClassroomSubHeader.tsx b/src/features/classroom/components/ui/ClassroomSubHeader.tsx index d92ebbf4b..c8b34586e 100644 --- a/src/features/classroom/components/ui/ClassroomSubHeader.tsx +++ b/src/features/classroom/components/ui/ClassroomSubHeader.tsx @@ -3,7 +3,7 @@ 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' @@ -12,6 +12,8 @@ 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 @@ -21,7 +23,15 @@ interface Props { } export default function ClassroomSubHeader({ classroom, curriculumId, 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 +51,7 @@ export default function ClassroomSubHeader({ classroom, curriculumId, currentTab
{/* Left - Classroom Info */}
- +
{classroom.name?.charAt(0).toUpperCase() ?? 'C'} @@ -56,37 +59,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/types/classroom.type.ts b/src/features/classroom/types/classroom.type.ts index d0bc13881..59be30907 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' } @@ -201,15 +208,15 @@ export type ClassroomStudentGroup = { // AI Analyses export type AiAnalysisResponse = { - classOverview: string; - atRiskCount: number; - atRiskStudents: AtRiskStudentAnalysis[]; + 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 + studentId: string + studentName: string + severity: 'High' | 'Medium' | 'Low' + reason: string + recommendation: string +} diff --git a/src/features/resource/course/components/list/CourseList.tsx b/src/features/resource/course/components/list/CourseList.tsx index 4af231f87..6fc71fe69 100644 --- a/src/features/resource/course/components/list/CourseList.tsx +++ b/src/features/resource/course/components/list/CourseList.tsx @@ -6,9 +6,10 @@ import { useTranslations } from 'next-intl' export default function CourseList() { const t = useTranslations('course') + const tc = useTranslations('common.breadcrumb') 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/list/LessonList.tsx b/src/features/resource/lesson/components/list/LessonList.tsx index c3cf12fc2..2be25e42d 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 ( - +
- +
From b438cbaa6cf0eea212a3fe2a50b753922acd2283 Mon Sep 17 00:00:00 2001 From: LeThanhNhan91 Date: Mon, 8 Dec 2025 20:12:50 +0700 Subject: [PATCH 07/13] feat: update organization admin terminology, enhance Vietnamese translations, and implement AI analysis for classroom progress --- messages/en/organization/en_organization.json | 2 +- messages/vi/common/vi_common.json | 3 +- messages/vi/organization/vi_organization.json | 2 +- src/features/classroom/api/classroomApi.ts | 13 +- .../classroom/types/classroom.type.ts | 28 +++-- .../table/StudentProgressStatistic.tsx | 112 +++++++++--------- .../user/OrganizationUserColumns.tsx | 2 +- 7 files changed, 90 insertions(+), 72 deletions(-) diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index 086586358..b3e498be6 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -250,7 +250,7 @@ "description": "Browse and manage all organization members registered on the platform.", "student": "Student", "teacher": "Teacher", - "orgAdmin": "Organization Admin", + "admin": "Organization Admin", "placeholder": { "email": "Search by email...", "license": "Select License" diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index efff86b0a..bc7846fe1 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -126,7 +126,8 @@ "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" }, "message": { "courseCreateSuccess": "Khóa học được tạo thành công!", diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index 03337d18c..babd4dc85 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -249,7 +249,7 @@ "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", - "orgAdmin": "Quản trị viên", + "admin": "Quản trị viên", "placeholder": { "email": "Tìm kiếm theo email...", "license": "Chọn vai trò" diff --git a/src/features/classroom/api/classroomApi.ts b/src/features/classroom/api/classroomApi.ts index 9cb10518c..b5fa4cd2a 100644 --- a/src/features/classroom/api/classroomApi.ts +++ b/src/features/classroom/api/classroomApi.ts @@ -7,7 +7,9 @@ import { StudentDetailResponse, CreateClassroom, StudentProgressData, - StudentProgressParams + StudentProgressParams, + AiAnalysisResponse, + AiAnalysisRequest } from '@/features/classroom/types/classroom.type' import { createCrudApi, customFetchBaseQueryWithErrorHandling } from '@/libs/redux/baseApi' import { RootState } from '@/libs/redux/store' @@ -85,6 +87,13 @@ export const classroomApi = createCrudApi({ body }), invalidatesTags: ['Classroom'] + }), + analyzeClassroomProgress: builder.mutation, AiAnalysisRequest>({ + query: (body) => ({ + url: `/ai/recommendations/analyze-progress`, + method: 'POST', + body + }) }) }) }) @@ -111,4 +120,6 @@ export const { useGetClassroomStatisticsQuery, useGetClassroomStudentDetailQuery, + + useAnalyzeClassroomProgressMutation } = classroomApi diff --git a/src/features/classroom/types/classroom.type.ts b/src/features/classroom/types/classroom.type.ts index 59be30907..55a576511 100644 --- a/src/features/classroom/types/classroom.type.ts +++ b/src/features/classroom/types/classroom.type.ts @@ -206,17 +206,25 @@ export type ClassroomStudentGroup = { studentIds: string[] } -// AI Analyses -export type AiAnalysisResponse = { - classOverview: string - atRiskCount: number - atRiskStudents: AtRiskStudentAnalysis[] +// =============== AI ANALYSIS TYPES =============== + +export type AiAnalysisRequest = { + classroom_id: number + force_mock: boolean + analysis_period_days: number } -export type AtRiskStudentAnalysis = { +export type AiStudentAnalysisResult = { studentId: string - studentName: string - severity: 'High' | 'Medium' | 'Low' - reason: string - recommendation: string + progressPercent: number + currentStatus: string + statusText: string + currentSection: string | null + interventionText: string +} + +export type AiAnalysisResponse = { + overviewText: string + students: AiStudentAnalysisResult[] + aiInsightsText: string } 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 {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/organization/components/user/OrganizationUserColumns.tsx b/src/features/organization/components/user/OrganizationUserColumns.tsx index 2323cf3a5..7bb195719 100644 --- a/src/features/organization/components/user/OrganizationUserColumns.tsx +++ b/src/features/organization/components/user/OrganizationUserColumns.tsx @@ -148,7 +148,7 @@ export const useOrganizationUserColumns = (): ColumnDef - {tc('button.action')} + {tc('button.actions')} handleViewDetail(user)}> {tc('button.view')} From 5ce54369a4102961e9cdf4517781d46661e8b90d Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 8 Dec 2025 20:36:21 +0700 Subject: [PATCH 08/13] feat: update lesson and quiz components with new translations and UI enhancements - Added "mins" translation to lesson details in English and Vietnamese. - Enhanced quiz JSON files with additional translations for various quiz-related terms. - Updated breadcrumb component to ignore specific URL segments and improve navigation. - Refactored ExportRSAButton to accept a className prop for better styling flexibility. - Modified ClassroomCourseList and TeacherClassroomList components to remove unnecessary imports and improve readability. - Improved ClassroomSchedule component to navigate to course details on click. - Updated ClassroomSubHeader to remove unused props and streamline the component. - Changed footer copyright text in PrintPreviewModal. - Enhanced AdminCourseDetail to display creator information in Vietnamese. - Updated CourseDetailDescription to format dates according to the current locale. - Improved CourseDetailEnrolled to handle empty course data gracefully. - Refactored CourseList to remove unnecessary title prop. - Updated LessonAction to include ExportRSAButton and improve button layout. - Enhanced LessonContent and LessonDescription to adjust scroll area heights based on user roles. - Updated LessonOutline to use translated "mins" instead of hardcoded text. - Improved QuizResult to include detailed translations and better duration formatting. - Refactored QuizAttempt to enhance localization and improve attempt history display. - Updated QuizViewer to remove unnecessary console logs for cleaner code. --- messages/en/common/en_common.json | 14 +++- messages/en/lesson/en_lessonDetails.json | 1 + messages/en/quiz/en_quiz.json | 13 +++ messages/vi/common/vi_common.json | 13 ++- messages/vi/lesson/vi_lessonDetails.json | 1 + messages/vi/quiz/vi_quiz.json | 14 ++++ src/components/shared/SBreadcrumb.tsx | 35 ++++++-- .../shared/button/ExportRSAButton.tsx | 14 +++- .../shared/layout/BreadcrumbPageLayout.tsx | 4 +- .../shared/modals/PrintPreviewModal.tsx | 2 +- .../components/detail/ClassroomCourseList.tsx | 14 +--- .../components/list/TeacherClassroomList.tsx | 2 +- .../components/schedule/ClassroomSchedule.tsx | 9 +- .../components/ui/ClassroomSubHeader.tsx | 9 +- .../components/detail/AdminCourseDetail.tsx | 2 +- .../enrolled/CourseDetailDescription.tsx | 5 +- .../detail/enrolled/CourseDetailEnrolled.tsx | 4 +- .../course/components/list/CourseList.tsx | 3 +- .../lesson/components/detail/LessonAction.tsx | 39 +++------ .../components/detail/LessonContent.tsx | 22 +++-- .../components/detail/LessonDescription.tsx | 15 +++- .../components/detail/LessonOutline.tsx | 2 +- .../lesson/components/list/LessonList.tsx | 2 +- .../quiz/components/player/QuizResult.tsx | 83 +++++++++---------- .../quiz/components/viewer/QuizAttempt.tsx | 71 +++++++--------- .../quiz/components/viewer/QuizViewer.tsx | 1 - 26 files changed, 218 insertions(+), 176 deletions(-) diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index a534058f2..94de0c70a 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -3,10 +3,13 @@ "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" @@ -127,7 +130,10 @@ "markAsComplete": "Mark as Complete", "startQuiz": "Start Quiz", "contact": "Contact Us", - "menu": "Open Menu" + "menu": "Open Menu", + "exportRSA": "Export RSA", + "exporting": "Exporting...", + "downloadAndPrint": "Download & Print" }, "message": { "courseCreateSuccess": "Course created successfully!", @@ -214,7 +220,11 @@ "user": "User", "license": "License", "groupName": "Group", - "className": "Class Name" + "className": "Class Name", + "menu": "Open Menu", + "score": "Score", + "correctAnswer": "Correct Answer", + "submissionDate": "Submission Date" }, "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/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/common/vi_common.json b/messages/vi/common/vi_common.json index efff86b0a..ab725d7c3 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -2,11 +2,14 @@ "common": { "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" @@ -126,7 +129,10 @@ "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", + "exportRSA": "Xuất RSA", + "exporting": "Đang Xuất...", + "downloadAndPrint": "Tải Xuống & In" }, "message": { "courseCreateSuccess": "Khóa học được tạo thành công!", @@ -213,7 +219,10 @@ "user": "Người dùng", "license": "Vai trò", "groupName": "Nhóm", - "menu": "Mở Menu" + "menu": "Mở Menu", + "score": "Điểm", + "correctAnswer": "Đáp án đúng", + "submissionDate": "Ngày nộp bài" }, "paging": { "previous": "Trước", 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/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/src/components/shared/SBreadcrumb.tsx b/src/components/shared/SBreadcrumb.tsx index f704c8f3f..7cc05a0dd 100644 --- a/src/components/shared/SBreadcrumb.tsx +++ b/src/components/shared/SBreadcrumb.tsx @@ -33,6 +33,12 @@ 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() @@ -41,6 +47,11 @@ export default function SBreadcrumb({ title, size = 'md', color, weight }: SBrea .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 @@ -67,20 +78,26 @@ export default function SBreadcrumb({ title, size = 'md', color, weight }: SBrea 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 ( - ) diff --git a/src/components/shared/layout/BreadcrumbPageLayout.tsx b/src/components/shared/layout/BreadcrumbPageLayout.tsx index 6067b9aae..8e403259a 100644 --- a/src/components/shared/layout/BreadcrumbPageLayout.tsx +++ b/src/components/shared/layout/BreadcrumbPageLayout.tsx @@ -5,12 +5,10 @@ import { VariantProps } from 'class-variance-authority' import React from 'react' type BreadcrumbPageLayoutProps = { - title: string children: React.ReactNode } & VariantProps export default function BreadcrumbPageLayout({ - title, children, color, size, @@ -22,7 +20,7 @@ export default function BreadcrumbPageLayout({
- +
{children}
diff --git a/src/components/shared/modals/PrintPreviewModal.tsx b/src/components/shared/modals/PrintPreviewModal.tsx index 8c268a7ea..c2457458d 100644 --- a/src/components/shared/modals/PrintPreviewModal.tsx +++ b/src/components/shared/modals/PrintPreviewModal.tsx @@ -16,7 +16,7 @@ const PrintFooter = () => { return (
- Copyright 2025 © Strawbees AB + Copyright 2025 © STEMify Generated on {generationDate} {currentUrl}
diff --git a/src/features/classroom/components/detail/ClassroomCourseList.tsx b/src/features/classroom/components/detail/ClassroomCourseList.tsx index e84ee955d..d82e63329 100644 --- a/src/features/classroom/components/detail/ClassroomCourseList.tsx +++ b/src/features/classroom/components/detail/ClassroomCourseList.tsx @@ -1,26 +1,18 @@ 'use client' -import { useGetCurriculumByIdQuery } from '@/features/resource/curriculum/api/curriculumApi' -import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { Badge } from '@/components/shadcn/badge' import { Button } from '@/components/shadcn/button' -import { BookOpen, Clock, User, GraduationCap } from 'lucide-react' +import { BookOpen, Clock, GraduationCap } from 'lucide-react' import React from 'react' -import { getLevelBadgeClass } from '@/utils/badgeColor' import CardLayout from '@/components/shared/card/CardLayout' import { formatDuration } from '@/utils/index' import { ClassroomSchedule } from '@/features/classroom/components/schedule/ClassroomSchedule' -import { Course, CourseLevel, CourseStatus } from '@/features/resource/course/types/course.type' import { Curriculum } from '@/features/resource/curriculum/types/curriculum.type' import { CourseEnrollment, CurriculumEnrollment, EnrollmentStatus } from '@/features/enrollment/types/enrollment.type' -import { - useCreateCourseEnrollmentMutation, - useSearchCourseEnrollmentQuery -} from '@/features/enrollment/api/courseEnrollmentApi' -import { is } from 'date-fns/locale' +import { useCreateCourseEnrollmentMutation } from '@/features/enrollment/api/courseEnrollmentApi' import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' import { toast } from 'sonner' import { useTranslations } from 'next-intl' -import { useDispatch } from 'react-redux' import { setCourseEnrollmentId } from '@/features/enrollment/slice/enrollmentSlice' type ClassroomCourseListProps = { diff --git a/src/features/classroom/components/list/TeacherClassroomList.tsx b/src/features/classroom/components/list/TeacherClassroomList.tsx index a436c8bda..dbb3bcbd6 100644 --- a/src/features/classroom/components/list/TeacherClassroomList.tsx +++ b/src/features/classroom/components/list/TeacherClassroomList.tsx @@ -63,7 +63,7 @@ export default function TeacherClassroomList() { })) return ( - + {/* Header */}
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 c8b34586e..634cd298a 100644 --- a/src/features/classroom/components/ui/ClassroomSubHeader.tsx +++ b/src/features/classroom/components/ui/ClassroomSubHeader.tsx @@ -1,28 +1,23 @@ 'use client' -import Link from 'next/link' -import { usePathname } from 'next/navigation' import { cn } from '@/utils/shadcn/utils' 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() 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/list/CourseList.tsx b/src/features/resource/course/components/list/CourseList.tsx index 6fc71fe69..aff00649d 100644 --- a/src/features/resource/course/components/list/CourseList.tsx +++ b/src/features/resource/course/components/list/CourseList.tsx @@ -6,10 +6,9 @@ import { useTranslations } from 'next-intl' export default function CourseList() { const t = useTranslations('course') - const tc = useTranslations('common.breadcrumb') return ( - +
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 && ( - - )} + +
- {lessonStatus === ProgressStatus.NOT_STARTED && ( + {/* {lessonStatus === ProgressStatus.NOT_STARTED && (
- )} + )} */} {/* Secondary actions */} -
-
- - {t('action.add')} -
-
- - {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 2be25e42d..22a109485 100644 --- a/src/features/resource/lesson/components/list/LessonList.tsx +++ b/src/features/resource/lesson/components/list/LessonList.tsx @@ -8,7 +8,7 @@ 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..8334f79ab 100644 --- a/src/features/resource/quiz/components/viewer/QuizAttempt.tsx +++ b/src/features/resource/quiz/components/viewer/QuizAttempt.tsx @@ -2,23 +2,14 @@ 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' type QuizAttemptProps = { studentQuizId: number @@ -27,6 +18,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 +39,7 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt
-

No quiz attempt data available

+

{tq('noData')}

) @@ -59,7 +54,7 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt
@@ -72,43 +67,37 @@ 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')}` } return ( @@ -117,7 +106,9 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt {completedAttempts.length > 0 && ( -

Your final score: {quizData.finalScore}%

+

+ {tq('yourFinalScore')}: {quizData.finalScore}% +

)} @@ -125,7 +116,7 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt {/* Attempts History */}
-

Attempt History

+

{tq('attemptHistory')}

{completedAttempts.length} @@ -135,11 +126,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,7 +157,7 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt
- {formatDate(attempt.completedAt)} + {formatDate(attempt.completedAt, { locale })}
@@ -186,7 +177,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) From 8d9a375238e02a9e53d6dc03ae9008218cfe3933 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 8 Dec 2025 20:44:13 +0700 Subject: [PATCH 09/13] feat: add due date validation for quiz attempt selection --- .../quiz/components/viewer/QuizAttempt.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/features/resource/quiz/components/viewer/QuizAttempt.tsx b/src/features/resource/quiz/components/viewer/QuizAttempt.tsx index 8334f79ab..5ba191a1b 100644 --- a/src/features/resource/quiz/components/viewer/QuizAttempt.tsx +++ b/src/features/resource/quiz/components/viewer/QuizAttempt.tsx @@ -10,6 +10,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ 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 @@ -100,6 +101,8 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` } + const isBeforeDueDate = new Date() < new Date(studentQuiz.data.dueDate) + return (
{/* Overall Summary Card */} @@ -162,8 +165,16 @@ export default function QuizAttempt({ studentQuizId, selectedAttempt, onSelectAt 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) + }} /> From f910a2d673c37edec7287f3eff9c34d55f70608a Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 8 Dec 2025 21:00:56 +0700 Subject: [PATCH 10/13] feat: remove class code description and comment out Google Meet Card --- .../classroom/components/detail/StudentClassroomDetails.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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
- + */}
From b3713f0ef231692ebd9f111ca55b46bcdefe5c47 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 8 Dec 2025 21:31:36 +0700 Subject: [PATCH 11/13] feat: enhance translations and UI components for better user experience --- messages/en/common/en_common.json | 4 ++- messages/en/organization/en_organization.json | 2 ++ messages/vi/common/vi_common.json | 4 ++- messages/vi/organization/vi_organization.json | 2 ++ .../components/upsert/CreateClassroom.tsx | 2 +- .../components/list/GroupTableWithTeacher.tsx | 32 +++++++++++-------- src/features/group/types/group.type.ts | 1 + .../organization/OrganizationCourseDetail.tsx | 5 +-- 8 files changed, 34 insertions(+), 18 deletions(-) diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 94de0c70a..89487ec14 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -1,5 +1,6 @@ { "common": { + "noData": "Not found", "breadcrumb": { "home": "Home", "resource": "Resources", @@ -224,7 +225,8 @@ "menu": "Open Menu", "score": "Score", "correctAnswer": "Correct Answer", - "submissionDate": "Submission Date" + "submissionDate": "Submission Date", + "studentGroup": "Student Group" }, "paging": { "previous": "Previous", diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index b3e498be6..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.", diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index 58d9619af..b2373117e 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -1,5 +1,6 @@ { "common": { + "noData": "Không tìm thấy", "breadcrumb": { "home": "Trang Chủ", "course": "Khóa Học", @@ -223,7 +224,8 @@ "menu": "Mở Menu", "score": "Điểm", "correctAnswer": "Đáp án đúng", - "submissionDate": "Ngày nộp bài" + "submissionDate": "Ngày nộp bài", + "studentGroup": "Nhóm học sinh" }, "paging": { "previous": "Trước", diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index babd4dc85..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.", 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/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/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' && ( )} From 6c7dfecb53f73475e5590ea73094f21f3ec4a035 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 8 Dec 2025 21:41:33 +0700 Subject: [PATCH 12/13] feat: add upgrade button translation and update AuthStatusMenu to use it --- messages/en/common/en_common.json | 3 ++- messages/vi/common/vi_common.json | 3 ++- src/components/layout/header/header-action/AuthStatusMenu.tsx | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 89487ec14..e739a691e 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -134,7 +134,8 @@ "menu": "Open Menu", "exportRSA": "Export RSA", "exporting": "Exporting...", - "downloadAndPrint": "Download & Print" + "downloadAndPrint": "Download & Print", + "upgrade": "Upgrade Plan" }, "message": { "courseCreateSuccess": "Course created successfully!", diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index b2373117e..7031b9556 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -134,7 +134,8 @@ "actions": "Thao tác", "exportRSA": "Xuất RSA", "exporting": "Đang Xuất...", - "downloadAndPrint": "Tải Xuống & In" + "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!", 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')} From 3630e361869f2f6615567be563b2a6c9b677a8b0 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 8 Dec 2025 22:06:43 +0700 Subject: [PATCH 13/13] feat: simplify student avatar rendering and remove redundant properties --- .../components/detail/OrganizationClassroomDetail.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/features/classroom/components/detail/OrganizationClassroomDetail.tsx b/src/features/classroom/components/detail/OrganizationClassroomDetail.tsx index 78b898f84..56684da2b 100644 --- a/src/features/classroom/components/detail/OrganizationClassroomDetail.tsx +++ b/src/features/classroom/components/detail/OrganizationClassroomDetail.tsx @@ -265,16 +265,16 @@ export default function OrganizationClassroomDetail() { /> - + - {student.name?.charAt(0).toUpperCase() || student.Name?.charAt(0).toUpperCase() || 'S'} + {student.name?.charAt(0).toUpperCase() || 'S'}
-

{student.name || student.email || student.Email}

- {(student.email || student.Email) && ( -

{student.email || student.Email}

+

{student.name || student.email}

+ {(student.email) && ( +

{student.email}

)}