From 7c1d0499fa86edb75a11ba81994df3417775bf37 Mon Sep 17 00:00:00 2001 From: meewaldor Date: Sat, 6 Dec 2025 21:20:31 +0700 Subject: [PATCH 01/15] feat: Update calendar component in CreateClassroom to enhance date selection with dropdown captions and improved focus handling --- .../components/upsert/CreateClassroom.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/features/classroom/components/upsert/CreateClassroom.tsx b/src/features/classroom/components/upsert/CreateClassroom.tsx index c4e9b6a7..db9f34dd 100644 --- a/src/features/classroom/components/upsert/CreateClassroom.tsx +++ b/src/features/classroom/components/upsert/CreateClassroom.tsx @@ -253,15 +253,17 @@ export default function CreateClassroom() { { const today = new Date() today.setHours(0, 0, 0, 0) - const effectiveMinDate = minDate && minDate > today ? minDate : today - return date < effectiveMinDate || (maxDate ? date > maxDate : false) + return date < today }} - initialFocus + autoFocus /> @@ -290,10 +292,8 @@ export default function CreateClassroom() { mode='single' selected={endDate} onSelect={setEndDate} - disabled={(date) => { - return (minDate ? date < minDate : false) || (maxDate ? date > maxDate : false) - }} - initialFocus + disabled={true} + autoFocus /> From ee719eea4c42183f949f6deb1bcf77ced4e0efad Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 7 Dec 2025 10:24:43 +0700 Subject: [PATCH 02/15] feat: Update Next.js version to 15.5.7 for improved performance and features --- package-lock.json | 80 +++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b173aee..adf27485 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,7 @@ "jwt-decode": "^4.0.0", "lodash-es": "^4.17.21", "lucide-react": "^0.511.0", - "next": "^15.5.5", + "next": "^15.5.7", "next-auth": "^4.24.11", "next-intl": "^4.3.4", "next-themes": "^0.4.6", @@ -1391,9 +1391,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.5.tgz", - "integrity": "sha512-2Zhvss36s/yL+YSxD5ZL5dz5pI6ki1OLxYlh6O77VJ68sBnlUrl5YqhBgCy7FkdMsp9RBeGFwpuDCdpJOqdKeQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", + "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1407,9 +1407,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.5.tgz", - "integrity": "sha512-lYExGHuFIHeOxf40mRLWoA84iY2sLELB23BV5FIDHhdJkN1LpRTPc1MDOawgTo5ifbM5dvAwnGuHyNm60G1+jw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -1423,9 +1423,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.5.tgz", - "integrity": "sha512-cacs/WQqa96IhqUm+7CY+z/0j9sW6X80KE07v3IAJuv+z0UNvJtKSlT/T1w1SpaQRa9l0wCYYZlRZUhUOvEVmg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -1439,9 +1439,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.5.tgz", - "integrity": "sha512-tLd90SvkRFik6LSfuYjcJEmwqcNEnVYVOyKTacSazya/SLlSwy/VYKsDE4GIzOBd+h3gW+FXqShc2XBavccHCg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -1455,9 +1455,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.5.tgz", - "integrity": "sha512-ekV76G2R/l3nkvylkfy9jBSYHeB4QcJ7LdDseT6INnn1p51bmDS1eGoSoq+RxfQ7B1wt+Qa0pIl5aqcx0GLpbw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -1471,9 +1471,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.5.tgz", - "integrity": "sha512-tI+sBu+3FmWtqlqD4xKJcj3KJtqbniLombKTE7/UWyyoHmOyAo3aZ7QcEHIOgInXOG1nt0rwh0KGmNbvSB0Djg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -1487,9 +1487,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.5.tgz", - "integrity": "sha512-kDRh+epN/ulroNJLr+toDjN+/JClY5L+OAWjOrrKCI0qcKvTw9GBx7CU/rdA2bgi4WpZN3l0rf/3+b8rduEwrQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -1503,9 +1503,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.5.tgz", - "integrity": "sha512-GDgdNPFFqiKjTrmfw01sMMRWhVN5wOCmFzPloxa7ksDfX6TZt62tAK986f0ZYqWpvDFqeBCLAzmgTURvtQBdgw==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -1519,9 +1519,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.5.tgz", - "integrity": "sha512-5kE3oRJxc7M8RmcTANP8RGoJkaYlwIiDD92gSwCjJY0+j8w8Sl1lvxgQ3bxfHY2KkHFai9tpy/Qx1saWV8eaJQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -11210,12 +11210,12 @@ } }, "node_modules/next": { - "version": "15.5.5", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.5.tgz", - "integrity": "sha512-OQVdBPtpBfq7HxFN0kOVb7rXXOSIkt5lTzDJDGRBcOyVvNRIWFauMqi1gIHd1pszq1542vMOGY0HP4CaiALfkA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", + "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", "license": "MIT", "dependencies": { - "@next/env": "15.5.5", + "@next/env": "15.5.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -11228,14 +11228,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.5", - "@next/swc-darwin-x64": "15.5.5", - "@next/swc-linux-arm64-gnu": "15.5.5", - "@next/swc-linux-arm64-musl": "15.5.5", - "@next/swc-linux-x64-gnu": "15.5.5", - "@next/swc-linux-x64-musl": "15.5.5", - "@next/swc-win32-arm64-msvc": "15.5.5", - "@next/swc-win32-x64-msvc": "15.5.5", + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { diff --git a/package.json b/package.json index a702c22c..4c02cec3 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "jwt-decode": "^4.0.0", "lodash-es": "^4.17.21", "lucide-react": "^0.511.0", - "next": "^15.5.5", + "next": "^15.5.7", "next-auth": "^4.24.11", "next-intl": "^4.3.4", "next-themes": "^0.4.6", From c24782af8eeaf6e152eaabda87d14d5d7ca826f3 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 7 Dec 2025 13:09:44 +0700 Subject: [PATCH 03/15] feat: add grade field to classroom details and update translations feat: add removeFromGroup action and new fields in common messages feat: add updatedAt field in organization details and translations refactor: update group detail page to use new table component fix: update classroom detail to display grade and clean up commented code feat: enhance CreateClassroom component with new GroupTableWithTeacher refactor: remove old OrganizationGroupDetail and GroupTable components feat: implement GroupColumn for better student management in groups feat: create OrganizationGroupTableDetail for displaying group information feat: add GroupTableWithTeacher for selecting groups with teacher assignments feat: implement groupSlice for managing group state in Redux fix: update userApi to include role in user queries fix: update AddPeopleModal to use classroom translations --- messages/en/classroom/en_classroom.json | 1 + messages/en/common/en_common.json | 8 +- messages/en/organization/en_organization.json | 1 + messages/vi/classroom/vi_classroom.json | 1 + messages/vi/common/vi_common.json | 8 +- messages/vi/organization/vi_organization.json | 1 + .../organization/group/[groupId]/page.tsx | 4 +- .../detail/OrganizationClassroomDetail.tsx | 9 +- .../components/upsert/CreateClassroom.tsx | 12 +- .../group/components/detail/GroupColumn.tsx | 107 ++++++++++++ .../detail/OrganizationGroupDetail.tsx | 165 ------------------ .../detail/OrganizationGroupTableDetail.tsx | 125 +++++++++++++ ...oupTable.tsx => GroupTableWithTeacher.tsx} | 32 ++-- src/features/group/slice/groupSlice.ts | 14 ++ src/features/group/types/group.type.ts | 6 + src/features/user/api/userApi.ts | 4 +- .../user/components/modal/AddPeopleModal.tsx | 4 +- src/features/user/types/user.type.ts | 1 + src/libs/auth/authOptions.ts | 3 +- src/libs/redux/rootReducer.ts | 2 + src/utils/index.ts | 39 ++++- 21 files changed, 342 insertions(+), 205 deletions(-) create mode 100644 src/features/group/components/detail/GroupColumn.tsx delete mode 100644 src/features/group/components/detail/OrganizationGroupDetail.tsx create mode 100644 src/features/group/components/detail/OrganizationGroupTableDetail.tsx rename src/features/group/components/list/{GroupTable.tsx => GroupTableWithTeacher.tsx} (84%) create mode 100644 src/features/group/slice/groupSlice.ts diff --git a/messages/en/classroom/en_classroom.json b/messages/en/classroom/en_classroom.json index a9eb40e7..9c709514 100644 --- a/messages/en/classroom/en_classroom.json +++ b/messages/en/classroom/en_classroom.json @@ -42,6 +42,7 @@ "backToClassrooms": "Back to Classrooms", "header": "Classroom Details", "selected": "Selected", + "grade": "Grade", "students": { "label": "Students", "noStudent": "No students enrolled yet.", diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index b299db6c..97cccea6 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -109,7 +109,8 @@ "addQuestion": "Add Question", "addCriterion": "Add Criterion", "createGroup": "Create Group", - "cancelSubscription": "Cancel Subscription" + "cancelSubscription": "Cancel Subscription", + "removeFromGroup": "Remove from Group" }, "message": { "courseCreateSuccess": "Course created successfully!", @@ -189,7 +190,10 @@ "numberOfLessons": "No. Lessons", "accountType": "Account Type", "assignedDate": "Assigned Date", - "course": "Course" + "course": "Course", + "joinedAt": "Joined At", + "subscription": "Subscription", + "student": "Student(s)" }, "paging": { "previous": "Previous", diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index d54dd03b..74801d55 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -209,6 +209,7 @@ "numberOfStudents": "Number of Students: {quantity}", "groupList": "Group List", "createdDate": "Created Date", + "updatedAt": "Updated Date", "totalStudents": "Total Students", "attendance": "Attendance", "step1": { diff --git a/messages/vi/classroom/vi_classroom.json b/messages/vi/classroom/vi_classroom.json index 2d5e14f3..6a2ca6d1 100644 --- a/messages/vi/classroom/vi_classroom.json +++ b/messages/vi/classroom/vi_classroom.json @@ -41,6 +41,7 @@ "backToClassrooms": "Quay lại Lớp học", "header": "Chi tiết lớp học", "selected": "Đã chọn", + "grade": "Khối", "students": { "label": "Học sinh", "noStudent": "Chưa có học sinh nào.", diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index 5f8c0076..30e9f427 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -108,7 +108,8 @@ "addQuestion": "Thêm Câu Hỏi", "addCriterion": "Thêm Tiêu Chí", "createGroup": "Tạo Nhóm", - "cancelSubscription": "Hủy Đăng Ký" + "cancelSubscription": "Hủy Đăng Ký", + "removeFromGroup": "Xóa khỏi Nhóm" }, "message": { "courseCreateSuccess": "Khóa học được tạo thành công!", @@ -187,7 +188,10 @@ "numberOfLessons": "Số Bài Học", "accountType": "Loại Tài Khoản", "assignedDate": "Ngày Gán", - "course": "Khóa Học" + "course": "Khóa Học", + "joinedAt": "Ngày tham gia", + "subscription": "Gói đăng ký", + "student": "Học sinh" }, "paging": { "previous": "Trước", diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index 97665832..d105108e 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -208,6 +208,7 @@ "numberOfStudents": "Số lượng: {quantity} học sinh", "groupList": "Danh sách học sinh", "createdDate": "Ngày tạo", + "updatedAt": "Ngày cập nhật", "totalStudents": "Tổng số học sinh", "attendance": "Tham gia", "step1": { diff --git a/src/app/[locale]/organization/group/[groupId]/page.tsx b/src/app/[locale]/organization/group/[groupId]/page.tsx index c5bfc1bb..c635feb5 100644 --- a/src/app/[locale]/organization/group/[groupId]/page.tsx +++ b/src/app/[locale]/organization/group/[groupId]/page.tsx @@ -1,10 +1,10 @@ -import OrganizationGroupDetail from '@/features/group/components/detail/OrganizationGroupDetail' +import OrganizationGroupTable from '@/features/group/components/detail/OrganizationGroupTableDetail' import React from 'react' export default function GroupDetailPage() { return (
- +
) } diff --git a/src/features/classroom/components/detail/OrganizationClassroomDetail.tsx b/src/features/classroom/components/detail/OrganizationClassroomDetail.tsx index 43ad4812..e94a8b6f 100644 --- a/src/features/classroom/components/detail/OrganizationClassroomDetail.tsx +++ b/src/features/classroom/components/detail/OrganizationClassroomDetail.tsx @@ -116,7 +116,9 @@ export default function OrganizationClassroomDetail() {
- {classroom.grade} + + {tClassroom('detail.grade')} {classroom.grade} +
@@ -236,11 +238,12 @@ export default function OrganizationClassroomDetail() { {tc('button.remove')} )} + {/* TODO */} - + */}
diff --git a/src/features/classroom/components/upsert/CreateClassroom.tsx b/src/features/classroom/components/upsert/CreateClassroom.tsx index db9f34dd..a3c03f6a 100644 --- a/src/features/classroom/components/upsert/CreateClassroom.tsx +++ b/src/features/classroom/components/upsert/CreateClassroom.tsx @@ -19,7 +19,7 @@ import { CalendarIcon } from 'lucide-react' import { format } from 'date-fns' import { cn } from '@/utils/shadcn/utils' import BackButton from '@/components/shared/button/BackButton' -import GroupTable from '@/features/group/components/list/GroupTable' +import GroupTableWithTeacher from '@/features/group/components/list/GroupTableWithTeacher' import { Grade } from '@/features/classroom/types/classroom.type' type ClassroomFormData = { @@ -192,7 +192,7 @@ export default function CreateClassroom() { - setSelectedGroups(groups)} /> + setSelectedGroups(groups)} /> {/* Basic Information Section */} @@ -288,13 +288,7 @@ export default function CreateClassroom() { - + diff --git a/src/features/group/components/detail/GroupColumn.tsx b/src/features/group/components/detail/GroupColumn.tsx new file mode 100644 index 00000000..257daa20 --- /dev/null +++ b/src/features/group/components/detail/GroupColumn.tsx @@ -0,0 +1,107 @@ +import { createActionsColumnFromItems, createSelectColumn } from '@/components/shared/data-table/columns-helpers' +import { useTranslations } from 'next-intl' +import { useModal } from '@/providers/ModalProvider' +import { ColumnDef } from '@tanstack/react-table' +import { toast } from 'sonner' +import { GroupDetailStudent } from '@/features/group/types/group.type' +import { useDeleteGroupMutation } from '@/features/group/api/groupApi' +import { Badge } from '@/components/shadcn/badge' +import { Avatar, AvatarFallback } from '@/components/shadcn/avatar' +import { formatDate } from '@/utils/index' + +export function useGetGroupColumn(): ColumnDef[] { + const { openModal } = useModal() + const [deleteGroup] = useDeleteGroupMutation() + const tc = useTranslations('common') + const tt = useTranslations('toast') + + const handleDelete = async (id: string) => { + try { + await deleteGroup(id).unwrap() + toast.success(tt('successMessage.delete')) + } catch (error) { + toast.error(tt('errorMessage')) + } + } + + const getInitials = (fullName: string) => { + return fullName + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2) + } + + return [ + createSelectColumn(), + { + accessorKey: 'fullName', + header: tc('tableHeader.student'), + cell: ({ row }) => { + const student = row.original + return ( +
+ + + {getInitials(student.fullName)} + + +
+
{student.fullName}
+
{student.userName}
+
+
+ ) + } + }, + { + accessorKey: 'email', + header: tc('tableHeader.email'), + cell: ({ row }) =>
{row.original.email}
+ }, + { + accessorKey: 'isActive', + header: tc('tableHeader.status'), + cell: ({ row }) => ( + + {row.original.isActive ? tc('status.active') : tc('status.inactive')} + + ) + }, + { + accessorKey: 'subscriptionOrderId', + header: tc('tableHeader.subscription'), + cell: ({ row }) =>
#{row.original.subscriptionOrderId}
+ }, + { + accessorKey: 'joinedAt', + header: tc('tableHeader.joinedAt'), + cell: ({ row }) =>
{formatDate(row.original.joinedAt)}
+ }, + createActionsColumnFromItems([ + // { + // label: tc('button.viewDetails'), + // onClick: ({ original }) => { + // openModal('studentDetails', { studentId: original.organizationUserId }) + // } + // }, + // { + // label: tc('button.update'), + // onClick: ({ original }) => { + // openModal('upsertStudent', { id: original.organizationUserId }) + // } + // }, + { + label: tc('button.removeFromGroup'), + danger: true, + onClick: async ({ original }) => { + openModal('confirm', { + message: `${tt('confirmMessage.remove', { title: original.fullName })}`, + onConfirm: () => handleDelete(original.organizationUserId) + }) + } + } + ]) + ] +} diff --git a/src/features/group/components/detail/OrganizationGroupDetail.tsx b/src/features/group/components/detail/OrganizationGroupDetail.tsx deleted file mode 100644 index 39dbabf6..00000000 --- a/src/features/group/components/detail/OrganizationGroupDetail.tsx +++ /dev/null @@ -1,165 +0,0 @@ -'use client' -import { useGetGroupByIdQuery } from '@/features/group/api/groupApi' -import { Button } from '@/components/shadcn/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/shadcn/card' -import { Badge } from '@/components/shadcn/badge' -import { Avatar, AvatarFallback } from '@/components/shadcn/avatar' -import { Skeleton } from '@/components/shadcn/skeleton' -import { ArrowLeft, Users, Calendar, CheckCircle2, XCircle } from 'lucide-react' -import { useParams, useRouter } from 'next/navigation' -import { Group, GroupStatus } from '@/features/group/types/group.type' -import { formatDate } from '@/utils/index' -import { useLocale, useTranslations } from 'next-intl' - -export default function OrganizationGroupDetail() { - const router = useRouter() - const locale = useLocale() - const { groupId } = useParams() - - const tc = useTranslations('common') - const to = useTranslations('organization.group') - - const { data, isLoading, isError } = useGetGroupByIdQuery(Number(groupId), { skip: !groupId }) - - const groupData: Group | undefined = data?.data - - const getInitials = (name: string) => { - return name - .split(' ') - .map((n) => n[0]) - .join('') - .toUpperCase() - .slice(0, 2) - } - - if (isLoading) { - return ( -
- - - -
- ) - } - - if (isError || !groupData) { - return ( -
- - - Lỗi - Không thể tải thông tin nhóm - - - - - -
- ) - } - - const activeStudents = groupData.students.filter((s) => s.isActive).length - const totalStudents = groupData.students.length - - return ( -
- {/* Header */} -
- -
- - {/* Group Info Card */} - - -
-
- {groupData.name} - - {to('groupCode')} {groupData.code} - -
- - {groupData.status === GroupStatus.ACTIVE ? tc('status.active') : tc('status.inactive')} - -
-
- -
-
- -
-

{to('totalStudents')}

-

{totalStudents}

-
-
-
- -
-

{tc('status.active')}

-

{activeStudents}

-
-
-
- -
-

{to('createdDate')}

-

{formatDate(groupData.createdAt, { locale })}

-
-
-
-
- -
-

{to('groupList')}

-
- -
- {groupData.students.map((student) => ( -
-
- - - {getInitials(student.fullName)} - - -
-

{student.fullName}

-

{student.email}

-

- Tham gia: {formatDate(student.joinedAt, { locale })} -

-
-
-
- {student.isActive ? ( - - - Hoạt động - - ) : ( - - - Không hoạt động - - )} -
-
- ))} -
-
-
-
- ) -} diff --git a/src/features/group/components/detail/OrganizationGroupTableDetail.tsx b/src/features/group/components/detail/OrganizationGroupTableDetail.tsx new file mode 100644 index 00000000..d9250c5f --- /dev/null +++ b/src/features/group/components/detail/OrganizationGroupTableDetail.tsx @@ -0,0 +1,125 @@ +'use client' +import { useLocale, useTranslations } from 'next-intl' +import { useGetGroupByIdQuery } from '@/features/group/api/groupApi' +import { DataTable } from '@/components/shared/data-table/data-table' +import { useGetGroupColumn } from '@/features/group/components/detail/GroupColumn' +import { useMemo } from 'react' +import BackButton from '@/components/shared/button/BackButton' +import { useParams } from 'next/navigation' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/shadcn/card' +import { Badge } from '@/components/shadcn/badge' +import { Users, Calendar, Hash, Activity } from 'lucide-react' +import { formatDate } from '@/utils/index' + +export default function OrganizationGroupTable() { + const { groupId } = useParams() + const to = useTranslations('organization.group') + const tc = useTranslations('common') + const columns = useGetGroupColumn() + const { data, isLoading } = useGetGroupByIdQuery(Number(groupId), { skip: !groupId }) + + const groupData = data?.data + + const rows = useMemo( + () => groupData?.students?.map((student) => ({ ...student, id: student.organizationUserId })) ?? [], + [groupData] + ) + + const stats = useMemo(() => { + if (!groupData?.students) return { total: 0, active: 0, inactive: 0 } + + const total = groupData.students.length + const active = groupData.students.filter((s) => s.isActive).length + const inactive = total - active + + return { total, active, inactive } + }, [groupData]) + + if (isLoading) { + return ( +
+
+
{tc('loading')}
+
+
+ ) + } + + if (!groupData) { + return ( +
+
+
{tc('noData')}
+
+
+ ) + } + + return ( +
+ {/* Header Section */} +
+ +
+
+

{groupData.name}

+ + {groupData.status} + +
+

{to('subTitle')}

+
+
+ + {/* Group Information Cards */} +
+ + + {to('totalStudents')} + + + +
{stats.total}
+

+ {stats.active} {tc('status.active')} • {stats.inactive} {tc('status.inactive')} +

+
+
+ + + + {to('groupCode')} + + + +
{groupData.code}
+
+
+ + + + {to('createdDate')} + + + +
{formatDate(groupData.createdAt)}
+
+
+ + + + {to('updatedAt')} + + + +
{formatDate(groupData.updatedAt)}
+
+
+
+ + +
+ ) +} diff --git a/src/features/group/components/list/GroupTable.tsx b/src/features/group/components/list/GroupTableWithTeacher.tsx similarity index 84% rename from src/features/group/components/list/GroupTable.tsx rename to src/features/group/components/list/GroupTableWithTeacher.tsx index a34f8aa4..79697333 100644 --- a/src/features/group/components/list/GroupTable.tsx +++ b/src/features/group/components/list/GroupTableWithTeacher.tsx @@ -2,15 +2,16 @@ import React, { useEffect, useState } from 'react' import { SingleSelectWithSearch } from '@/components/shared/SingleSelectWithSearch' import { useSearchGroupByOrganizationIdQuery } from '@/features/group/api/groupApi' import { LicenseAssignmentType } from '@/features/license-assignment/types/licenseAssignment' -import { useSearchUserV2Query } from '@/features/user/api/userApi' +import { useGetOrganizationUserQuery, useSearchUserV2Query } from '@/features/user/api/userApi' import { useAppSelector } from '@/hooks/redux-hooks' -import { getOptions } from '@/utils/index' +import { getOptions, getOptionsV2 } from '@/utils/index' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/shadcn/table' import { Checkbox } from '@/components/shadcn/checkbox' import { Group } from '@/features/group/types/group.type' import { useTranslations } from 'next-intl' +import { LicenseType } from '@/types/userRole' -type GroupTableProps = { +type GroupTableWithTeacherProps = { onGroupsChange: ( groups: { groupCode: string @@ -21,26 +22,29 @@ type GroupTableProps = { ) => void } -export default function GroupTable({ onGroupsChange }: GroupTableProps) { - const tClassroom = useTranslations('classroom.create') +export default function GroupTableWithTeacher({ onGroupsChange }: GroupTableWithTeacherProps) { const [selectedRows, setSelectedRows] = useState([]) const [teacherAssignments, setTeacherAssignments] = useState>({}) - const searchUserQuery = useAppSelector((state) => state.user) - const { selectedSubscriptionOrderId, selectedOrganizationId } = useAppSelector((state) => state.selectedOrganization) + const { selectedOrganizationId } = useAppSelector((state) => state.selectedOrganization) const { data } = useSearchGroupByOrganizationIdQuery( { organizationId: selectedOrganizationId!, params: {} }, { skip: !selectedOrganizationId } ) - const { data: teacherData } = useSearchUserV2Query({ - ...searchUserQuery, - license_type: LicenseAssignmentType.TEACHER, - subscription_order_id: selectedSubscriptionOrderId - }) + const { data: organizationUserData } = useGetOrganizationUserQuery( + { organizationId: selectedOrganizationId!, role: LicenseType.TEACHER }, + { skip: !selectedOrganizationId } + ) - const teacherOptions = getOptions(teacherData?.data.items, 'userName', 'imageUrl', 'email') + const teacherOptions = getOptionsV2( + organizationUserData?.data.items, + 'userName', + 'organizationUserId', + 'imageUrl', + 'email' + ) const groups = data?.data.items || [] const emitSelectedGroups = () => { @@ -52,7 +56,7 @@ export default function GroupTable({ onGroupsChange }: GroupTableProps) { groupCode: group.code, groupName: group.name, teacherId: teacherAssignments[groupId], - studentIds: group.students.map((s) => s.userId) + studentIds: group.students.map((s) => s.organizationUserId) } }) diff --git a/src/features/group/slice/groupSlice.ts b/src/features/group/slice/groupSlice.ts new file mode 100644 index 00000000..f30629c7 --- /dev/null +++ b/src/features/group/slice/groupSlice.ts @@ -0,0 +1,14 @@ +import { GroupSliceParams } from '@/features/group/types/group.type' +import { createQuerySlice } from '@/libs/redux/createQuerySlice' + +const initialState: GroupSliceParams = { + pageNumber: 1, + pageSize: 20, + search: '', + orderBy: '', + status: '' +} + +export const groupSlice = createQuerySlice('groupSlice', initialState) + +export const { setPageIndex, setPageSize, setSearchTerm, setParam, setMultipleParams, resetParams } = groupSlice.actions diff --git a/src/features/group/types/group.type.ts b/src/features/group/types/group.type.ts index 9a14c03b..2a39dd48 100644 --- a/src/features/group/types/group.type.ts +++ b/src/features/group/types/group.type.ts @@ -1,3 +1,4 @@ +import { SliceQueryParams } from '@/libs/redux/createQuerySlice' import { SearchPaginatedRequestParams } from '@/types/baseModel' export enum GroupStatus { @@ -33,3 +34,8 @@ export type GroupQueryParams = { includeArchived?: boolean activeOnly?: boolean } & SearchPaginatedRequestParams + +export type GroupSliceParams = { + includeArchived?: boolean + activeOnly?: boolean +} & SliceQueryParams diff --git a/src/features/user/api/userApi.ts b/src/features/user/api/userApi.ts index 1c8825f2..3f2d1704 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 }) => ({ + query: ({ organizationId, pageNumber, pageSize, role }) => ({ url: `/organizations/${organizationId}/users`, method: 'GET', - params: { pageNumber, pageSize } + params: { pageNumber, pageSize, role } }), providesTags: ['User'] }) diff --git a/src/features/user/components/modal/AddPeopleModal.tsx b/src/features/user/components/modal/AddPeopleModal.tsx index 028f515f..f39ff392 100644 --- a/src/features/user/components/modal/AddPeopleModal.tsx +++ b/src/features/user/components/modal/AddPeopleModal.tsx @@ -279,9 +279,9 @@ export default function AddPeopleModal() { ) : debouncedKeyword.trim() ? (
- {tc('update.students.noStudentFound')} "{debouncedKeyword}" + {tClassroom('update.students.noStudentFound')} "{debouncedKeyword}"
-
{tc('update.students.noStudentFoundSubtext')}
+
{tClassroom('update.students.noStudentFoundSubtext')}
) : null} diff --git a/src/features/user/types/user.type.ts b/src/features/user/types/user.type.ts index 454cac05..426974d9 100644 --- a/src/features/user/types/user.type.ts +++ b/src/features/user/types/user.type.ts @@ -87,4 +87,5 @@ export type OrganizationUserQueryParams = { organizationId: number pageNumber?: number pageSize?: number + role?: LicenseType } diff --git a/src/libs/auth/authOptions.ts b/src/libs/auth/authOptions.ts index 60ede290..45dd8498 100644 --- a/src/libs/auth/authOptions.ts +++ b/src/libs/auth/authOptions.ts @@ -69,7 +69,7 @@ export const authOptions: NextAuthOptions = { }, async jwt({ token, account, profile }) { if (account?.access_token) { - console.log('JWT callback', { profile }) + // console.log('JWT callback', { profile }) token.accessToken = account.access_token token.idToken = account.id_token token.role = profile?.role || UserRole.GUEST @@ -80,7 +80,6 @@ export const authOptions: NextAuthOptions = { console.error('Failed to parse organizations JSON:', err) token.organizations = undefined } - console.log('Token debug:', token) // try { // const decoded: any = jwtDecode(account.access_token) diff --git a/src/libs/redux/rootReducer.ts b/src/libs/redux/rootReducer.ts index eacfb9c7..2b9d1d0d 100644 --- a/src/libs/redux/rootReducer.ts +++ b/src/libs/redux/rootReducer.ts @@ -74,6 +74,7 @@ import { studentAssignmentSelectedSlice } from '@/features/assignment/slice/stud import { enrollmentSlice } from '@/features/enrollment/slice/enrollmentSlice' import { groupApi } from '@/features/group/api/groupApi' import { organizationSpecialSlice } from '@/features/organization/slice/organizationSpecialSlice' +import { groupSlice } from '@/features/group/slice/groupSlice' export const rootReducer = combineReducers({ // Add your reducers here @@ -118,6 +119,7 @@ export const rootReducer = combineReducers({ enrollment: enrollmentSlice.reducer, organizationSpecial: organizationSpecialSlice.reducer, selectedCurriculum: selectedCurriculumSlice.reducer, + group: groupSlice.reducer, // api reducers [courseApi.reducerPath]: courseApi.reducer, diff --git a/src/utils/index.ts b/src/utils/index.ts index 64e0c888..b9a33745 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' export const formatDuration = (minutes: number) => { if (typeof minutes !== 'number' || isNaN(minutes) || minutes <= 0) return '00:00' @@ -50,7 +50,8 @@ export interface FormatDateOptions { day?: 'numeric' | '2-digit' } export const formatDate = (dateString: string, options: FormatDateOptions = {}) => { - const { locale = 'en', showTime = false, pattern, year = 'numeric', month = 'short', day = 'numeric' } = options + const locale = useLocale() + const { showTime = false, pattern, year = 'numeric', month = 'short', day = 'numeric' } = options const date = new Date(dateString) @@ -141,6 +142,40 @@ export const getOptions = ( : undefined })) || [] +type OptionResult = { + value: string + label: string + imageUrl?: string + subLabel?: string + status?: string + date?: string +} + +export const getOptionsV2 = ( + data: any[] | undefined, + labelKey: string, + valueKey: string, // ✅ THÊM + imageKey?: string, + subLabelKey?: string, + statusKey?: string, + startDateKey?: string, + endDateKey?: string +): OptionResult[] => + data?.map((item) => ({ + value: item[valueKey]?.toString() ?? '', + label: item[labelKey], + imageUrl: imageKey ? item[imageKey] : undefined, + subLabel: subLabelKey ? item[subLabelKey] : undefined, + status: statusKey ? item[statusKey] : undefined, + date: + startDateKey && endDateKey + ? 'Start Date: ' + + (item[startDateKey] ? new Date(item[startDateKey]).toLocaleDateString() : 'N/A') + + ' - End Date: ' + + (item[endDateKey] ? new Date(item[endDateKey]).toLocaleDateString() : 'N/A') + : undefined + })) || [] + export function fileToBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() From 5cd5d671663e3eb986576f6e2f66abcc5f4f2ebc Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 7 Dec 2025 13:24:20 +0700 Subject: [PATCH 04/15] feat: add organization user status translations and update related components --- messages/en/common/en_common.json | 4 + messages/vi/common/vi_common.json | 4 + .../group/components/detail/GroupColumn.tsx | 7 +- .../detail/OrganizationGroupTableDetail.tsx | 110 +++++++++++------- src/utils/index.ts | 7 ++ 5 files changed, 90 insertions(+), 42 deletions(-) diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 97cccea6..5501133c 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -237,6 +237,10 @@ "inprogress": "In Progress", "locked": "Locked" }, + "orgUserStatus": { + "active": "Active", + "inactive": "Inactive" + }, "level": { "all": "All Levels", "beginner": "Beginner", diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index 30e9f427..fc39140d 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -241,6 +241,10 @@ "inprogress": "Đang Diễn Ra", "locked": "Khóa" }, + "orgUserStatus": { + "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/features/group/components/detail/GroupColumn.tsx b/src/features/group/components/detail/GroupColumn.tsx index 257daa20..5412daec 100644 --- a/src/features/group/components/detail/GroupColumn.tsx +++ b/src/features/group/components/detail/GroupColumn.tsx @@ -7,13 +7,14 @@ import { GroupDetailStudent } from '@/features/group/types/group.type' import { useDeleteGroupMutation } from '@/features/group/api/groupApi' import { Badge } from '@/components/shadcn/badge' import { Avatar, AvatarFallback } from '@/components/shadcn/avatar' -import { formatDate } from '@/utils/index' +import { formatDate, useOrgUserStatusTranslation } from '@/utils/index' export function useGetGroupColumn(): ColumnDef[] { const { openModal } = useModal() const [deleteGroup] = useDeleteGroupMutation() const tc = useTranslations('common') const tt = useTranslations('toast') + const orgUserStatusTranslation = useOrgUserStatusTranslation() const handleDelete = async (id: string) => { try { @@ -64,8 +65,8 @@ export function useGetGroupColumn(): ColumnDef[] { accessorKey: 'isActive', header: tc('tableHeader.status'), cell: ({ row }) => ( - - {row.original.isActive ? tc('status.active') : tc('status.inactive')} + + {row.original.isActive ? orgUserStatusTranslation('active') : orgUserStatusTranslation('inactive')} ) }, diff --git a/src/features/group/components/detail/OrganizationGroupTableDetail.tsx b/src/features/group/components/detail/OrganizationGroupTableDetail.tsx index d9250c5f..cc94e791 100644 --- a/src/features/group/components/detail/OrganizationGroupTableDetail.tsx +++ b/src/features/group/components/detail/OrganizationGroupTableDetail.tsx @@ -8,14 +8,17 @@ import BackButton from '@/components/shared/button/BackButton' import { useParams } from 'next/navigation' import { Card, CardContent, CardHeader, CardTitle } from '@/components/shadcn/card' import { Badge } from '@/components/shadcn/badge' -import { Users, Calendar, Hash, Activity } from 'lucide-react' -import { formatDate } from '@/utils/index' +import { Users, Calendar, Hash, Activity, Copy } from 'lucide-react' +import { formatDate, useOrgUserStatusTranslation } from '@/utils/index' +import { toast } from 'sonner' export default function OrganizationGroupTable() { const { groupId } = useParams() const to = useTranslations('organization.group') const tc = useTranslations('common') + const tt = useTranslations('toast') const columns = useGetGroupColumn() + const orgUserStatusTranslation = useOrgUserStatusTranslation() const { data, isLoading } = useGetGroupByIdQuery(Number(groupId), { skip: !groupId }) const groupData = data?.data @@ -45,6 +48,11 @@ export default function OrganizationGroupTable() { ) } + const handleCopyToClipboard = (text: string) => { + navigator.clipboard.writeText(text) + toast.success(tt('successMessage.copiedToClipboard')) + } + if (!groupData) { return (
@@ -64,61 +72,85 @@ export default function OrganizationGroupTable() {

{groupData.name}

- {groupData.status} + {orgUserStatusTranslation(groupData.status)}

{to('subTitle')}

- {/* Group Information Cards */} -
- +
+ {/* Large Card: Statistics - Spans 1 column */} + {to('totalStudents')} -
{stats.total}
-

- {stats.active} {tc('status.active')} • {stats.inactive} {tc('status.inactive')} -

+
{stats.total}
+
+
+
+
+ {orgUserStatusTranslation('active')} +
+ {stats.active} +
+
+
+
+ {orgUserStatusTranslation('inactive')} +
+ {stats.inactive} +
+
- - - {to('groupCode')} - - - -
{groupData.code}
-
-
+ {/* Right Side: Stacked Cards - Spans 2 columns */} +
+ + + {to('groupCode')} + + + +
+

{groupData.code}

+ +
+
+
- - - {to('createdDate')} - - - -
{formatDate(groupData.createdAt)}
-
-
+
+ + + {to('createdDate')} + + + +
{formatDate(groupData.createdAt)}
+
+
- - - {to('updatedAt')} - - - -
{formatDate(groupData.updatedAt)}
-
-
+ + + {to('updatedAt')} + + + +
{formatDate(groupData.updatedAt)}
+
+
+
+
-
) diff --git a/src/utils/index.ts b/src/utils/index.ts index b9a33745..a146511b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -211,6 +211,13 @@ export const useStatusTranslation = () => { } } +export const useOrgUserStatusTranslation = () => { + const tc = useTranslations('common.orgUserStatus') + return (status: string) => { + return tc(status.toLowerCase()) + } +} + export const useLevelTranslation = () => { const tc = useTranslations('common.level') return (level: string) => { From 068ef5241195446d7ec755b730e2a28a4d372abf Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 7 Dec 2025 14:13:25 +0700 Subject: [PATCH 05/15] feat: add myLearning translations and update related components for improved user experience --- messages/en/classroom/en_classroom.json | 6 ++++ messages/en/user/en_myLearning.json | 2 +- messages/vi/classroom/vi_classroom.json | 6 ++++ messages/vi/user/vi_myLearning.json | 2 +- .../components/list/ClassroomList.tsx | 31 +++++++++---------- .../group/components/detail/GroupColumn.tsx | 5 +-- .../detail/OrganizationGroupTableDetail.tsx | 5 +-- .../components/my-learning/MyLearningHero.tsx | 2 +- .../components/my-learning/MyLearningList.tsx | 11 ++----- .../slice/selectedOrganizationSlice.ts | 14 +++++++-- src/providers/AuthSessionSync.tsx | 2 ++ src/utils/index.ts | 5 ++- 12 files changed, 55 insertions(+), 36 deletions(-) diff --git a/messages/en/classroom/en_classroom.json b/messages/en/classroom/en_classroom.json index 9c709514..f1dc8212 100644 --- a/messages/en/classroom/en_classroom.json +++ b/messages/en/classroom/en_classroom.json @@ -100,6 +100,12 @@ "asmTotal": "Total Assignments", "submitted": "submitted" } + }, + "myLearning": { + "title": "My Classrooms", + "noClassroom": "You are not enrolled in any classrooms yet.", + "noClassroomSubtext": "Explore available classrooms and start learning today!", + "grade": "Grade" } } } diff --git a/messages/en/user/en_myLearning.json b/messages/en/user/en_myLearning.json index b26dfc22..6004648d 100644 --- a/messages/en/user/en_myLearning.json +++ b/messages/en/user/en_myLearning.json @@ -1,5 +1,5 @@ { - "MyLearning": { + "myLearning": { "title": "Your Learning Journey", "subtitle": "Continue your learning journey with these courses", "description": "Continue learning and developing your skills with your enrolled courses.", diff --git a/messages/vi/classroom/vi_classroom.json b/messages/vi/classroom/vi_classroom.json index 6a2ca6d1..a84d0a9c 100644 --- a/messages/vi/classroom/vi_classroom.json +++ b/messages/vi/classroom/vi_classroom.json @@ -99,6 +99,12 @@ "asmTotal": "Tổng số Bài Tập", "submitted": "đã nộp" } + }, + "myLearning": { + "title": "Lớp học của tôi", + "noClassroom": "Bạn chưa tham gia lớp học nào.", + "noClassroomSubtext": "Khám phá các lớp học có sẵn và bắt đầu học ngay hôm nay!", + "grade": "Khối" } } } diff --git a/messages/vi/user/vi_myLearning.json b/messages/vi/user/vi_myLearning.json index 732a478e..f50a8d2d 100644 --- a/messages/vi/user/vi_myLearning.json +++ b/messages/vi/user/vi_myLearning.json @@ -1,5 +1,5 @@ { - "MyLearning": { + "myLearning": { "title": "Hành Trình Học Tập Của Bạn", "subtitle": "Tiếp tục hành trình học tập của bạn với các khóa học này", "description": "Tiếp tục học hỏi và phát triển kỹ năng với các khóa học bạn đã đăng ký.", diff --git a/src/features/classroom/components/list/ClassroomList.tsx b/src/features/classroom/components/list/ClassroomList.tsx index 25a659ee..1d7d9371 100644 --- a/src/features/classroom/components/list/ClassroomList.tsx +++ b/src/features/classroom/components/list/ClassroomList.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, { useState } from 'react' import { getStatusBadgeClass } from '@/utils/badgeColor' import Link from 'next/link' @@ -16,25 +15,25 @@ import SearchBar from '@/components/shared/search/SearchBar' import SSelect from '@/components/shared/SSelect' import { useLocale, useTranslations } from 'next-intl' -import { formatDate } from '@/utils/index' +import { formatDate, useStatusTranslation } from '@/utils/index' export default function ClassroomList() { - const tClassroom = useTranslations('classroom') const locale = useLocale() + const statusTranslations = useStatusTranslation() + const tClassroom = useTranslations('classroom.myLearning') + + const { selectedOrgUserId } = useAppSelector((state) => state.selectedOrganization) - const user = useAppSelector((state) => state.auth?.user) const queryParams = useAppSelector((state) => state.classroom) const [selectedStatus, setSelectedStatus] = useState('') - const classroomQueryParams = { - ...queryParams, - status: selectedStatus || undefined - } - - const { data, isLoading, error } = useSearchClassroomsQuery({ - ...queryParams, - studentId: user?.userId - }) + const { data, isLoading, error } = useSearchClassroomsQuery( + { + ...queryParams, + studentId: selectedOrgUserId + }, + { skip: !selectedOrgUserId } + ) const classrooms = data?.data.items || [] if (isLoading) { @@ -50,7 +49,7 @@ export default function ClassroomList() { if (error || !classrooms || classrooms.length === 0) { return (
- +
) } @@ -97,7 +96,7 @@ export default function ClassroomList() { {/* Status Badge */}
- {classroom.status} + {statusTranslations(classroom.status)}
@@ -108,7 +107,7 @@ export default function ClassroomList() {

{classroom.name}

- {classroom.grade} + {tClassroom('grade')} {classroom.grade}
diff --git a/src/features/group/components/detail/GroupColumn.tsx b/src/features/group/components/detail/GroupColumn.tsx index 5412daec..73257f06 100644 --- a/src/features/group/components/detail/GroupColumn.tsx +++ b/src/features/group/components/detail/GroupColumn.tsx @@ -1,5 +1,5 @@ import { createActionsColumnFromItems, createSelectColumn } from '@/components/shared/data-table/columns-helpers' -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' import { useModal } from '@/providers/ModalProvider' import { ColumnDef } from '@tanstack/react-table' import { toast } from 'sonner' @@ -11,6 +11,7 @@ import { formatDate, useOrgUserStatusTranslation } from '@/utils/index' export function useGetGroupColumn(): ColumnDef[] { const { openModal } = useModal() + const locale = useLocale() const [deleteGroup] = useDeleteGroupMutation() const tc = useTranslations('common') const tt = useTranslations('toast') @@ -78,7 +79,7 @@ export function useGetGroupColumn(): ColumnDef[] { { accessorKey: 'joinedAt', header: tc('tableHeader.joinedAt'), - cell: ({ row }) =>
{formatDate(row.original.joinedAt)}
+ cell: ({ row }) =>
{formatDate(row.original.joinedAt, { locale })}
}, createActionsColumnFromItems([ // { diff --git a/src/features/group/components/detail/OrganizationGroupTableDetail.tsx b/src/features/group/components/detail/OrganizationGroupTableDetail.tsx index cc94e791..1cd8e129 100644 --- a/src/features/group/components/detail/OrganizationGroupTableDetail.tsx +++ b/src/features/group/components/detail/OrganizationGroupTableDetail.tsx @@ -8,12 +8,13 @@ import BackButton from '@/components/shared/button/BackButton' import { useParams } from 'next/navigation' import { Card, CardContent, CardHeader, CardTitle } from '@/components/shadcn/card' import { Badge } from '@/components/shadcn/badge' -import { Users, Calendar, Hash, Activity, Copy } from 'lucide-react' +import { Users, Calendar, Hash, Copy } from 'lucide-react' import { formatDate, useOrgUserStatusTranslation } from '@/utils/index' import { toast } from 'sonner' export default function OrganizationGroupTable() { const { groupId } = useParams() + const locale = useLocale() const to = useTranslations('organization.group') const tc = useTranslations('common') const tt = useTranslations('toast') @@ -135,7 +136,7 @@ export default function OrganizationGroupTable() { -
{formatDate(groupData.createdAt)}
+
{formatDate(groupData.createdAt, { locale })}
diff --git a/src/features/resource/course/components/my-learning/MyLearningHero.tsx b/src/features/resource/course/components/my-learning/MyLearningHero.tsx index fa000c6e..1f40c144 100644 --- a/src/features/resource/course/components/my-learning/MyLearningHero.tsx +++ b/src/features/resource/course/components/my-learning/MyLearningHero.tsx @@ -14,7 +14,7 @@ type MyLearningHeroProps = { } export function MyLearningHero({ course, studentId }: MyLearningHeroProps) { - const t = useTranslations('MyLearning') + const t = useTranslations('myLearning') const auth = useAppSelector((state) => state.auth) const { data } = useSearchCourseEnrollmentQuery( diff --git a/src/features/resource/course/components/my-learning/MyLearningList.tsx b/src/features/resource/course/components/my-learning/MyLearningList.tsx index 93b6bf77..242a6467 100644 --- a/src/features/resource/course/components/my-learning/MyLearningList.tsx +++ b/src/features/resource/course/components/my-learning/MyLearningList.tsx @@ -1,4 +1,3 @@ -// app/my-learning/MyLearningList.tsx 'use client' import React, { useMemo } from 'react' @@ -13,19 +12,15 @@ import { SpecializationCard } from '@/features/certificate/components/list/Speci import { useSearchCurriculumEnrollmentQuery } from '@/features/enrollment/api/curriculumEnrollmentApi' import { CourseCard } from '@/features/certificate/components/list/CourseCard' import ClassroomList from '@/features/classroom/components/list/ClassroomList' -import { Separator } from '@/components/shadcn/separator' import { MyLearningSidebar } from './MyLearningSidebar' -import { Badge } from '@/components/shadcn/badge' type MyLearningListProps = { studentId?: string } export function MyLearningList({ studentId }: MyLearningListProps) { - const t = useTranslations('MyLearning') - - const courseEnrollParams = useAppSelector((state) => state.courseEnrollment) - const curriculumEnrollParams = useAppSelector((state) => state.curriculumEnrollment) + const t = useTranslations('myLearning') + const tClassroom = useTranslations('classroom.myLearning') const { data: courseEnrollment, isLoading: isLoadingCourseEnrollment } = useSearchCourseEnrollmentQuery( { studentId }, @@ -75,7 +70,7 @@ export function MyLearningList({ studentId }: MyLearningListProps) {
{/* Classroom Section */}
-

My Classrooms

+

{tClassroom('title')}

diff --git a/src/features/subscription/slice/selectedOrganizationSlice.ts b/src/features/subscription/slice/selectedOrganizationSlice.ts index dc424512..84afcd47 100644 --- a/src/features/subscription/slice/selectedOrganizationSlice.ts +++ b/src/features/subscription/slice/selectedOrganizationSlice.ts @@ -3,12 +3,14 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' interface SelectedOrganizationState { selectedOrganizationId: number | null + selectedOrgUserId?: string | null selectedSubscriptionOrderId?: number | null currentRole?: LicenseType | UserRole.ADMIN | UserRole.STAFF | UserRole.GUEST } const initialState: SelectedOrganizationState = { selectedOrganizationId: null, + selectedOrgUserId: null, selectedSubscriptionOrderId: null, currentRole: UserRole.GUEST } @@ -20,6 +22,9 @@ const selectedOrganizationSlice = createSlice({ setSelectedOrganizationId: (state, action: PayloadAction) => { state.selectedOrganizationId = action.payload }, + setSelectedOrgUserId: (state, action: PayloadAction) => { + state.selectedOrgUserId = action.payload + }, setSelectedSubscriptionOrderId: (state, action: PayloadAction) => { state.selectedSubscriptionOrderId = action.payload }, @@ -34,7 +39,12 @@ const selectedOrganizationSlice = createSlice({ } }) -export const { setSelectedOrganizationId, setSelectedSubscriptionOrderId, setCurrentRole, clearSelectedOrganization } = - selectedOrganizationSlice.actions +export const { + setSelectedOrganizationId, + setSelectedOrgUserId, + setSelectedSubscriptionOrderId, + setCurrentRole, + clearSelectedOrganization +} = selectedOrganizationSlice.actions export default selectedOrganizationSlice.reducer diff --git a/src/providers/AuthSessionSync.tsx b/src/providers/AuthSessionSync.tsx index 84ad81be..d9d61fa4 100644 --- a/src/providers/AuthSessionSync.tsx +++ b/src/providers/AuthSessionSync.tsx @@ -5,6 +5,7 @@ import { setToken, setUser } from '@/features/auth/authSlice' import { setCurrentRole, setSelectedOrganizationId, + setSelectedOrgUserId, setSelectedSubscriptionOrderId } from '@/features/subscription/slice/selectedOrganizationSlice' import { UserRole } from '@/types/userRole' @@ -67,6 +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(setCurrentRole(activeSub.type)) // Đây là LicenseType } } diff --git a/src/utils/index.ts b/src/utils/index.ts index a146511b..23755eb5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ -import { useLocale, useTranslations } from 'next-intl' +import { useTranslations } from 'next-intl' export const formatDuration = (minutes: number) => { if (typeof minutes !== 'number' || isNaN(minutes) || minutes <= 0) return '00:00' @@ -50,8 +50,7 @@ export interface FormatDateOptions { day?: 'numeric' | '2-digit' } export const formatDate = (dateString: string, options: FormatDateOptions = {}) => { - const locale = useLocale() - const { showTime = false, pattern, year = 'numeric', month = 'short', day = 'numeric' } = options + const { locale, showTime = false, pattern, year = 'numeric', month = 'short', day = 'numeric' } = options const date = new Date(dateString) From ece4b49667badd32ab8ce8fc47e0e79680cf0f47 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 7 Dec 2025 14:35:47 +0700 Subject: [PATCH 06/15] feat: update translations for courses and add new buttons in common components --- messages/en/classroom/en_classroom.json | 2 +- messages/en/common/en_common.json | 4 +- messages/vi/classroom/vi_classroom.json | 4 +- messages/vi/common/vi_common.json | 4 +- .../[locale]/classroom/[classroomId]/page.tsx | 4 +- .../detail/StudentClassroomDetails.tsx | 80 +++++-------------- .../components/ui/ClassroomSubHeader.tsx | 1 - 7 files changed, 33 insertions(+), 66 deletions(-) diff --git a/messages/en/classroom/en_classroom.json b/messages/en/classroom/en_classroom.json index f1dc8212..27ccd46c 100644 --- a/messages/en/classroom/en_classroom.json +++ b/messages/en/classroom/en_classroom.json @@ -48,7 +48,7 @@ "noStudent": "No students enrolled yet.", "noStudentSubtext": "Start building your class by adding students" }, - "courses": "Courses", + "course": "Course", "curriculum": { "label": "Curriculum" }, diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 5501133c..901bd02f 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -110,7 +110,9 @@ "addCriterion": "Add Criterion", "createGroup": "Create Group", "cancelSubscription": "Cancel Subscription", - "removeFromGroup": "Remove from Group" + "removeFromGroup": "Remove from Group", + "backToClassroomList": "Back to Classroom List", + "continueLearning": "Continue Learning" }, "message": { "courseCreateSuccess": "Course created successfully!", diff --git a/messages/vi/classroom/vi_classroom.json b/messages/vi/classroom/vi_classroom.json index a84d0a9c..3a6ba6b4 100644 --- a/messages/vi/classroom/vi_classroom.json +++ b/messages/vi/classroom/vi_classroom.json @@ -47,7 +47,7 @@ "noStudent": "Chưa có học sinh nào.", "noStudentSubtext": "Bắt đầu xây dựng lớp học của bạn bằng cách thêm học sinh" }, - "courses": "Khóa học", + "course": "Khóa học", "curriculum": { "label": "Chương trình học" }, @@ -58,7 +58,7 @@ }, "teacher": "Giáo viên", "meet": { - "label": "Họp", + "label": "Cuộc Họp", "joinButton": "Tham gia cuộc họp" }, "quickStats": { diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index fc39140d..3b0240c5 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -109,7 +109,9 @@ "addCriterion": "Thêm Tiêu Chí", "createGroup": "Tạo Nhóm", "cancelSubscription": "Hủy Đăng Ký", - "removeFromGroup": "Xóa khỏi Nhóm" + "removeFromGroup": "Xóa khỏi Nhóm", + "backToClassroomList": "Quay lại Danh sách Lớp học", + "continueLearning": "Tiếp tục Học" }, "message": { "courseCreateSuccess": "Khóa học được tạo thành công!", diff --git a/src/app/[locale]/classroom/[classroomId]/page.tsx b/src/app/[locale]/classroom/[classroomId]/page.tsx index 6f7dd763..14a46000 100644 --- a/src/app/[locale]/classroom/[classroomId]/page.tsx +++ b/src/app/[locale]/classroom/[classroomId]/page.tsx @@ -50,7 +50,7 @@ export default function ClassroomDetailPage() { {currentTab === 'overview' && currentRole === LicenseType.TEACHER ? : null} {currentTab === 'overview' && currentRole === LicenseType.STUDENT ? ( - + ) : null} {currentTab === 'course' ? (
@@ -69,7 +69,7 @@ export default function ClassroomDetailPage() { ) : null} {currentTab === 'student' ? (
- +
) : null}
diff --git a/src/features/classroom/components/detail/StudentClassroomDetails.tsx b/src/features/classroom/components/detail/StudentClassroomDetails.tsx index f4ce58ef..cadffa88 100644 --- a/src/features/classroom/components/detail/StudentClassroomDetails.tsx +++ b/src/features/classroom/components/detail/StudentClassroomDetails.tsx @@ -6,36 +6,15 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/shadcn/ca import { Badge } from '@/components/shadcn/badge' import { Button } from '@/components/shadcn/button' import { Avatar, AvatarFallback, AvatarImage } from '@/components/shadcn/avatar' -import { Separator } from '@/components/shadcn/separator' -import { - Calendar, - Users, - BookOpen, - Copy, - Settings, - UserPlus, - MoreVertical, - ArrowLeft, - Clock, - GraduationCap, - Mail, - Edit -} from 'lucide-react' -import { format } from 'date-fns' -import { ClassroomStatus } from '@/features/classroom/types/classroom.type' +import { Users, BookOpen, Copy, MoreVertical, Mail, Camera, Video } from 'lucide-react' import Link from 'next/link' import Image from 'next/image' -import { getStatusBadgeClass } from '@/utils/badgeColor' import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' -import { LicenseType, UserRole } from '@/types/userRole' -import { - useCreateCurriculumEnrollmentMutation, - useSearchCurriculumEnrollmentQuery -} from '@/features/enrollment/api/curriculumEnrollmentApi' + import { useRouter } from 'next/navigation' import { useLocale, useTranslations } from 'next-intl' import { signIn } from 'next-auth/react' -import { CourseEnrollment, CurriculumEnrollment, EnrollmentStatus } from '@/features/enrollment/types/enrollment.type' +import { CourseEnrollment, EnrollmentStatus } from '@/features/enrollment/types/enrollment.type' import { toast } from 'sonner' import { ClassroomNavItems } from 'app/[locale]/classroom/[classroomId]/page' import { setCourseEnrollmentId } from '@/features/enrollment/slice/enrollmentSlice' @@ -43,13 +22,13 @@ import { useCreateCourseEnrollmentMutation } from '@/features/enrollment/api/cou export type StudentClassroomDetailProps = { courseEnrollment?: CourseEnrollment - setCurrentTab: (tab: ClassroomNavItems) => void } -export default function StudentClassroomDetail({ courseEnrollment, setCurrentTab }: StudentClassroomDetailProps) { +export default function StudentClassroomDetail({ courseEnrollment }: StudentClassroomDetailProps) { const tc = useTranslations('common') const tt = useTranslations('toast') + const tClassroom = useTranslations('classroom') const { classroomId } = useParams() - const auth = useAppSelector((state) => state.auth) + const { selectedOrgUserId } = useAppSelector((state) => state.selectedOrganization) const router = useRouter() const locale = useLocale() const dispatch = useAppDispatch() @@ -62,18 +41,18 @@ export default function StudentClassroomDetail({ courseEnrollment, setCurrentTab const copyClassCode = () => { if (classroom?.classCode) { navigator.clipboard.writeText(classroom.classCode) - // You can add a toast notification here + toast.success(tt('successMessage.copiedToClipboard')) } } const handleEnroll = () => { - if (!auth.user?.userId) { + if (!selectedOrgUserId) { signIn('oidc', { callbackUrl: `/`, prompt: 'login' }) return } if (classroom?.course.id) { createEnrollment({ courseId: classroom?.course.id, - studentId: auth?.user?.userId, + studentId: selectedOrgUserId, status: EnrollmentStatus.IN_PROGRESS, classroomId: Number(classroomId) }) @@ -105,10 +84,10 @@ export default function StudentClassroomDetail({ courseEnrollment, setCurrentTab return (
-

Classroom not found

-

The classroom you're looking for doesn't exist.

+

{tClassroom('detail.notFound')}

+

{tClassroom('detail.notFoundSubtext')}

- +
@@ -128,7 +107,7 @@ export default function StudentClassroomDetail({ courseEnrollment, setCurrentTab - Course + {tClassroom('detail.course')} @@ -159,7 +138,7 @@ export default function StudentClassroomDetail({ courseEnrollment, setCurrentTab router.push(`/resource/course/${classroom.course.id}/learn`) }} > - Continue Learning + {tc('button.continueLearning')} ) : (
-

Share this code with students to join the class

+

{tClassroom('detail.classCode.description')}

@@ -242,7 +221,7 @@ export default function StudentClassroomDetail({ courseEnrollment, setCurrentTab {classroom.teacher && ( - Teacher + {tClassroom('detail.teacher')}
@@ -275,30 +254,15 @@ export default function StudentClassroomDetail({ courseEnrollment, setCurrentTab
- - - +
- Meet + {tClassroom('detail.meet.label')}
-
- -
- - - - Visible to students -
diff --git a/src/features/classroom/components/ui/ClassroomSubHeader.tsx b/src/features/classroom/components/ui/ClassroomSubHeader.tsx index 38e77e50..d92ebbf4 100644 --- a/src/features/classroom/components/ui/ClassroomSubHeader.tsx +++ b/src/features/classroom/components/ui/ClassroomSubHeader.tsx @@ -22,7 +22,6 @@ interface Props { export default function ClassroomSubHeader({ classroom, curriculumId, currentTab, setCurrentTab }: Props) { const t = useTranslations('Header') - const pathname = usePathname() const subNavItems: { name: string; currentTab: ClassroomNavItems }[] = [ { name: 'overview', currentTab: 'overview' }, From 06f1a9e995d184586e6085c621657c0e7d14ec9d Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 7 Dec 2025 18:03:29 +0700 Subject: [PATCH 07/15] feat: add "Mark as Complete" button text and update translations for improved clarity --- messages/en/common/en_common.json | 3 +- messages/vi/common/vi_common.json | 3 +- messages/vi/common/vi_toast.json | 2 +- .../[locale]/classroom/[classroomId]/page.tsx | 6 +-- .../course/components/detail/CourseDetail.tsx | 47 ------------------- .../components/detail/LessonContent.tsx | 5 +- .../lesson/components/detail/LessonDetail.tsx | 6 +-- .../quiz/components/viewer/QuizViewer.tsx | 2 +- 8 files changed, 15 insertions(+), 59 deletions(-) delete mode 100644 src/features/resource/course/components/detail/CourseDetail.tsx diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 901bd02f..0a1ce646 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -112,7 +112,8 @@ "cancelSubscription": "Cancel Subscription", "removeFromGroup": "Remove from Group", "backToClassroomList": "Back to Classroom List", - "continueLearning": "Continue Learning" + "continueLearning": "Continue Learning", + "markAsComplete": "Mark as Complete" }, "message": { "courseCreateSuccess": "Course created successfully!", diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index 3b0240c5..5189e48d 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -111,7 +111,8 @@ "cancelSubscription": "Hủy Đăng Ký", "removeFromGroup": "Xóa khỏi Nhóm", "backToClassroomList": "Quay lại Danh sách Lớp học", - "continueLearning": "Tiếp tục Học" + "continueLearning": "Tiếp tục Học", + "markAsComplete": "Đã Hoàn thành" }, "message": { "courseCreateSuccess": "Khóa học được tạo thành công!", diff --git a/messages/vi/common/vi_toast.json b/messages/vi/common/vi_toast.json index e36ebc6c..e31d816c 100644 --- a/messages/vi/common/vi_toast.json +++ b/messages/vi/common/vi_toast.json @@ -15,7 +15,7 @@ "removeCourseFromCurriculum": "Đã xóa khóa học khỏi chương trình học thành công!", "lessonStart": "Bài học đã bắt đầu!", "lessonStatus": "Trạng thái bài học đã được cập nhật thành {status}!", - "sectionComplete": "Phần đã hoàn thành!", + "sectionComplete": "Đã hoàn thành!", "addComponentToKit": "Đã thêm thành phần vào bộ dụng cụ thành công!", "removeComponent": "Đã xóa thành phần khỏi bộ dụng cụ thành công!", "updateComponentInKit": "Đã cập nhật thành phần bộ dụng cụ thành công!", diff --git a/src/app/[locale]/classroom/[classroomId]/page.tsx b/src/app/[locale]/classroom/[classroomId]/page.tsx index 14a46000..d1f224fe 100644 --- a/src/app/[locale]/classroom/[classroomId]/page.tsx +++ b/src/app/[locale]/classroom/[classroomId]/page.tsx @@ -19,7 +19,7 @@ export type ClassroomNavItems = 'overview' | 'course' | 'quiz' | 'assignment' | export default function ClassroomDetailPage() { const { classroomId } = useParams() - const auth = useAppSelector((state) => state.auth) + const { selectedOrgUserId } = useAppSelector((state) => state.selectedOrganization) const currentRole = useAppSelector((state) => state.selectedOrganization.currentRole) const [currentTab, setCurrentTab] = React.useState('overview') @@ -27,12 +27,12 @@ export default function ClassroomDetailPage() { const { data: courseEnrollment } = useSearchCourseEnrollmentQuery( { courseId: classroomData?.data.course.id, - studentId: auth?.user?.userId || '', + studentId: selectedOrgUserId!, classroomId: Number(classroomId), pageNumber: 1, pageSize: 20 }, - { skip: !auth.user?.userId || !classroomData?.data.course.id || currentRole !== LicenseType.STUDENT } + { skip: !selectedOrgUserId || !classroomData?.data.course.id || currentRole !== LicenseType.STUDENT } ) if (isLoading) { return ( diff --git a/src/features/resource/course/components/detail/CourseDetail.tsx b/src/features/resource/course/components/detail/CourseDetail.tsx deleted file mode 100644 index 85a88ea7..00000000 --- a/src/features/resource/course/components/detail/CourseDetail.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client' - -import LoadingComponent from '@/components/shared/loading/LoadingComponent' -import { useSearchCourseEnrollmentQuery } from '@/features/enrollment/api/courseEnrollmentApi' -import CourseDetailEnrolled from '@/features/resource/course/components/detail/enrolled/CourseDetailEnrolled' -import CourseDetailNotEnrolled from '@/features/resource/course/components/detail/not-enrolled/CourseDetailNotEnrolled' -import { useAppSelector } from '@/hooks/redux-hooks' -import { LicenseType, UserRole } from '@/types/userRole' -import { useParams } from 'next/navigation' - -export default function CourseDetail() { - const param = useParams() - const courseIdParam = param?.courseId - const courseId = courseIdParam ? Number(courseIdParam) : undefined - - const userRole = useAppSelector((state) => state.selectedOrganization.currentRole) - const studentId = useAppSelector((state) => state.auth.user?.userId) - - const { data, isLoading, error } = useSearchCourseEnrollmentQuery( - { pageNumber: 1, pageSize: 10, courseId, studentId }, - { skip: !studentId } - ) - - if (isLoading) { - return ( -
- -
- ) - } - if (error) { - return

Error: {(error as any)?.message ?? 'Unknown error'}

- } - - const enrollmentItems = data?.data?.items ?? [] - const firstEnrollment = enrollmentItems[0] - - if (firstEnrollment) { - return - } - - if (userRole === LicenseType.TEACHER) { - return - } - - return -} diff --git a/src/features/resource/lesson/components/detail/LessonContent.tsx b/src/features/resource/lesson/components/detail/LessonContent.tsx index 87f01464..cc090803 100644 --- a/src/features/resource/lesson/components/detail/LessonContent.tsx +++ b/src/features/resource/lesson/components/detail/LessonContent.tsx @@ -35,6 +35,7 @@ export default function LessonContent({ token, lessonId, sectionStatus, enrollme const dispatch = useAppDispatch() const t = useTranslations('LessonDetails') + const tc = useTranslations('common') const tt = useTranslations('toast') // const { data: userData, status } = useSession() @@ -96,7 +97,7 @@ export default function LessonContent({ token, lessonId, sectionStatus, enrollme
{/* Content */}
- +
@@ -105,7 +106,7 @@ export default function LessonContent({ token, lessonId, sectionStatus, enrollme {isLoggedIn && currentSectionProgress?.status === ProgressStatus.IN_PROGRESS && (
)} diff --git a/src/features/resource/lesson/components/detail/LessonDetail.tsx b/src/features/resource/lesson/components/detail/LessonDetail.tsx index 19cb9817..b028071f 100644 --- a/src/features/resource/lesson/components/detail/LessonDetail.tsx +++ b/src/features/resource/lesson/components/detail/LessonDetail.tsx @@ -27,7 +27,7 @@ export default function LessonDetail() { // redux const dispatch = useAppDispatch() - const userId = useAppSelector((state) => state.auth.user?.userId) + const { selectedOrgUserId } = useAppSelector((state) => state.selectedOrganization) const { selectedSectionId, mode } = useAppSelector((state) => state.lessonDetail) const token = useAppSelector((state) => state.auth.token) const shouldRefetch = useAppSelector((state) => state.studentProgress.shouldRefetchSectionProgress) @@ -43,9 +43,9 @@ export default function LessonDetail() { const sectionData = sections?.data?.items ?? [] const { data: enrollment } = useSearchCourseEnrollmentQuery( - { studentId: userId, courseId, pageNumber: 1, pageSize: 10 }, + { studentId: selectedOrgUserId!, courseId, pageNumber: 1, pageSize: 10 }, { - skip: !userId || !courseId + skip: !selectedOrgUserId || !courseId } ) diff --git a/src/features/resource/quiz/components/viewer/QuizViewer.tsx b/src/features/resource/quiz/components/viewer/QuizViewer.tsx index 5a6c71d3..9d2a96c6 100644 --- a/src/features/resource/quiz/components/viewer/QuizViewer.tsx +++ b/src/features/resource/quiz/components/viewer/QuizViewer.tsx @@ -112,7 +112,7 @@ export default function QuizViewer({ quiz, isShowQuestionAnswer, studentQuizId }

{tq('timeLimit')}

- {quiz.timeLimitInMinutes} {tq('mins')} + {quiz.timeLimitInMinutes ? `${quiz.timeLimitInMinutes} ${tq('mins')}` : '-'}

From fb1c6bba83e531f1bc665a56ee04b736cbf1661a Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 7 Dec 2025 20:12:51 +0700 Subject: [PATCH 08/15] feat: add "Start Quiz" button text and update related components for improved user experience --- messages/en/common/en_common.json | 3 +- messages/vi/common/vi_common.json | 3 +- .../attempt/AssignmentSubmission.tsx | 15 ++-------- .../components/detail/LessonContent.tsx | 10 +++++-- .../components/player/QuizPlayerContainer.tsx | 1 - .../question/types/MultipleChoiceQuestion.tsx | 2 +- .../question/types/SingleChoiceQuestion.tsx | 28 ++++++------------- .../quiz/components/viewer/QuizViewer.tsx | 26 ++++++++++------- .../slice/studentProgressSlice.ts | 10 +++++-- 9 files changed, 48 insertions(+), 50 deletions(-) diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 0a1ce646..1fef69ae 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -113,7 +113,8 @@ "removeFromGroup": "Remove from Group", "backToClassroomList": "Back to Classroom List", "continueLearning": "Continue Learning", - "markAsComplete": "Mark as Complete" + "markAsComplete": "Mark as Complete", + "startQuiz": "Start Quiz" }, "message": { "courseCreateSuccess": "Course created successfully!", diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index 5189e48d..7e25b6c3 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -112,7 +112,8 @@ "removeFromGroup": "Xóa khỏi Nhóm", "backToClassroomList": "Quay lại Danh sách Lớp học", "continueLearning": "Tiếp tục Học", - "markAsComplete": "Đã Hoàn thành" + "markAsComplete": "Đánh dấu hoàn thành", + "startQuiz": "Bắt Đầu Bài Kiểm Tra" }, "message": { "courseCreateSuccess": "Khóa học được tạo thành công!", diff --git a/src/features/assignment/components/attempt/AssignmentSubmission.tsx b/src/features/assignment/components/attempt/AssignmentSubmission.tsx index 865813f0..6f7aff77 100644 --- a/src/features/assignment/components/attempt/AssignmentSubmission.tsx +++ b/src/features/assignment/components/attempt/AssignmentSubmission.tsx @@ -14,7 +14,7 @@ import { Textarea } from '@/components/shadcn/textarea' import { useAppSelector } from '@/hooks/redux-hooks' import BackButton from '@/components/shared/button/BackButton' import SEmpty from '@/components/shared/empty/SEmpty' -import { formatDate, formatDateV2 } from '@/utils/index' +import { fileToBase64, formatDate, formatDateV2 } from '@/utils/index' import { useLocale, useTranslations } from 'next-intl' const FileInput = ({ file, onFileChange }: { file: File | null; onFileChange: (file: File | null) => void }) => { @@ -94,15 +94,6 @@ const FileInput = ({ file, onFileChange }: { file: File | null; onFileChange: (f ) } -const fileToBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.readAsDataURL(file) - reader.onload = () => resolve(reader.result as string) - reader.onerror = (error) => reject(error) - }) -} - export default function AssignmentSubmissionForm() { const t = useTranslations('assignment') const tc = useTranslations('common') @@ -230,7 +221,7 @@ export default function AssignmentSubmissionForm() { {/* Submission Form */} {true && (
-
+ {/*
@@ -241,7 +232,7 @@ export default function AssignmentSubmissionForm() { placeholder={t('student.doAsm.projectTitle')} className='max-w-2xl' /> -
+
*/} {questions.map((question) => (
diff --git a/src/features/resource/lesson/components/detail/LessonContent.tsx b/src/features/resource/lesson/components/detail/LessonContent.tsx index cc090803..acf0542e 100644 --- a/src/features/resource/lesson/components/detail/LessonContent.tsx +++ b/src/features/resource/lesson/components/detail/LessonContent.tsx @@ -83,7 +83,13 @@ export default function LessonContent({ token, lessonId, sectionStatus, enrollme const lastItem = content.data.items[content.data.items.length - 1] if (lastItem.contentType === ContentType.QUIZ) { - return + return ( + + ) } else if (lastItem.contentType === ContentType.ASSIGNMENT) { return ( -
diff --git a/src/features/resource/quiz/components/player/QuizPlayerContainer.tsx b/src/features/resource/quiz/components/player/QuizPlayerContainer.tsx index f093410a..2bed73db 100644 --- a/src/features/resource/quiz/components/player/QuizPlayerContainer.tsx +++ b/src/features/resource/quiz/components/player/QuizPlayerContainer.tsx @@ -1,7 +1,6 @@ 'use client' import { useEffect } from 'react' -import { useIsMobile } from '@/hooks/use-mobile' import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' import QuizSidebar from '@/features/resource/quiz/components/player/QuizSidebar' import QuizMainContent from '@/features/resource/quiz/components/player/QuizMainContent' diff --git a/src/features/resource/quiz/components/player/question/types/MultipleChoiceQuestion.tsx b/src/features/resource/quiz/components/player/question/types/MultipleChoiceQuestion.tsx index 02244722..ea8ca7fa 100644 --- a/src/features/resource/quiz/components/player/question/types/MultipleChoiceQuestion.tsx +++ b/src/features/resource/quiz/components/player/question/types/MultipleChoiceQuestion.tsx @@ -69,7 +69,7 @@ export default function MultipleChoiceQuestion({ question }: MultipleChoiceQuest > {/* Checkbox */} {/* Answer Label */} {String.fromCharCode(65 + index)} @@ -97,13 +92,6 @@ export default function SingleChoiceQuestion({ question }: SingleChoiceQuestionP )} - - {/* Checkmark indicator for selected */} - {!isSubmitted && isChosen && ( -
- -
- )} ) })} diff --git a/src/features/resource/quiz/components/viewer/QuizViewer.tsx b/src/features/resource/quiz/components/viewer/QuizViewer.tsx index 9d2a96c6..2c634e94 100644 --- a/src/features/resource/quiz/components/viewer/QuizViewer.tsx +++ b/src/features/resource/quiz/components/viewer/QuizViewer.tsx @@ -16,16 +16,25 @@ import QuizAttempt from '@/features/resource/quiz/components/viewer/QuizAttempt' import { Attempt } from '@/features/resource/quiz/types/quiz.type' import { LicenseType, UserRole } from '@/types/userRole' import { useTranslations } from 'next-intl' +import { PaginatedResult } from '@/types/baseModel' +import { StudentProgress } from '@/features/student-progress/types/studentProgress.type' type QuizViewerProps = { quiz: QuizContent isShowQuestionAnswer?: boolean studentQuizId?: number + sectionStatus?: PaginatedResult } -export default function QuizViewer({ quiz, isShowQuestionAnswer, studentQuizId }: QuizViewerProps) { +export default function QuizViewer({ quiz, isShowQuestionAnswer, studentQuizId, sectionStatus }: QuizViewerProps) { const tq = useTranslations('quiz.detail') + const tc = useTranslations('common') const dispatch = useAppDispatch() + 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) const role = useAppSelector((state) => state.selectedOrganization.currentRole) @@ -77,6 +86,8 @@ export default function QuizViewer({ quiz, isShowQuestionAnswer, studentQuizId } } } + const canStartQuiz = !isShowQuestionAnswer && quizStatus !== 'Completed' && quizStatus !== 'Locked' + return (
{/* Quiz Header */} @@ -255,18 +266,13 @@ export default function QuizViewer({ quiz, isShowQuestionAnswer, studentQuizId } })}
- ) : ( + ) : !canStartQuiz ? (
- {/* loading button when creating quiz attempt */} -
- )} + ) : null} {studentQuizId && ( ) => { + state.isSectionDone = action.payload } } }) @@ -55,6 +60,7 @@ export const { setSelectedSectionId, setSelectedSectionStatus, triggerRefetchSectionProgress, - clearRefetchSectionProgress + clearRefetchSectionProgress, + setSectionDone } = studentProgressSlice.actions export const studentProgressReducer = studentProgressSlice.reducer From 71ac47883ec8e07774aa7a8b3027708b0b1561a1 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 8 Dec 2025 01:23:27 +0700 Subject: [PATCH 09/15] feat: update Vietnamese translations and improve button texts for better clarity --- messages/vi/common/vi_common.json | 2 +- next.config.ts | 1 + src/components/shared/card/CardHorizontal.tsx | 2 +- .../creator-3d/components/creator3d/Creator3D.tsx | 9 ++++----- .../components/right-sidebar/WorkspaceTree.tsx | 8 ++++---- src/features/emulator/api/emulatorApi.ts | 7 ++++--- src/features/resource/question/components/QuizEditor.tsx | 4 ++-- .../resource/question/components/QuizEditorSidebar.tsx | 5 +++-- 8 files changed, 20 insertions(+), 18 deletions(-) diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index 7e25b6c3..088d14e6 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -66,7 +66,7 @@ "exploreRobotAi": "Khám Phá Robot AI", "readBlogs": "Xem Blog", "addKit": "Thêm Bộ Kit", - "publish": "Xuất Bản", + "publish": "Công khai", "print": "In", "share": "Chia sẻ", "start": "Bắt Đầu", diff --git a/next.config.ts b/next.config.ts index 0965534b..8209fdb0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -9,6 +9,7 @@ const nextConfig: NextConfig = { { protocol: 'https', hostname: 'github.com' }, { protocol: 'https', hostname: 'encrypted-tbn0.gstatic.com' }, { protocol: 'https', hostname: 'classroom.strawbees.com' }, + { protocol: 'https', hostname: 'strawbees.com' }, { protocol: 'http', hostname: 'res.cloudinary.com' }, { protocol: 'https', hostname: 'res.cloudinary.com' } ], diff --git a/src/components/shared/card/CardHorizontal.tsx b/src/components/shared/card/CardHorizontal.tsx index b975a2c2..e73fc905 100644 --- a/src/components/shared/card/CardHorizontal.tsx +++ b/src/components/shared/card/CardHorizontal.tsx @@ -46,7 +46,7 @@ export default function CardHorizontal({ {/* Description */} - {description &&

{description}

} + {description &&

{description}

} {/* CTA button */} {/* From 8f80847c7c1feefd999ce0e7a2651b61ba58e910 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 8 Dec 2025 01:26:21 +0700 Subject: [PATCH 10/15] feat: add delete emulator functionality and update related components for improved user experience --- src/features/emulator/api/emulatorApi.ts | 3 +- .../workspace-3d/Workspace3dLibrary.tsx | 28 ++++++++++--------- .../components/list/ComponentList.tsx | 5 +--- .../components/list/SelectComponentList.tsx | 4 ++- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/features/emulator/api/emulatorApi.ts b/src/features/emulator/api/emulatorApi.ts index 714350c3..185c5ec5 100644 --- a/src/features/emulator/api/emulatorApi.ts +++ b/src/features/emulator/api/emulatorApi.ts @@ -74,5 +74,6 @@ export const { useGetEmulatorByIdQuery, useSearchEmulationsQuery, useCreateEmulatorMutation, - useUpdateEmulatorMutation + useUpdateEmulatorMutation, + useDeleteEmulatorMutation } = emulatorApi diff --git a/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx b/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx index de714ed7..f0f8afc8 100644 --- a/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx +++ b/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx @@ -11,7 +11,11 @@ import { Button } from '@/components/shadcn/button' import { Card, CardContent } from '@/components/shadcn/card' import SEmpty from '@/components/shared/empty/SEmpty' -import { useSearchEmulationsQuery, useUpdateEmulatorMutation } from '@/features/emulator/api/emulatorApi' +import { + useDeleteEmulatorMutation, + useSearchEmulationsQuery, + useUpdateEmulatorMutation +} from '@/features/emulator/api/emulatorApi' import BackButton from '@/components/shared/button/BackButton' import { Popover, PopoverContent, PopoverTrigger } from '@/components/shadcn/popover' import { EmulatorStatus, EmulatorWithThumbnail } from '@/features/emulator/types/emulator.type' @@ -32,6 +36,7 @@ export default function Workspace3dLibrary() { const { data, isLoading } = useSearchEmulationsQuery({ page: 1 }) const [updateEmulation] = useUpdateEmulatorMutation() + const [deleteEmulation] = useDeleteEmulatorMutation() const emulations = data?.data.items || [] @@ -47,22 +52,19 @@ export default function Workspace3dLibrary() { } }).unwrap() - toast.success('Đã publish mô hình!') + toast.success('Mô hình đã được công khai!') } catch (error) { - toast.error('❌ Publish thất bại') + toast.error('Thất bại khi công khai mô hình.') console.error(error) } } - const handleArchiveEmulation = async (emulator: EmulatorWithThumbnail) => { + const handleDeleteEmulation = async (emulator: EmulatorWithThumbnail) => { openModal('confirm', { - message: tt('confirmMessage.archive', { title: emulator.name }), + message: tt('confirmMessage.delete', { title: emulator.name }), onConfirm: async () => { - await updateEmulation({ - emulationId: emulator.emulationId, - body: { - status: EmulatorStatus.ARCHIVED - } + await deleteEmulation({ + emulationId: emulator.emulationId }).unwrap() toast.success('Đã xóa mô hình!') @@ -154,10 +156,10 @@ export default function Workspace3dLibrary() { )}
diff --git a/src/features/kit-components/components/list/ComponentList.tsx b/src/features/kit-components/components/list/ComponentList.tsx index ae7a5bd3..89489349 100644 --- a/src/features/kit-components/components/list/ComponentList.tsx +++ b/src/features/kit-components/components/list/ComponentList.tsx @@ -1,11 +1,9 @@ 'use client' import { Button } from '@/components/shadcn/button' -import { Card, CardContent } from '@/components/shadcn/card' import { Input } from '@/components/shadcn/input' import { DataTable } from '@/components/shared/data-table/data-table' import SEmpty from '@/components/shared/empty/SEmpty' import LoadingComponent from '@/components/shared/loading/LoadingComponent' -import { SPagination } from '@/components/shared/SPagination' import { useDeleteComponentMutation, useSearchComponentQuery } from '@/features/kit-components/api/kitComponentApi' import { useGetComponentColumn } from '@/features/kit-components/components/list/ComponentColumn' import { setPageIndex, setSearchTerm } from '@/features/kit-components/slice/componentSlice' @@ -14,7 +12,6 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' import { useModal } from '@/providers/ModalProvider' import { Plus, Search } from 'lucide-react' import { useTranslations } from 'next-intl' -import Image from 'next/image' import React from 'react' export default function ComponentList() { @@ -72,7 +69,7 @@ export default function ComponentList() { data={rows} columns={columns} enableRowSelection - pagingData={componentData?.data} + pagingData={componentData} pagingParams={queryParams} handlePageChange={handlePageChange} /> diff --git a/src/features/kit-components/components/list/SelectComponentList.tsx b/src/features/kit-components/components/list/SelectComponentList.tsx index dced0b08..b26e1532 100644 --- a/src/features/kit-components/components/list/SelectComponentList.tsx +++ b/src/features/kit-components/components/list/SelectComponentList.tsx @@ -14,7 +14,6 @@ import { setPageIndex, setPageSize, setSearchTerm } from '@/features/kit-compone import { ComponentSliceParams, KitComponent } from '@/features/kit-components/types/kit-component.type' import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' import { useModal } from '@/providers/ModalProvider' -import Loading from 'app/[locale]/loading' import { useLocale, useTranslations } from 'next-intl' import Link from 'next/link' import React, { useEffect, useState } from 'react' @@ -212,6 +211,9 @@ export default function SelectComponentList({ id: c.componentId // map lại để dùng chung column logic }))} columns={extendedColumns} + pagingData={data} + pagingParams={queryParams} + handlePageChange={handlePageChange} enableRowSelection={false} /> From 8145db1cf71207b11b50d799d7edd0ce65554a8e Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 8 Dec 2025 01:30:30 +0700 Subject: [PATCH 11/15] feat: enhance delete emulation functionality and reset component list parameters for improved state management --- .../emulator/components/workspace-3d/Workspace3dLibrary.tsx | 3 ++- .../kit-components/components/list/SelectComponentList.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx b/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx index f0f8afc8..43cd39ab 100644 --- a/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx +++ b/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx @@ -64,7 +64,8 @@ export default function Workspace3dLibrary() { message: tt('confirmMessage.delete', { title: emulator.name }), onConfirm: async () => { await deleteEmulation({ - emulationId: emulator.emulationId + emulationId: emulator.emulationId, + permanent: true }).unwrap() toast.success('Đã xóa mô hình!') diff --git a/src/features/kit-components/components/list/SelectComponentList.tsx b/src/features/kit-components/components/list/SelectComponentList.tsx index b26e1532..61a7adf7 100644 --- a/src/features/kit-components/components/list/SelectComponentList.tsx +++ b/src/features/kit-components/components/list/SelectComponentList.tsx @@ -10,7 +10,7 @@ import { useUpdateKitComponentsMutation } from '@/features/kit-components/api/kitComponentApi' import { useGetComponentColumn } from '@/features/kit-components/components/list/ComponentColumn' -import { setPageIndex, setPageSize, setSearchTerm } from '@/features/kit-components/slice/componentSlice' +import { resetParams, setPageIndex, setPageSize, setSearchTerm } from '@/features/kit-components/slice/componentSlice' import { ComponentSliceParams, KitComponent } from '@/features/kit-components/types/kit-component.type' import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' import { useModal } from '@/providers/ModalProvider' @@ -59,7 +59,7 @@ export default function SelectComponentList({ } useEffect(() => { - dispatch(setPageSize(6)) + dispatch(resetParams()) }, [dispatch]) const { data, isLoading } = useSearchComponentQuery(queryParams) From 60d3ff217e8d7e854f8c2bed39dff9b8e11234fd Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 8 Dec 2025 01:38:10 +0700 Subject: [PATCH 12/15] feat: update layout spacing and remove unnecessary header in StrawLabList component --- src/features/emulator/components/straw-lab/StrawLabList.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/emulator/components/straw-lab/StrawLabList.tsx b/src/features/emulator/components/straw-lab/StrawLabList.tsx index 04b7948f..211bd9e2 100644 --- a/src/features/emulator/components/straw-lab/StrawLabList.tsx +++ b/src/features/emulator/components/straw-lab/StrawLabList.tsx @@ -60,10 +60,9 @@ export default function StrawLabList() { } return ( -
+
-

Danh sách mô hình

From eabd71323eaea9110b4a0da98d7147e0a3473492 Mon Sep 17 00:00:00 2001 From: LeThanhNhan91 Date: Mon, 8 Dec 2025 11:53:31 +0700 Subject: [PATCH 13/15] feat: multi-language in Model Maker --- messages/en/agent/en_agent.json | 17 ++++++++++- messages/vi/agent/vi_agent.json | 17 ++++++++++- messages/vi/common/vi_common.json | 1 + src/features/AI-model/UseTeachableMachine.ts | 28 ++++++++++--------- .../blockly-self-build/components/MicroAI.tsx | 6 ++-- 5 files changed, 51 insertions(+), 18 deletions(-) diff --git a/messages/en/agent/en_agent.json b/messages/en/agent/en_agent.json index 32ec5293..734b5837 100644 --- a/messages/en/agent/en_agent.json +++ b/messages/en/agent/en_agent.json @@ -30,7 +30,22 @@ "result": "RESULT", "uploading": "Đang tải model…", "reset": "Reset the AI recognition", - "video": "Video is zoomed in for better viewing." + "video": "Video is zoomed in for better viewing.", + "status": { + "minImagesPerClass": "At least {minImagesPerClass} images per class are required to train a good model!\nCurrent: {totalImages} images\nNeeded: {missingImage} images", + "notBalance": "Data is unbalanced! Each class needs at least {minImage} images.", + "dataPreparation": "Preparing data...", + "createModel": "Creating model...", + "trainModel": "Training model...", + "trainSuccess": "Model has been trained successfully!", + "trainError": "Error during model training: {error}", + "readyDownload": "Preparing model download...", + "downloadSuccess": "Model has been downloaded successfully! Includes: model.json, weights.bin, model-info.json, labels.json (ZIP).", + "downloadError": "Error while downloading model: {error}", + "imageAnalysing": "Analyzing image...", + "imageAnalyseFail": "Error analyzing image: {error}", + "predict": "Prediction: " +} } } } diff --git a/messages/vi/agent/vi_agent.json b/messages/vi/agent/vi_agent.json index e1b771e3..b7fea3c6 100644 --- a/messages/vi/agent/vi_agent.json +++ b/messages/vi/agent/vi_agent.json @@ -30,7 +30,22 @@ "result": "KẾT QUẢ", "uploading": "Đang tải mô hình…", "reset": "Đặt lại quá trình nhận diện AI", - "video": "Video được phóng to để dễ quan sát hơn." + "video": "Video được phóng to để dễ quan sát hơn.", + "status": { + "minImagesPerClass": "Cần ít nhất {minImagesPerClass} ảnh cho mỗi class để train model tốt!\nHiện tại: {totalImages} ảnh\nCần: {missingImage} ảnh", + "notBalance": "Dữ liệu không cân bằng! Cần ít nhất {minImage} ảnh cho mỗi class.", + "dataPreparation": "Đang chuẩn bị dữ liệu...", + "createModel": "Đang tạo model...", + "trainModel": "Đang train model...", + "trainSuccess": "Model đã được train thành công!", + "trainError": "Lỗi khi train model: {error}", + "readyDownload": "Đang chuẩn bị tải xuống model...", + "downloadSuccess": "Model đã được tải xuống thành công! Bao gồm: model.json, weights.bin, model-info.json, labels.json (ZIP).", + "downloadError": "Lỗi khi tải xuống model: {error}", + "imageAnalysing": "Đang phân tích ảnh...", + "imageAnalyseFail": "Lỗi khi phân tích ảnh: {error}", + "predict": "Dự đoán: " + } } } } diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index 5f8c0076..b2cc862e 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -15,6 +15,7 @@ "camera": "Mở Camera", "ready": "Sẵn sàng", "connect": "Kết nối", + "disconnect": "Ngắt kết nối", "update": "Cập Nhật", "browse": "Tải Lên", "delete": "Xóa", diff --git a/src/features/AI-model/UseTeachableMachine.ts b/src/features/AI-model/UseTeachableMachine.ts index dc33d8aa..c31531bc 100644 --- a/src/features/AI-model/UseTeachableMachine.ts +++ b/src/features/AI-model/UseTeachableMachine.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react' import * as tf from '@tensorflow/tfjs' import JSZip from 'jszip' import { saveAs } from 'file-saver' +import { useTranslations } from 'next-intl' export interface PredictionResult { className: string @@ -36,6 +37,7 @@ const CONFIG = { const BASE_MODEL_URL = 'https://storage.googleapis.com/tfjs-models/tfjs/mobilenet_v1_0.25_224/model.json' export function useTeachableMachine(initialClasses: string[] = ['Class 1', 'Class 2']) { + const t = useTranslations('agent.modelMaker.microbit.status') const [classes, setClasses] = useState(initialClasses) const [classImages, setClassImages] = useState>(() => { const initial: Record = {} @@ -248,7 +250,7 @@ export function useTeachableMachine(initialClasses: string[] = ['Class 1', 'Clas if (totalImages < minImagesPerClass * classes.length) { alert( - `Cần ít nhất ${minImagesPerClass} ảnh cho mỗi class để train model tốt!\nHiện tại: ${totalImages} ảnh\nCần: ${minImagesPerClass * classes.length} ảnh` + t('minImagesPerClass', {minImagesPerClass: minImagesPerClass, totalImages: totalImages, missingImage: minImagesPerClass * classes.length}) ) return } @@ -261,7 +263,7 @@ export function useTeachableMachine(initialClasses: string[] = ['Class 1', 'Clas }) if (!balanced) { - alert(`Dữ liệu không cân bằng! Cần ít nhất ${minImagesPerClass} ảnh cho mỗi class.`) + alert(t('notBalance', {minImage: minImagesPerClass}))// return } @@ -269,19 +271,19 @@ export function useTeachableMachine(initialClasses: string[] = ['Class 1', 'Clas try { console.log('Bắt đầu train model thật...') - setTrainingStatus({ message: 'Đang chuẩn bị dữ liệu...', type: 'info' }) + setTrainingStatus({ message: t('dataPreparation'), type: 'info' }) setTrainingProgress(10) const { images, labels } = await prepareTrainingData() console.log('Training data prepared:', images.shape, labels.shape) - setTrainingStatus({ message: 'Đang tạo model...', type: 'info' }) + setTrainingStatus({ message: t('createModel'), type: 'info' }) setTrainingProgress(20) const { featureExtractor, classifier } = await createTransferLearningModel(classes.length) console.log('Model created:', classifier) - setTrainingStatus({ message: 'Đang train model...', type: 'info' }) + setTrainingStatus({ message: t('trainModel'), type: 'info' }) setTrainingProgress(30) console.log('Extracting features...') @@ -354,12 +356,12 @@ export function useTeachableMachine(initialClasses: string[] = ['Class 1', 'Clas images.dispose() labels.dispose() - setTrainingStatus({ message: 'Model đã được train thành công!', type: 'success' }) + setTrainingStatus({ message: t('trainSuccess'), type: 'success' }) setTrainingProgress(100) } catch (error) { console.error('Lỗi khi train model:', error) setTrainingStatus({ - message: `Lỗi khi train model: ${error instanceof Error ? error.message : 'Unknown error'}`, + message: t('trainError', {error: error instanceof Error ? error.message : 'Unknown error'}), type: 'error' }) } finally { @@ -375,7 +377,7 @@ export function useTeachableMachine(initialClasses: string[] = ['Class 1', 'Clas } try { - setTrainingStatus({ message: 'Đang chuẩn bị tải xuống model...', type: 'info' }) + setTrainingStatus({ message: t('readyDownload'), type: 'info' }) // --- Lưu model vào bộ nhớ (thay vì auto download) --- const artifacts = await model.classifier.save( @@ -447,13 +449,13 @@ export function useTeachableMachine(initialClasses: string[] = ['Class 1', 'Clas setTrainingStatus({ message: - 'Model đã được tải xuống thành công! Bao gồm: model.json, weights.bin, model-info.json, labels.json (ZIP).', + t('downloadSuccess'), type: 'success' }) } catch (error) { console.error('Lỗi khi tải xuống model:', error) setTrainingStatus({ - message: `❌ Lỗi khi tải xuống model: ${error instanceof Error ? error.message : 'Unknown error'}`, + message: t('downloadError', {error: error instanceof Error ? error.message : 'Unknown error'}), type: 'error' }) } @@ -481,7 +483,7 @@ export function useTeachableMachine(initialClasses: string[] = ['Class 1', 'Clas } console.log('Bắt đầu phân tích ảnh...') - setTrainingStatus({ message: 'Đang phân tích ảnh...', type: 'info' }) + setTrainingStatus({ message: t('imageAnalysing'), type: 'info' }) try { const img = new Image() @@ -514,7 +516,7 @@ export function useTeachableMachine(initialClasses: string[] = ['Class 1', 'Clas const topResult = results[0] setTrainingStatus({ - message: `Dự đoán: ${topResult.className} (${(topResult.probability * 100).toFixed(1)}%)`, + message: t('predict') + `${topResult.className} (${(topResult.probability * 100).toFixed(1)}%)`, type: 'success' }) @@ -524,7 +526,7 @@ export function useTeachableMachine(initialClasses: string[] = ['Class 1', 'Clas } catch (error) { console.error('Error analyzing image:', error) setTrainingStatus({ - message: `Lỗi khi phân tích ảnh: ${error instanceof Error ? error.message : 'Unknown error'}`, + message: t('imageAnalyseFail', {error: error instanceof Error ? error.message : 'Unknown error'}), type: 'error' }) } diff --git a/src/features/blockly-self-build/components/MicroAI.tsx b/src/features/blockly-self-build/components/MicroAI.tsx index 5b86c7b5..42101759 100644 --- a/src/features/blockly-self-build/components/MicroAI.tsx +++ b/src/features/blockly-self-build/components/MicroAI.tsx @@ -214,9 +214,9 @@ export default function MicroAI({ modelUrl, zipFile }: { modelUrl?: string; zipF : -1 return ( -
+
{/* Header */} -
+

IMAGE MODEL

+

{/* LEFT: Results like mock */}
From 7ea4a4ad8f37745601589f95007816a4220b1998 Mon Sep 17 00:00:00 2001 From: meewaldor Date: Mon, 8 Dec 2025 12:58:45 +0700 Subject: [PATCH 14/15] fix: update layout styles in CodeLab and Workspace3DLibraryPage for improved responsiveness --- src/app/[locale]/lab/layout.tsx | 2 +- src/app/[locale]/lab/workspace-3d/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/[locale]/lab/layout.tsx b/src/app/[locale]/lab/layout.tsx index ef295633..42fc40ae 100644 --- a/src/app/[locale]/lab/layout.tsx +++ b/src/app/[locale]/lab/layout.tsx @@ -15,7 +15,7 @@ export default async function CodeLab({ return (
-
{children}
+
{children}
) } diff --git a/src/app/[locale]/lab/workspace-3d/page.tsx b/src/app/[locale]/lab/workspace-3d/page.tsx index 0586edad..9de69add 100644 --- a/src/app/[locale]/lab/workspace-3d/page.tsx +++ b/src/app/[locale]/lab/workspace-3d/page.tsx @@ -3,7 +3,7 @@ import React from 'react' export default function Workspace3DLibraryPage() { return ( -
+
) From 4e3907c23ed9fceb19d638aefca79242d1fcd44b Mon Sep 17 00:00:00 2001 From: LeThanhNhan91 Date: Mon, 8 Dec 2025 13:04:17 +0700 Subject: [PATCH 15/15] feat: multi-language in Subscription Plan for user --- messages/en/agent/en_agent.json | 28 +++++++++---------- messages/en/common/en_common.json | 3 +- messages/en/product/en_plan.json | 10 +++++++ messages/vi/common/vi_common.json | 3 +- messages/vi/product/vi_plan.json | 10 +++++++ .../components/header/SubscriptionHeader.tsx | 14 ++++++---- .../plan/components/list/SubscriptionPlan.tsx | 8 ++++-- 7 files changed, 51 insertions(+), 25 deletions(-) diff --git a/messages/en/agent/en_agent.json b/messages/en/agent/en_agent.json index 734b5837..0c683f73 100644 --- a/messages/en/agent/en_agent.json +++ b/messages/en/agent/en_agent.json @@ -32,20 +32,20 @@ "reset": "Reset the AI recognition", "video": "Video is zoomed in for better viewing.", "status": { - "minImagesPerClass": "At least {minImagesPerClass} images per class are required to train a good model!\nCurrent: {totalImages} images\nNeeded: {missingImage} images", - "notBalance": "Data is unbalanced! Each class needs at least {minImage} images.", - "dataPreparation": "Preparing data...", - "createModel": "Creating model...", - "trainModel": "Training model...", - "trainSuccess": "Model has been trained successfully!", - "trainError": "Error during model training: {error}", - "readyDownload": "Preparing model download...", - "downloadSuccess": "Model has been downloaded successfully! Includes: model.json, weights.bin, model-info.json, labels.json (ZIP).", - "downloadError": "Error while downloading model: {error}", - "imageAnalysing": "Analyzing image...", - "imageAnalyseFail": "Error analyzing image: {error}", - "predict": "Prediction: " -} + "minImagesPerClass": "At least {minImagesPerClass} images per class are required to train a good model!\nCurrent: {totalImages} images\nNeeded: {missingImage} images", + "notBalance": "Data is unbalanced! Each class needs at least {minImage} images.", + "dataPreparation": "Preparing data...", + "createModel": "Creating model...", + "trainModel": "Training model...", + "trainSuccess": "Model has been trained successfully!", + "trainError": "Error during model training: {error}", + "readyDownload": "Preparing model download...", + "downloadSuccess": "Model has been downloaded successfully! Includes: model.json, weights.bin, model-info.json, labels.json (ZIP).", + "downloadError": "Error while downloading model: {error}", + "imageAnalysing": "Analyzing image...", + "imageAnalyseFail": "Error analyzing image: {error}", + "predict": "Prediction: " + } } } } diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 1fef69ae..10e06a34 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -114,7 +114,8 @@ "backToClassroomList": "Back to Classroom List", "continueLearning": "Continue Learning", "markAsComplete": "Mark as Complete", - "startQuiz": "Start Quiz" + "startQuiz": "Start Quiz", + "contact": "Contact Us" }, "message": { "courseCreateSuccess": "Course created successfully!", diff --git a/messages/en/product/en_plan.json b/messages/en/product/en_plan.json index c5056fcf..66f103da 100644 --- a/messages/en/product/en_plan.json +++ b/messages/en/product/en_plan.json @@ -64,6 +64,16 @@ "recommended": "RECOMMENDED", "choosePlanBtn": "Choose Plan", "month": "Month" + }, + "user": { + "title": "Plans & Pricing", + "subTitle": "Flexible Plans", + "des1": "Whether your time-saving automation needs are large or small, we're here to help you scale.", + "des2": "Choose the perfect plan for your team and unlock unlimited potential.", + "annual": "Annual", + "semiAnnual": "SemiAnnual", + "popular": "Most Popular", + "support": "Support & Access" } } } diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index c491b852..748c4346 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -114,7 +114,8 @@ "backToClassroomList": "Quay lại Danh sách Lớp học", "continueLearning": "Tiếp tục Học", "markAsComplete": "Đánh dấu hoàn thành", - "startQuiz": "Bắt Đầu Bài Kiểm Tra" + "startQuiz": "Bắt Đầu Bài Kiểm Tra", + "contact": "Liên Hệ Ngay" }, "message": { "courseCreateSuccess": "Khóa học được tạo thành công!", diff --git a/messages/vi/product/vi_plan.json b/messages/vi/product/vi_plan.json index 719b3bf0..6d9bc193 100644 --- a/messages/vi/product/vi_plan.json +++ b/messages/vi/product/vi_plan.json @@ -63,6 +63,16 @@ "recommended": "ĐỀ XUẤT", "choosePlanBtn": "Chọn gói", "month": "Tháng" + }, + "user": { + "title": "Gói & Giá", + "subTitle": "Gói linh hoạt", + "des1": "Dù nhu cầu tự động hóa của bạn lớn hay nhỏ, chúng tôi luôn sẵn sàng hỗ trợ bạn phát triển.", + "des2": "Chọn gói phù hợp nhất cho đội của bạn và mở khóa tiềm năng không giới hạn.", + "annual": "Hàng năm", + "semiAnnual": "Nửa năm", + "popular": "Phổ biến nhất", + "support": "Hỗ trợ & Quyền truy cập" } } } diff --git a/src/features/plan/components/header/SubscriptionHeader.tsx b/src/features/plan/components/header/SubscriptionHeader.tsx index 0cc1cc98..f80fce42 100644 --- a/src/features/plan/components/header/SubscriptionHeader.tsx +++ b/src/features/plan/components/header/SubscriptionHeader.tsx @@ -6,8 +6,10 @@ import { setParam } from '@/features/plan/slice/planProductSlice' import { BillingCycle } from '@/features/plan/types/plan.type' import { containerVariants, itemVariants } from '@/utils/motion' import { useEffect } from 'react' +import { useTranslations } from 'next-intl' export function SubscriptionHeader() { + const t = useTranslations('plan.user') const dispatch = useAppDispatch() const billingCycle = useAppSelector((state) => state.plan.billingCycle) @@ -27,19 +29,19 @@ export function SubscriptionHeader() {
- Flexible Plans + {t('subTitle')} - Plans & Pricing + {t('title')} - Whether your time-saving automation needs are large or small, we're here to help you scale. + {t('des1')} - Choose the perfect plan for your team and unlock unlimited potential. + {t('des2')} @@ -53,8 +55,8 @@ export function SubscriptionHeader() { >
{[ - { label: 'Semiannual', value: BillingCycle.SEMIANNUAL }, - { label: 'Annual', value: BillingCycle.ANNUAL } + { label: t('semiAnnual'), value: BillingCycle.SEMIANNUAL }, + { label: t('annual'), value: BillingCycle.ANNUAL } ].map((option) => ( {/* Features */}