From 696195d222398399850299a125993af8ddd70178 Mon Sep 17 00:00:00 2001 From: meewaldor Date: Sat, 13 Dec 2025 14:38:44 +0700 Subject: [PATCH 01/36] feat: implement AddStudentToGroup modal and update group API for student management; enhance UI with new student removal functionality --- messages/en/common/en_common.json | 3 +- messages/vi/common/vi_common.json | 3 +- .../organization/group/create/page.tsx | 6 - src/features/group/api/groupApi.ts | 4 +- .../detail/OrganizationGroupTableDetail.tsx | 45 +++++- .../upsert/AddStudentToGroupModal.tsx | 71 +++++++++ .../upsert/CreateStudentGroupPage.tsx | 59 -------- .../upsert/Step1SelectStudentGroup.tsx | 121 ---------------- .../upsert/Step2CreateStudentGroup.tsx | 137 ------------------ .../group/components/upsert/StudentColumn.tsx | 4 - src/providers/ModalProvider.tsx | 2 + src/types/general.ts | 1 + 12 files changed, 117 insertions(+), 339 deletions(-) delete mode 100644 src/app/[locale]/organization/group/create/page.tsx create mode 100644 src/features/group/components/upsert/AddStudentToGroupModal.tsx delete mode 100644 src/features/group/components/upsert/CreateStudentGroupPage.tsx delete mode 100644 src/features/group/components/upsert/Step1SelectStudentGroup.tsx delete mode 100644 src/features/group/components/upsert/Step2CreateStudentGroup.tsx diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 13855ede..bcdaf8df 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -142,7 +142,8 @@ "upgrade": "Upgrade Plan", "uploadFile": "Upload File", "exportGLB": "Export GLB", - "restore": "Restore" + "restore": "Restore", + "removeStudents": "Remove {student} Students" }, "message": { "courseCreateSuccess": "Course created successfully!", diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index ce514170..5c629192 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -142,7 +142,8 @@ "upgrade": "Nâng Cấp Gói", "uploadFile": "Tải Lên Tệp", "exportGLB": "Xuất GLB", - "restore": "Khôi Phục" + "restore": "Khôi Phục", + "removeStudents": "Xóa {student} Học Sinh" }, "message": { "courseCreateSuccess": "Khóa học được tạo thành công!", diff --git a/src/app/[locale]/organization/group/create/page.tsx b/src/app/[locale]/organization/group/create/page.tsx deleted file mode 100644 index 5e53719a..00000000 --- a/src/app/[locale]/organization/group/create/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import CreateStudentGroupPage from '@/features/group/components/upsert/CreateStudentGroupPage' -import React from 'react' - -export default function Page() { - return -} diff --git a/src/features/group/api/groupApi.ts b/src/features/group/api/groupApi.ts index 677a4127..f87d37bb 100644 --- a/src/features/group/api/groupApi.ts +++ b/src/features/group/api/groupApi.ts @@ -27,7 +27,7 @@ export const groupApi = createCrudApi({ query: ({ groupId, studentIds }) => ({ url: `/groups/${groupId}/students`, method: 'POST', - body: studentIds + body: { studentIds } }), invalidatesTags: ['Group'] }), @@ -39,7 +39,7 @@ export const groupApi = createCrudApi({ query: ({ groupId, studentIds }) => ({ url: `/groups/${groupId}/students`, method: 'DELETE', - body: studentIds + body: { studentIds } }), invalidatesTags: ['Group'] }) diff --git a/src/features/group/components/detail/OrganizationGroupTableDetail.tsx b/src/features/group/components/detail/OrganizationGroupTableDetail.tsx index 1cd8e129..6c2ecdc2 100644 --- a/src/features/group/components/detail/OrganizationGroupTableDetail.tsx +++ b/src/features/group/components/detail/OrganizationGroupTableDetail.tsx @@ -1,9 +1,13 @@ 'use client' import { useLocale, useTranslations } from 'next-intl' -import { useGetGroupByIdQuery } from '@/features/group/api/groupApi' +import { + useAddStudentToGroupMutation, + useGetGroupByIdQuery, + useRemoveStudentFromGroupMutation +} 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 { useMemo, useState } from 'react' import BackButton from '@/components/shared/button/BackButton' import { useParams } from 'next/navigation' import { Card, CardContent, CardHeader, CardTitle } from '@/components/shadcn/card' @@ -11,8 +15,13 @@ import { Badge } from '@/components/shadcn/badge' import { Users, Calendar, Hash, Copy } from 'lucide-react' import { formatDate, useOrgUserStatusTranslation } from '@/utils/index' import { toast } from 'sonner' +import { Button } from '@/components/shadcn/button' +import { useModal } from '@/providers/ModalProvider' export default function OrganizationGroupTable() { + const [selectedStudentIds, setSelectedStudentIds] = useState([]) + + const { openModal } = useModal() const { groupId } = useParams() const locale = useLocale() const to = useTranslations('organization.group') @@ -21,6 +30,7 @@ export default function OrganizationGroupTable() { const columns = useGetGroupColumn() const orgUserStatusTranslation = useOrgUserStatusTranslation() const { data, isLoading } = useGetGroupByIdQuery(Number(groupId), { skip: !groupId }) + const [deleteStudents] = useRemoveStudentFromGroupMutation() const groupData = data?.data @@ -39,6 +49,16 @@ export default function OrganizationGroupTable() { return { total, active, inactive } }, [groupData]) + const handleCopyToClipboard = (text: string) => { + navigator.clipboard.writeText(text) + toast.success(tt('successMessage.copiedToClipboard')) + } + + const handleRemoveStudents = () => { + deleteStudents({ groupId: Number(groupId), studentIds: selectedStudentIds }) + setSelectedStudentIds([]) + } + if (isLoading) { return (
@@ -49,11 +69,6 @@ export default function OrganizationGroupTable() { ) } - const handleCopyToClipboard = (text: string) => { - navigator.clipboard.writeText(text) - toast.success(tt('successMessage.copiedToClipboard')) - } - if (!groupData) { return (
@@ -152,7 +167,21 @@ export default function OrganizationGroupTable() {
- +
+ {selectedStudentIds.length > 0 && ( + + )} + + +
+ setSelectedStudentIds(ids.map(String))} + /> ) } diff --git a/src/features/group/components/upsert/AddStudentToGroupModal.tsx b/src/features/group/components/upsert/AddStudentToGroupModal.tsx new file mode 100644 index 00000000..ad4dba18 --- /dev/null +++ b/src/features/group/components/upsert/AddStudentToGroupModal.tsx @@ -0,0 +1,71 @@ +import { Dialog, DialogContent, DialogTitle } from '@/components/shadcn/dialog' +import { DataTable } from '@/components/shared/data-table/data-table' +import { useAddStudentToGroupMutation } from '@/features/group/api/groupApi' +import StudentColumn from '@/features/group/components/upsert/StudentColumn' +import { useModal } from '@/providers/ModalProvider' +import { useParams } from 'next/navigation' +import React, { useState } from 'react' + +type Student = { + id: string + name: string + email: string + avatar: string +} + +// id is guid type +const MOCK_STUDENTS: Student[] = [ + { id: 'b3b4f7e2-5f92-4c44-9c8a-42f7c15af101', name: 'Nguyễn Văn A', email: 'nguyenvana@example.com', avatar: '' }, + { id: 'e8c1cb2a-0c3b-4db2-9fa7-cb0abf9ad3c2', name: 'Trần Thị B', email: 'tranthib@example.com', avatar: '' }, + { id: '9f37d6d8-0b15-4a19-9e6f-4d40f1a8f93f', name: 'Lê Văn C', email: 'levanc@example.com', avatar: '' }, + { id: 'c5a3ef0e-7de1-442c-b7c7-9f0e239aeba4', name: 'Phạm Thị D', email: 'phamthid@example.com', avatar: '' }, + { id: 'f4c9829e-0f3e-41f9-9c53-6d3b2897b51a', name: 'Hoàng Văn E', email: 'hoangvane@example.com', avatar: '' }, + { id: 'd1bffec6-0bc0-4d22-b5b7-fac2e57c3b0a', name: 'Vũ Thị F', email: 'vuthif@example.com', avatar: '' }, + { id: 'a8b72631-6c46-4f67-8c73-0c9e3e35a9fd', name: 'Đặng Văn G', email: 'dangvang@example.com', avatar: '' }, + { id: '7c3e0a4d-2e0c-4e66-9e12-712a1c4f2eb4', name: 'Bùi Thị H', email: 'buithih@example.com', avatar: '' }, + { id: 'fddc6f8e-0d4c-4777-8dbb-0f1e3af1a21e', name: 'Đỗ Văn I', email: 'dovani@example.com', avatar: '' }, + { id: '4d96b7e2-5a28-42b2-a28a-8fd1a0c3f6df', name: 'Ngô Thị K', email: 'ngothik@example.com', avatar: '' } +] + +export default function AddStudentToGroupModal() { + const [selectedStudentIds, setSelectedStudentIds] = useState([]) + + const { closeModal } = useModal() + const { groupId } = useParams() + const columns = StudentColumn() + + const [addStudents] = useAddStudentToGroupMutation() + + const handleAddStudents = () => { + addStudents({ groupId: Number(groupId), studentIds: selectedStudentIds }) + // closeModal() + } + + return ( + + + AddStudentToGroupModal +
+ {}} + onSelectionChange={(ids) => setSelectedStudentIds(ids.map(String))} + /> + +
+ +
+
+
+
+ ) +} diff --git a/src/features/group/components/upsert/CreateStudentGroupPage.tsx b/src/features/group/components/upsert/CreateStudentGroupPage.tsx deleted file mode 100644 index 0a28dc28..00000000 --- a/src/features/group/components/upsert/CreateStudentGroupPage.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'use client' -import { useState } from 'react' -import Step1SelectStudentGroup from './Step1SelectStudentGroup' -import Step2CreateStudentGroup from './Step2CreateStudentGroup' - -export default function CreateStudentGroupPage() { - const [currentStep, setCurrentStep] = useState<1 | 2>(1) - const [step1Data, setStep1Data] = useState<{ - numberOfStudents: number - gradeLevel: number - selectedStudentIds: string[] - } | null>(null) - - const handleStep1Next = (data: { numberOfStudents: number; gradeLevel: number; selectedStudentIds: string[] }) => { - setStep1Data(data) - setCurrentStep(2) - } - - const handleStep2Back = () => { - setCurrentStep(1) - } - - return ( -
-
-
-
- 1 -
-
-
-
-
- 2 -
-
-
- - {currentStep === 1 && } - - {currentStep === 2 && step1Data && ( - - )} -
- ) -} diff --git a/src/features/group/components/upsert/Step1SelectStudentGroup.tsx b/src/features/group/components/upsert/Step1SelectStudentGroup.tsx deleted file mode 100644 index 8a497dad..00000000 --- a/src/features/group/components/upsert/Step1SelectStudentGroup.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useState } from 'react' -import { Button } from '@/components/shadcn/button' -import { Input } from '@/components/shadcn/input' -import { Label } from '@/components/shadcn/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/shadcn/select' -import { DataTable } from '@/components/shared/data-table/data-table' -import StudentColumn from '@/features/group/components/upsert/StudentColumn' -import { useTranslations } from 'next-intl' - -type Student = { - id: string - name: string - email: string - avatar: string -} - -const MOCK_STUDENTS: Student[] = [ - { id: '1', name: 'Nguyễn Văn A', email: 'nguyenvana@example.com', avatar: '' }, - { id: '2', name: 'Trần Thị B', email: 'tranthib@example.com', avatar: '' }, - { id: '3', name: 'Lê Văn C', email: 'levanc@example.com', avatar: '' }, - { id: '4', name: 'Phạm Thị D', email: 'phamthid@example.com', avatar: '' }, - { id: '5', name: 'Hoàng Văn E', email: 'hoangvane@example.com', avatar: '' }, - { id: '6', name: 'Vũ Thị F', email: 'vuthif@example.com', avatar: '' }, - { id: '7', name: 'Đặng Văn G', email: 'dangvang@example.com', avatar: '' }, - { id: '8', name: 'Bùi Thị H', email: 'buithih@example.com', avatar: '' }, - { id: '9', name: 'Đỗ Văn I', email: 'dovani@example.com', avatar: '' }, - { id: '10', name: 'Ngô Thị K', email: 'ngothik@example.com', avatar: '' } -] - -interface Step1SelectStudentGroupProps { - onNext: (data: { numberOfStudents: number; gradeLevel: number; selectedStudentIds: string[] }) => void -} - -export default function Step1SelectStudentGroup({ onNext }: Step1SelectStudentGroupProps) { - const to = useTranslations('organization.group') - const tc = useTranslations('common') - - const [numberOfStudents, setNumberOfStudents] = useState('') - const [gradeLevel, setGradeLevel] = useState('') - const [selectedStudentIds, setSelectedStudentIds] = useState([]) - - const columns = StudentColumn() - - const handleNext = () => { - if (!numberOfStudents || !gradeLevel || selectedStudentIds.length === 0) { - alert('Vui lòng điền đầy đủ thông tin và chọn ít nhất 1 học sinh') - return - } - - const numStudents = parseInt(numberOfStudents) - if (numStudents <= 0) { - alert('Số học sinh trong 1 group phải lớn hơn 0') - return - } - - onNext({ - numberOfStudents: numStudents, - gradeLevel: parseInt(gradeLevel), - selectedStudentIds - }) - } - - return ( -
-
-

{to('step1.title')}

-
- -
-
- - setNumberOfStudents(e.target.value)} - /> -
- -
- - -
-
- -
-
- - - {to('step1.selected')}: {selectedStudentIds.length}/{MOCK_STUDENTS.length} - -
- - setSelectedStudentIds(ids.map(String))} - /> -
- -
- -
-
- ) -} diff --git a/src/features/group/components/upsert/Step2CreateStudentGroup.tsx b/src/features/group/components/upsert/Step2CreateStudentGroup.tsx deleted file mode 100644 index dd583f4c..00000000 --- a/src/features/group/components/upsert/Step2CreateStudentGroup.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useState } from 'react' -import { Button } from '@/components/shadcn/button' -import { Input } from '@/components/shadcn/input' -import { Label } from '@/components/shadcn/label' -import { useTranslations } from 'next-intl' - -interface Group { - groupName: string - studentIds: string[] -} - -interface Step2CreateStudentGroupProps { - numberOfStudents: number - gradeLevel: number - selectedStudentIds: string[] - onBack?: () => void -} - -export default function Step2CreateStudentGroup({ - numberOfStudents, - gradeLevel, - selectedStudentIds, - onBack -}: Step2CreateStudentGroupProps) { - const to = useTranslations('organization.group') - const tc = useTranslations('common') - // Tính số lượng groups - const numberOfGroups = Math.ceil(selectedStudentIds.length / numberOfStudents) - - // Chia students vào các groups - const distributeStudents = () => { - const groups: string[][] = [] - for (let i = 0; i < numberOfGroups; i++) { - groups.push([]) - } - - selectedStudentIds.forEach((studentId, index) => { - const groupIndex = index % numberOfGroups - groups[groupIndex].push(studentId) - }) - - return groups - } - - const distributedGroups = distributeStudents() - - // State để lưu tên của mỗi group - const [groupNames, setGroupNames] = useState( - Array(numberOfGroups) - .fill('') - .map((_, i) => `Group ${i + 1}`) - ) - - const handleGroupNameChange = (index: number, value: string) => { - const newGroupNames = [...groupNames] - newGroupNames[index] = value - setGroupNames(newGroupNames) - } - - const handleCreate = () => { - // Validate: kiểm tra tất cả groups đều có tên - const hasEmptyName = groupNames.some((name) => name.trim() === '') - if (hasEmptyName) { - alert('Vui lòng nhập tên cho tất cả các groups') - return - } - - // Tạo mảng kết quả - const result: Group[] = groupNames.map((groupName, index) => ({ - groupName: groupName.trim(), - studentIds: distributedGroups[index] - })) - - // Console log kết quả - console.log('=== CREATE STUDENT GROUPS ===') - console.log('Grade Level:', gradeLevel) - console.log('Groups:', JSON.stringify(result, null, 2)) - console.log('===========================') - - // Có thể thêm logic gọi API ở đây - alert('Đã tạo groups thành công! Kiểm tra console để xem kết quả.') - } - - return ( -
-
-

{to('step2.title')}

-

- {to('step2.numberOfStudents')}: {selectedStudentIds.length} | {to('step2.studentsPerGroup')}:{' '} - {numberOfStudents} | {to('step2.gradeLevel')}: {gradeLevel} -

-
- -
- - -
- {distributedGroups.map((studentIds, index) => ( -
-
- - handleGroupNameChange(index, e.target.value)} - /> -
- -
-
-
{to('step2.studentCount')}
-
{studentIds.length}
-
-
-
- ))} -
-
- -
- {onBack && ( - - )} - -
-
- ) -} diff --git a/src/features/group/components/upsert/StudentColumn.tsx b/src/features/group/components/upsert/StudentColumn.tsx index b175a70b..e569c915 100644 --- a/src/features/group/components/upsert/StudentColumn.tsx +++ b/src/features/group/components/upsert/StudentColumn.tsx @@ -13,10 +13,6 @@ export default function StudentColumn(): ColumnDef[] { const to = useTranslations('common.tableHeader') return [ createSelectColumn(), - { - accessorKey: 'avatar', - header: to('avatar') - }, { accessorKey: 'id', header: 'ID' diff --git a/src/providers/ModalProvider.tsx b/src/providers/ModalProvider.tsx index 2b2c68c1..6cdf9e62 100644 --- a/src/providers/ModalProvider.tsx +++ b/src/providers/ModalProvider.tsx @@ -47,6 +47,7 @@ import UpsertGroupModal from '@/features/group/components/modal/UpsertGroupModal import UpdateGroupModal from '@/features/group/components/modal/UpdateGroupModal' import CreateQuizModal from '@/features/resource/quiz/components/modal/CreateQuizModal' import { UpsertStudentGroup } from '@/features/group/components/upsert/UpsertStudentGroup' +import AddStudentToGroupModal from '@/features/group/components/upsert/AddStudentToGroupModal' const ModalContext = createContext({ openModal: () => {}, closeModal: () => {}, @@ -105,6 +106,7 @@ export const ModalProvider = ({ children }: { children: React.ReactNode }) => { {modalType === 'updateGroup' && } {modalType === 'createQuiz' && } {modalType === 'upsertStudentGroup' && } + {modalType === 'addStudentToGroup' && } {/* detail */} {modalType === 'lessonDetail' && } diff --git a/src/types/general.ts b/src/types/general.ts index 0e5df6c9..43fd8b06 100644 --- a/src/types/general.ts +++ b/src/types/general.ts @@ -44,6 +44,7 @@ export type ModalType = | 'updateGroup' | 'createQuiz' | 'upsertStudentGroup' + | 'addStudentToGroup' // detail | 'lessonDetail' From eed9892040a0c2374eca72a7574e08b5983e067d Mon Sep 17 00:00:00 2001 From: meewaldor Date: Sat, 13 Dec 2025 14:50:04 +0700 Subject: [PATCH 02/36] feat: update loading component to use Loader2 and adjust size; modify middleware role redirect paths for courses --- src/components/shared/loading/LoadingComponent.tsx | 13 ++++++------- src/middleware.ts | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/components/shared/loading/LoadingComponent.tsx b/src/components/shared/loading/LoadingComponent.tsx index 7f156f4c..7d1d633b 100644 --- a/src/components/shared/loading/LoadingComponent.tsx +++ b/src/components/shared/loading/LoadingComponent.tsx @@ -1,7 +1,6 @@ 'use client' import React from 'react' -import { DotLottieReact } from '@lottiefiles/dotlottie-react' -import Image from 'next/image' +import { Loader2 } from 'lucide-react' type LoadingProps = { size?: number @@ -9,12 +8,12 @@ type LoadingProps = { text?: string } -export default function LoadingComponent({ size = 75, textShow = true, text }: LoadingProps) { - // return +export default function LoadingComponent({ size = 32, textShow = true, text }: LoadingProps) { return ( -
- Loading Cat - {/* {textShow &&

{text || 'One moment please...'}

} */} +
+ + + {/* {textShow &&

{text || 'One moment please...'}

} */}
) } diff --git a/src/middleware.ts b/src/middleware.ts index b46f838f..a40262d3 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -112,8 +112,8 @@ export default withAuth( } const roleRedirectMap: Record = { - [UserRole.ADMIN]: `/${locale}/admin/curriculum`, - [UserRole.STAFF]: `/${locale}/admin/curriculum`, + [UserRole.ADMIN]: `/${locale}/admin/course`, + [UserRole.STAFF]: `/${locale}/admin/course`, [LicenseType.ORGANIZATION_ADMIN]: `/${locale}/organization/dashboard`, [UserRole.GUEST]: `/${locale}`, [LicenseType.STUDENT]: `/${locale}`, From 94ae8d792a84cafff0a2e9be765cf69a739f6574 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sat, 13 Dec 2025 14:51:22 +0700 Subject: [PATCH 03/36] feat: Add cancellation confirmation modal and update subscription status handling in OrganizationSubscriptionDetail component --- messages/en/common/en_common.json | 2 +- messages/en/common/en_toast.json | 3 ++- messages/vi/common/vi_common.json | 2 +- messages/vi/common/vi_toast.json | 3 ++- .../detail/OrganizationSubscriptionDetail.tsx | 20 +++++++++++++++++-- 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 13855ede..bff7fcf7 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -115,7 +115,7 @@ "sendInvitations": "Send Invitations", "clearFilters": "Clear Filters", "createClassroom": "Create Classroom", - "addStudents": "Add Students", + "addStudents": "Add Users", "reviewed": "Reviewed", "notReviewed": "Not Reviewed", "submitReview": "Submit Review", diff --git a/messages/en/common/en_toast.json b/messages/en/common/en_toast.json index 5077d1c5..648d40be 100644 --- a/messages/en/common/en_toast.json +++ b/messages/en/common/en_toast.json @@ -54,7 +54,8 @@ "removeItemFromCart": "Are you sure you want to remove this item from the cart?", "clearCart": "Are you sure you want to clear the cart?", "deleteUserEmail": "Are you sure you want to delete the user with email \"{title}\"?", - "restore": "Are you sure you want to restore \"{title}\"?" + "restore": "Are you sure you want to restore \"{title}\"?", + "cancelledSubscriptions": "Are you sure you want to cancel this subscription?" }, "unauthorized": "Unauthorized access." } diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index ce514170..73d2fa29 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -116,7 +116,7 @@ "sendInvitations": "Gửi Lời Mời", "clearFilters": "Xóa Bộ Lọc", "createClassroom": "Tạo Lớp Học", - "addStudents": "Thêm Học Sinh", + "addStudents": "Thêm Người Dùng", "reviewed": "Đã đánh giá", "notReviewed": "Chưa đánh giá", "submitReview": "Gửi Đánh Giá", diff --git a/messages/vi/common/vi_toast.json b/messages/vi/common/vi_toast.json index 5769916e..8583bd22 100644 --- a/messages/vi/common/vi_toast.json +++ b/messages/vi/common/vi_toast.json @@ -53,7 +53,8 @@ "addAnotherKit": "Khóa học đã có bộ kit. Bạn có muốn thay thế bằng bộ mới?", "removeItemFromCart": "Bạn có chắc chắn muốn xóa sản phẩm này khỏi giỏ hàng không?", "clearCart": "Bạn có chắc chắn muốn xóa giỏ hàng không?", - "restore": "Bạn có chắc chắn muốn khôi phục \"{title}\" không?" + "restore": "Bạn có chắc chắn muốn khôi phục \"{title}\" không?", + "cancelledSubscriptions": "Bạn có chắc chắn muốn hủy đăng ký này không?" }, "unauthorized": "Truy cập không được phép." } diff --git a/src/features/subscription/components/detail/OrganizationSubscriptionDetail.tsx b/src/features/subscription/components/detail/OrganizationSubscriptionDetail.tsx index 4c4f7b20..8f6ec1c4 100644 --- a/src/features/subscription/components/detail/OrganizationSubscriptionDetail.tsx +++ b/src/features/subscription/components/detail/OrganizationSubscriptionDetail.tsx @@ -5,7 +5,7 @@ import { Badge } from '@/components/shadcn/badge' import { Button } from '@/components/shadcn/button' import { Progress } from '@/components/shadcn/progress' import { Users, GraduationCap, BookOpen, Calendar, CreditCard } from 'lucide-react' -import { useGetSubscriptionByIdQuery } from '@/features/subscription/api/subscriptionApi' +import { useGetSubscriptionByIdQuery, useUpdateSubscriptionMutation } from '@/features/subscription/api/subscriptionApi' import { useParams } from 'next/navigation' import LoadingComponent from '@/components/shared/loading/LoadingComponent' import { formatDate, useStatusTranslation } from '@/utils/index' @@ -16,16 +16,20 @@ import { getStatusBadgeClass } from '@/utils/badgeColor' import BackButton from '@/components/shared/button/BackButton' import { SubscriptionStatus } from '@/features/subscription/types/subscription.type' import { useLocale, useTranslations } from 'next-intl' +import { useModal } from '@/providers/ModalProvider' export default function OrganizationSubscriptionDetail() { const locale = useLocale() const ts = useTranslations('subscription.detail') const to = useTranslations('organization') const tc = useTranslations('common') + const tt = useTranslations('toast') const { subscriptionId } = useParams() const statusTranslations = useStatusTranslation() + const { openModal } = useModal() const { data: subscription, isLoading: isLoadingSubscription } = useGetSubscriptionByIdQuery(Number(subscriptionId)) + const [updateSubscription] = useUpdateSubscriptionMutation() const getRemainingMonths = (endDate?: string) => { if (!endDate) return 0 @@ -63,6 +67,18 @@ export default function OrganizationSubscriptionDetail() { return Math.round(progress) } + const handleCancelSubscription = () => { + openModal('confirm', { + title: tt('confirmMessage.cancelledSubscriptions'), + message: tt('confirmMessage.cancelledSubscriptions'), + onConfirm: () => + updateSubscription({ + subscriptionId: Number(subscriptionId), + body: { status: SubscriptionStatus.CANCELLED } + }) + }) + } + if (isLoadingSubscription) { return (
@@ -93,7 +109,7 @@ export default function OrganizationSubscriptionDetail() { {/* Action Buttons */}
{/* */} -
From f494364344a2a1521c7bc40fe16498b47ac8e2b2 Mon Sep 17 00:00:00 2001 From: meewaldor Date: Sat, 13 Dec 2025 15:34:42 +0700 Subject: [PATCH 04/36] feat: integrate status translation and badge styling in CardHorizontal, AdminCourseList, AdminCurriculumList, KitList, and KitListSection components --- src/components/shared/card/CardHorizontal.tsx | 9 ++-- .../components/list/AdminCourseList.tsx | 7 +-- .../components/list/AdminCurriculumList.tsx | 44 ++++++++++--------- .../resource/kit/components/list/KitList.tsx | 2 + .../kit/components/list/KitListSection.tsx | 12 +---- 5 files changed, 35 insertions(+), 39 deletions(-) diff --git a/src/components/shared/card/CardHorizontal.tsx b/src/components/shared/card/CardHorizontal.tsx index e73fc905..25d5c919 100644 --- a/src/components/shared/card/CardHorizontal.tsx +++ b/src/components/shared/card/CardHorizontal.tsx @@ -3,6 +3,8 @@ import { Star } from 'lucide-react' import { Card, CardContent } from '@/components/shadcn/card' import { Badge } from '@/components/shadcn/badge' import { useTranslations } from 'next-intl' +import { getStatusBadgeClass } from '@/utils/badgeColor' +import { useStatusTranslation } from '@/utils/index' type CardProductProps = { imageUrl: string @@ -22,6 +24,7 @@ export default function CardHorizontal({ onClick }: CardProductProps) { const t = useTranslations('kits.list') + const translateStatus = useStatusTranslation() return ( {title} - {badge && ( - - {badge} - - )} + {badge && {translateStatus(badge)}} {/* Description */} diff --git a/src/features/resource/course/components/list/AdminCourseList.tsx b/src/features/resource/course/components/list/AdminCourseList.tsx index 0d27d6a5..1466e69b 100644 --- a/src/features/resource/course/components/list/AdminCourseList.tsx +++ b/src/features/resource/course/components/list/AdminCourseList.tsx @@ -12,7 +12,7 @@ import { IconPlus } from '@tabler/icons-react' import Link from 'next/link' import CardLayout from '@/components/shared/card/CardLayout' import { Badge } from '@/components/shadcn/badge' -import { capitalizeFirst } from '@/utils/index' +import { capitalizeFirst, useStatusTranslation } from '@/utils/index' import { SPagination } from '@/components/shared/SPagination' import { LayoutGrid, TableIcon } from 'lucide-react' import { getLevelBadgeClass, getStatusBadgeClass } from '@/utils/badgeColor' @@ -27,6 +27,7 @@ export default function AdminCourseList() { const router = useRouter() const locale = useLocale() const { openModal } = useModal() + const statusTranslation = useStatusTranslation() const courseParams = useAppSelector((state) => state.course) @@ -111,14 +112,14 @@ export default function AdminCourseList() { imageSrc={course.imageUrl} badge={ - {capitalizeFirst(course.status)} + {statusTranslation(course.status)} } footer={
{course.duration > 0 && ( - {capitalizeFirst(course.level)} + {tc(`level.${course.level.toLowerCase()}`)} )}
diff --git a/src/features/resource/curriculum/components/list/AdminCurriculumList.tsx b/src/features/resource/curriculum/components/list/AdminCurriculumList.tsx index ce34cacf..1c9a7028 100644 --- a/src/features/resource/curriculum/components/list/AdminCurriculumList.tsx +++ b/src/features/resource/curriculum/components/list/AdminCurriculumList.tsx @@ -95,27 +95,29 @@ export default function AdminCurriculumList() {
-
- - - - } - items={[ - - ]} - /> -
+ {curriculum.status === CurriculumStatus.DRAFT && ( +
+ + + + } + items={[ + + ]} + /> +
+ )}
))}
diff --git a/src/features/resource/kit/components/list/KitList.tsx b/src/features/resource/kit/components/list/KitList.tsx index 8b2b540a..25904a9c 100644 --- a/src/features/resource/kit/components/list/KitList.tsx +++ b/src/features/resource/kit/components/list/KitList.tsx @@ -9,6 +9,7 @@ import { setPageIndex } from '@/features/resource/kit/slice/kitProductSlice' import { KitSliceParams } from '@/features/resource/kit/types/kit.type' import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' import { useModal } from '@/providers/ModalProvider' +import { useStatusTranslation } from '@/utils/index' import { useLocale, useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' import React from 'react' @@ -22,6 +23,7 @@ export default function KitList() { const locale = useLocale() const { openModal } = useModal() const dispatch = useAppDispatch() + const statusTranslate = useStatusTranslation() const queryParams: KitSliceParams = useAppSelector((state) => state.kit) const { data: kitData, isLoading } = useSearchKitQuery(queryParams) diff --git a/src/features/resource/kit/components/list/KitListSection.tsx b/src/features/resource/kit/components/list/KitListSection.tsx index 5eac1499..7856b02a 100644 --- a/src/features/resource/kit/components/list/KitListSection.tsx +++ b/src/features/resource/kit/components/list/KitListSection.tsx @@ -47,16 +47,8 @@ export default function KitListSection({ context, kitId, kitIds = [] }: KitListS setLoadingKits(true) const kits: Kit[] = [] for (const id of kitIds) { - try { - const result = await triggerGetKitById(id).unwrap() - kits.push(result.data) - } catch (err: any) { - if (err?.status === 404) { - console.warn(`Kit ${id} not found (404), skipping.`) - } else { - console.error(`Error loading kit ${id}:`, err) - } - } + const result = await triggerGetKitById(id).unwrap() + kits.push(result.data) } setKits(kits) setLoadingKits(false) From 700ef9caddc6cf6e6456ca9a2cf37be761d71eda Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 14 Dec 2025 15:55:46 +0700 Subject: [PATCH 05/36] fix organization selection --- .../layout/organization/sidebar/organization-switcher.tsx | 6 +++--- .../classroom/components/upsert/CreateClassroom.tsx | 6 ++++-- .../components/list/licenseAssignmentColumnTable.tsx | 2 +- .../detail/organization/OrganizationCourseClassroom.tsx | 2 +- src/providers/AuthSessionSync.tsx | 1 + 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/layout/organization/sidebar/organization-switcher.tsx b/src/components/layout/organization/sidebar/organization-switcher.tsx index 45ee2090..eebdcea8 100644 --- a/src/components/layout/organization/sidebar/organization-switcher.tsx +++ b/src/components/layout/organization/sidebar/organization-switcher.tsx @@ -31,7 +31,7 @@ export function OrganizationSwitcher() { const licenseAssignments = licenseAssignmentData?.data?.items ?? [] - // ✅ Nếu chưa chọn org nào => mặc định chọn org đầu tiên + // Nếu chưa chọn org nào => mặc định chọn org đầu tiên React.useEffect(() => { if (licenseAssignments.length && !selectedOrganizationId) { const first = licenseAssignments[0] @@ -42,7 +42,7 @@ export function OrganizationSwitcher() { if (isLoading || !licenseAssignments.length) return null - // ✅ Organization đang được chọn + // Organization đang được chọn const selectedOrg = licenseAssignments.find((org) => org.organizationId === selectedOrganizationId) ?? licenseAssignments[0] @@ -80,7 +80,7 @@ export function OrganizationSwitcher() { side={isMobile ? 'bottom' : 'right'} sideOffset={4} > - Organizations + Tổ chức {licenseAssignments.map((org) => ( { const payload = { ...value, - courseId: Number(value.courseId), + courseId: Number(courseId), organizationSubscriptionOrderId: selectedSubscriptionId!, studentGroups: selectedGroups } diff --git a/src/features/license-assignment/components/list/licenseAssignmentColumnTable.tsx b/src/features/license-assignment/components/list/licenseAssignmentColumnTable.tsx index c8e05b0e..545c94fa 100644 --- a/src/features/license-assignment/components/list/licenseAssignmentColumnTable.tsx +++ b/src/features/license-assignment/components/list/licenseAssignmentColumnTable.tsx @@ -22,7 +22,7 @@ export function useGetLicenseAssignmentColumnTable(): ColumnDef { const src = row.original.user.imageUrl - const alt = row.original.user.firstName.charAt(0) + row.original.user.lastName.charAt(0) + const alt = row.original.user.name.charAt(0) return (
{src ? ( diff --git a/src/features/resource/course/components/detail/organization/OrganizationCourseClassroom.tsx b/src/features/resource/course/components/detail/organization/OrganizationCourseClassroom.tsx index 91de1fd8..9bd06f42 100644 --- a/src/features/resource/course/components/detail/organization/OrganizationCourseClassroom.tsx +++ b/src/features/resource/course/components/detail/organization/OrganizationCourseClassroom.tsx @@ -69,7 +69,7 @@ export default function OrganizationCourseClassroom() { className='bg-sky-600 text-white hover:bg-sky-700' onClick={() => { dispatch(setCourseId(Number(courseId))) - router.push(`/${locale}/organization/classroom/create`) + router.push(`/${locale}/organization/classroom/create?courseId=${courseId}`) }} > + {tc('button.createClassroom')} diff --git a/src/providers/AuthSessionSync.tsx b/src/providers/AuthSessionSync.tsx index 5589bc74..c7d48139 100644 --- a/src/providers/AuthSessionSync.tsx +++ b/src/providers/AuthSessionSync.tsx @@ -70,6 +70,7 @@ export default function AuthSessionSync() { dispatch(setSelectedSubscriptionOrderId(activeSub.subscriptionId)) dispatch(setSelectedOrgUserId(firstOrg.organizationUserId[0])) dispatch(setCurrentRole(activeSub.type)) // Đây là LicenseType + console.log('currentRole set to:', activeSub.type) } } }, [reduxUser, reduxSelectedOrganizationId, reduxSelectedSubscriptionOrderId, reduxCurrentRole, dispatch]) From 25202fc7aca829a06af1bd3e50e3415e2a2e85f2 Mon Sep 17 00:00:00 2001 From: LeThanhNhan91 Date: Sun, 14 Dec 2025 16:56:24 +0700 Subject: [PATCH 06/36] feat: student certificate --- .../(protected)/[certificateId]/page.tsx | 8 ++ .../[locale]/certificate/(protected)/page.tsx | 4 +- .../certificate/api/certificateApi.ts | 42 ++++++--- .../components/detail/SpecificCertificate.tsx | 5 +- .../components/item/CertificateItem.tsx | 86 ++++++++++++++++++ .../components/list/CertificateList.tsx | 90 +++++++++++++++++++ .../certificate/types/certificate.type.ts | 27 +++--- 7 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 src/app/[locale]/certificate/(protected)/[certificateId]/page.tsx create mode 100644 src/features/certificate/components/item/CertificateItem.tsx create mode 100644 src/features/certificate/components/list/CertificateList.tsx diff --git a/src/app/[locale]/certificate/(protected)/[certificateId]/page.tsx b/src/app/[locale]/certificate/(protected)/[certificateId]/page.tsx new file mode 100644 index 00000000..6207d0ec --- /dev/null +++ b/src/app/[locale]/certificate/(protected)/[certificateId]/page.tsx @@ -0,0 +1,8 @@ +import SpecificCertificate from '@/features/certificate/components/detail/SpecificCertificate' +import React from 'react' + +export default function CertificateDetailPage() { + return ( + + ) +} diff --git a/src/app/[locale]/certificate/(protected)/page.tsx b/src/app/[locale]/certificate/(protected)/page.tsx index a7fa8f9f..35889960 100644 --- a/src/app/[locale]/certificate/(protected)/page.tsx +++ b/src/app/[locale]/certificate/(protected)/page.tsx @@ -1,6 +1,6 @@ -import Accomplishment from '@/features/certificate/components/list/Accomplishment' +import CertificateList from '@/features/certificate/components/list/CertificateList' import React from 'react' export default function AccomplishmentPage() { - return + return } diff --git a/src/features/certificate/api/certificateApi.ts b/src/features/certificate/api/certificateApi.ts index 0943bb8f..888006df 100644 --- a/src/features/certificate/api/certificateApi.ts +++ b/src/features/certificate/api/certificateApi.ts @@ -1,20 +1,40 @@ -import { Certificate } from '@/features/certificate/types/certificate.type' -import { createCrudApi } from '@/libs/redux/baseApi' -import { SliceQueryParams } from '@/libs/redux/createQuerySlice' +import { createApi } from '@reduxjs/toolkit/query/react' +import { Certificate, CertificateQueryParams } from '@/features/certificate/types/certificate.type' +import { customFetchBaseQueryWithErrorHandling } from '@/libs/redux/baseApi' +import { ApiSuccessResponse, PaginatedResult } from '@/types/baseModel' -export const certificateApi = createCrudApi({ +export const certificateApi = createApi({ reducerPath: 'certificateApi', + baseQuery: customFetchBaseQueryWithErrorHandling, tagTypes: ['Certificate'], - baseUrl: '/certificates' + endpoints: (builder) => ({ + getById: builder.query, number | string>({ + query: (id) => `/certificates/${id}`, + transformResponse: (response: any) => { + return { + ...response, + data: response.data?.certificate || response.data + } + }, + providesTags: (result, error, id) => [{ type: 'Certificate', id }] + }), + + search: builder.query>, CertificateQueryParams>({ + query: (params) => ({ + url: '/certificates', + method: 'GET', + params + }), + providesTags: ['Certificate'] + }) + }) }) export const { useGetByIdQuery: useGetCertificateByIdQuery, useSearchQuery: useSearchCertificateQuery, - useGetAllQuery: useGetAllCertificateQuery, - - // lazy + + // lazy hooks useLazyGetByIdQuery: useLazyGetCertificateByIdQuery, - useLazySearchQuery: useLazySearchCertificateQuery, - useLazyGetAllQuery: useLazyGetAllCertificateQuery -} = certificateApi + useLazySearchQuery: useLazySearchCertificateQuery +} = certificateApi \ No newline at end of file diff --git a/src/features/certificate/components/detail/SpecificCertificate.tsx b/src/features/certificate/components/detail/SpecificCertificate.tsx index a200f9f9..cf9ce2c1 100644 --- a/src/features/certificate/components/detail/SpecificCertificate.tsx +++ b/src/features/certificate/components/detail/SpecificCertificate.tsx @@ -1,4 +1,3 @@ -// app/certificate/page.tsx 'use client' import CertificateDetails from './CertificateDetails' import CertificateHeader from './CertificateHeader' @@ -10,7 +9,7 @@ import { useSearchCurriculumEnrollmentQuery } from '@/features/enrollment/api/cu import { useAppSelector } from '@/hooks/redux-hooks' import SEmpty from '@/components/shared/empty/SEmpty' -const SpecificCertificatePage = () => { +const SpecificCertificate = () => { const { verificationCode } = useParams() const code = Array.isArray(verificationCode) ? verificationCode[0] : verificationCode const curriculumEnrollParams = useAppSelector((state) => state.enrollment.curriculumEnrollmentId) @@ -67,4 +66,4 @@ const SpecificCertificatePage = () => { ) } -export default SpecificCertificatePage +export default SpecificCertificate \ No newline at end of file diff --git a/src/features/certificate/components/item/CertificateItem.tsx b/src/features/certificate/components/item/CertificateItem.tsx new file mode 100644 index 00000000..7584d9b3 --- /dev/null +++ b/src/features/certificate/components/item/CertificateItem.tsx @@ -0,0 +1,86 @@ +'use client' +import { Button } from '@/components/shadcn/button' +import { Card, CardContent } from '@/components/shadcn/card' +import { Certificate, CertificateType } from '@/features/certificate/types/certificate.type' +import { formatDate } from '@/utils/index' +import { Award, FileText, Download, ExternalLink } from 'lucide-react' +import { useLocale } from 'next-intl' +import { useRouter } from 'next/navigation' +import { Badge } from '@/components/shadcn/badge' + +interface CertificateItemProps { + certificate: Certificate +} + +export const CertificateItem = ({ certificate }: CertificateItemProps) => { + const locale = useLocale() + const router = useRouter() + + const isCurriculum = certificate.certificateType === CertificateType.CURRICULUM + + const handleViewDetail = () => { + router.push(`/${locale}/certificate/${certificate.id}`) + } + + const handleDownload = (e: React.MouseEvent) => { + e.stopPropagation() + if (certificate.certificateUrl) { + window.open(certificate.certificateUrl, '_blank') + } + } + + return ( + + +
+
+ {isCurriculum ? ( + + ) : ( + + )} +
+ + {/* Thông tin chính từ API */} +
+
+

+ {certificate.title} +

+ + {certificate.certificateType} + +
+ +

+ Issued to {certificate.userName} +

+ +
+ + Date: {formatDate(certificate.issueDate)} + + + ID: {certificate.verificationCode} + +
+
+
+ + {/* Action Buttons */} +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/features/certificate/components/list/CertificateList.tsx b/src/features/certificate/components/list/CertificateList.tsx new file mode 100644 index 00000000..c2abf6aa --- /dev/null +++ b/src/features/certificate/components/list/CertificateList.tsx @@ -0,0 +1,90 @@ +'use client' +import { useSearchCertificateQuery } from '@/features/certificate/api/certificateApi' +import { CertificateType } from '@/features/certificate/types/certificate.type' +import { useAppSelector } from '@/hooks/redux-hooks' +import LoadingComponent from '@/components/shared/loading/LoadingComponent' +import SEmpty from '@/components/shared/empty/SEmpty' +import { Award, Filter } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { useMemo, useState } from 'react' +import { Tabs, TabsList, TabsTrigger } from '@/components/shadcn/tabs' +import { CertificateItem } from '../item/CertificateItem' + +export default function CertificateList() { + const t = useTranslations('MyLearning') + const { token, user } = useAppSelector((state) => state.auth) + const userId = user?.userId + + const [filterType, setFilterType] = useState('ALL') + + const { data: certificateResponse, isLoading } = useSearchCertificateQuery( + { userId: userId, pageNumber: 1, pageSize: 100 }, + { skip: !userId } + ) + + const filteredCertificates = useMemo(() => { + if (!certificateResponse?.data?.items) return [] + + const items = certificateResponse.data.items + + if (filterType === 'ALL') return items + + return items.filter(item => + item.certificateType.toLowerCase() === filterType.toLowerCase() + ) + }, [certificateResponse, filterType]) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!certificateResponse?.data?.items || certificateResponse.data.items.length === 0) { + return ( + } + /> + ) + } + + return ( +
+
+ + {/* Header Section */} +
+
+

My Certificates

+

Manage and view your earned credentials

+
+ + + + All + Courses + Specializations + + +
+ +
+ {filteredCertificates.length > 0 ? ( + filteredCertificates.map((cert) => ( + + )) + ) : ( +
+ No certificates found for this category. +
+ )} +
+ +
+
+ ) +} \ No newline at end of file diff --git a/src/features/certificate/types/certificate.type.ts b/src/features/certificate/types/certificate.type.ts index 91e9ff9a..395c649d 100644 --- a/src/features/certificate/types/certificate.type.ts +++ b/src/features/certificate/types/certificate.type.ts @@ -1,24 +1,29 @@ import { CourseEnrollment } from '@/features/enrollment/types/enrollment.type' +import { SearchPaginatedRequestParams } from '@/types/baseModel' export type Certificate = { - CertificateType: CertificateType - curriculumId?: number - courseId?: number id: number userId: string userName: string courseEnrollmentId?: number curriculumEnrollmentId?: number - issuedDate: string - certificateUrl: string + certificateType: CertificateType + issueDate: string verificationCode: string - courseTitle?: string - curriculumTitle?: string - courseEnrollments?: CourseEnrollment[] - + certificateUrl: string + title: string + completedAt?: string + courseEnrollments?: CourseEnrollment[] + userImageUrl?: string } export enum CertificateType { - COURSE = 'COURSE', - CURRICULUM = 'CURRICULUM' + COURSE = 'Course', + CURRICULUM = 'Curriculum' } + +export type CertificateQueryParams = { + userId?: string + courseEnrollmentId?: number + verificationCode?: string +} & SearchPaginatedRequestParams \ No newline at end of file From 123c92a0485b0e7617358f0edbb6894ac9de1a31 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 14 Dec 2025 17:08:11 +0700 Subject: [PATCH 07/36] feat: Add emulator management functionality to curriculum; include emulator list, selection modals, and API integration --- messages/en/common/en_toast.json | 2 + messages/en/curriculum/en_curriculum.json | 11 +- messages/vi/common/vi_toast.json | 4 +- messages/vi/curriculum/vi_curriculum.json | 11 +- .../components/list/AdminCourseList.tsx | 36 +++--- .../resource/curriculum/api/curriculumApi.ts | 30 ++++- .../detail/AdminCurriculumDetail.tsx | 3 + .../list/AdminCurriculumEmulatorList.tsx | 99 ++++++++++++++ .../components/list/EmulatorColum.tsx | 70 ++++++++++ .../AdminCurriculumSelectCourseList.tsx | 0 .../AdminCurriculumSelectEmulatorList.tsx | 121 ++++++++++++++++++ .../CurriculumSelectCourseListModal.tsx | 2 +- .../CurriculumSelectEmulatorListModal.tsx | 37 ++++++ .../curriculum/types/curriculum.type.ts | 2 + .../pacing-guide/GuideLessonDetails.tsx | 10 +- src/providers/ModalProvider.tsx | 4 +- src/types/general.ts | 1 + 17 files changed, 408 insertions(+), 35 deletions(-) create mode 100644 src/features/resource/curriculum/components/list/AdminCurriculumEmulatorList.tsx create mode 100644 src/features/resource/curriculum/components/list/EmulatorColum.tsx rename src/features/resource/curriculum/components/{list => modal}/AdminCurriculumSelectCourseList.tsx (100%) create mode 100644 src/features/resource/curriculum/components/modal/AdminCurriculumSelectEmulatorList.tsx rename src/features/resource/curriculum/components/{list => modal}/CurriculumSelectCourseListModal.tsx (94%) create mode 100644 src/features/resource/curriculum/components/modal/CurriculumSelectEmulatorListModal.tsx diff --git a/messages/en/common/en_toast.json b/messages/en/common/en_toast.json index 648d40be..a9b9de9d 100644 --- a/messages/en/common/en_toast.json +++ b/messages/en/common/en_toast.json @@ -13,6 +13,7 @@ "enrollDes": "You have enrolled in {title}. Start learning now!", "addToCourse": "Successfully added to course!", "removeCourseFromCurriculum": "Successfully removed course from this curriculum!", + "removeEmulatorFromCurriculum": "Successfully removed emulator from this curriculum!", "lessonStart": "Lesson Started!", "lessonStatus": "Lesson status updated to {status}!", "sectionComplete": "Section Completed!", @@ -44,6 +45,7 @@ "delete": "Are you sure you want to delete \"{title}\"?", "archive": "Are you sure you want to archive \"{title}\"?", "removeCourse": "Are you sure you want to remove \"{title}\" from this curriculum?", + "removeEmulator": "Are you sure you want to remove \"{title}\" from this curriculum?", "removeKit": "Are you sure you want to remove \"{title}\" from this course?", "removeComponent": "Are you sure you want to remove component from this kit?", "discard": "Are you sure you want to discard your changes?", diff --git a/messages/en/curriculum/en_curriculum.json b/messages/en/curriculum/en_curriculum.json index 5eb88d1d..40361a02 100644 --- a/messages/en/curriculum/en_curriculum.json +++ b/messages/en/curriculum/en_curriculum.json @@ -13,6 +13,7 @@ "loading": "Loading curriculums...", "error": "An error occurred while fetching curriculums. Please try again.", "courseListTitle": "Courses List", + "emulatorListTitle": "Emulator List", "viewDetails": "View Details", "reviews": "reviews" }, @@ -20,9 +21,9 @@ "title": "Curriculum Details", "addCourse": "Add Course", "price": "Price", - "alrealyEnrolled": "Already Enrolled" + "alrealyEnrolled": "Already Enrolled", + "addEmulator": "Add Emulator" }, - "form": { "title": { "create": "Create Curriculum", @@ -61,10 +62,14 @@ "selectCourseTitle": "Select Courses", "selectedCourses": "Selected courses", "searchCoursePlaceholder": "Search courses by code or title...", + "emulatorListTitle": "Emulator List", + "selectEmulationTitle": "Select Emulators", + "selectedEmulators": "Selected emulators", + "searchEmulatorPlaceholder": "Search emulators here...", "selectKitTitle": "Select Kits", "selectedKits": "Selected kits", "searchKitPlaceholder": "Search kits here...", "reviewMessage": "Curriculum submission is under review." } } -} +} \ No newline at end of file diff --git a/messages/vi/common/vi_toast.json b/messages/vi/common/vi_toast.json index 8583bd22..b516f59e 100644 --- a/messages/vi/common/vi_toast.json +++ b/messages/vi/common/vi_toast.json @@ -13,6 +13,7 @@ "enrollDes": "Bạn đã đăng ký khóa học {title}. Bắt đầu học ngay!", "addToCourse": "Đã thêm vào khóa học thành công!", "removeCourseFromCurriculum": "Đã xóa khóa học khỏi chương trình học thành công!", + "removeEmulatorFromCurriculum": "Đã xóa mô hình 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": "Đã hoàn thành!", @@ -44,6 +45,7 @@ "delete": "Bạn có chắc chắn muốn xóa \"{title}\" không?", "archive": "Bạn có chắc chắn muốn lưu trữ \"{title}\" không?", "removeCourse": "Bạn có chắc chắn muốn xóa \"{title}\" khỏi chương trình học này không?", + "removeEmulator": "Bạn có chắc chắn muốn xóa \"{title}\" khỏi chương trình học này không?", "removeKit": "Bạn có chắc chắn muốn xóa \"{title}\" khỏi khóa học này không?", "removeComponent": "Bạn có chắc chắn muốn xóa thành phần khỏi bộ dụng cụ này không?", "discard": "Bạn có chắc chắn muốn hủy bỏ các thay đổi của mình không?", @@ -58,4 +60,4 @@ }, "unauthorized": "Truy cập không được phép." } -} +} \ No newline at end of file diff --git a/messages/vi/curriculum/vi_curriculum.json b/messages/vi/curriculum/vi_curriculum.json index fd4a79b4..e1547c5b 100644 --- a/messages/vi/curriculum/vi_curriculum.json +++ b/messages/vi/curriculum/vi_curriculum.json @@ -13,6 +13,7 @@ "loading": "Đang tải khung chương trình...", "error": "Đã xảy ra lỗi trong quá trình lấy khung chương trình. Vui lòng thử lại.", "courseListTitle": "Danh Sách Các Khóa Học", + "emulatorListTitle": "Danh Sách Mô Hình", "viewDetails": "Xem Chi Tiết", "reviews": "đánh giá" }, @@ -20,9 +21,9 @@ "title": "Chi Tiết Khung Chương Trình", "addCourse": "Thêm Khóa Học", "price": "Giá", - "alreadyEnrolled": "Đã Đăng Ký" + "alreadyEnrolled": "Đã Đăng Ký", + "addEmulator": "Thêm Mô Hình" }, - "form": { "title": { "create": "Tạo Khung Chương Trình", @@ -61,10 +62,14 @@ "selectCourseTitle": "Chọn Khóa Học", "selectedCourses": "Khóa học đã chọn", "searchCoursePlaceholder": "Tìm kiếm khóa học theo mã hoặc tiêu đề...", + "emulatorListTitle": "Danh Sách Mô Hình", + "selectEmulationTitle": "Chọn Mô Hình", + "selectedEmulators": "Mô hình đã chọn", + "searchEmulatorPlaceholder": "Tìm kiếm mô hình ở đây...", "selectKitTitle": "Chọn Bộ Dụng Cụ Học Tập", "selectedKits": "Kit đã chọn", "searchKitPlaceholder": "Tìm kiếm bộ dụng cụ ở đây...", "reviewMessage": "Khung chương trình đang đợi quản trị viên phê duyệt." } } -} +} \ No newline at end of file diff --git a/src/features/resource/course/components/list/AdminCourseList.tsx b/src/features/resource/course/components/list/AdminCourseList.tsx index 1466e69b..31e800de 100644 --- a/src/features/resource/course/components/list/AdminCourseList.tsx +++ b/src/features/resource/course/components/list/AdminCourseList.tsx @@ -54,10 +54,6 @@ export default function AdminCourseList() { const rows = React.useMemo(() => data?.data.items ?? [], [data]) - const handleCreate = () => { - router.push(`/${locale}/admin/course/create`) - } - const handlePageChange = (newPage: number) => { dispatch(setPageIndex(newPage)) } @@ -67,7 +63,7 @@ export default function AdminCourseList() {
@@ -86,26 +82,12 @@ export default function AdminCourseList() { ) }} items={[ - { - value: 'table', - label: , - content: ( - - ) - }, { value: 'card', label: , content: (
-
+
{rows.map((course: any) => ( ) + }, + { + value: 'table', + label: , + content: ( + + ) } ]} /> diff --git a/src/features/resource/curriculum/api/curriculumApi.ts b/src/features/resource/curriculum/api/curriculumApi.ts index 3b81a029..2a2a1c99 100644 --- a/src/features/resource/curriculum/api/curriculumApi.ts +++ b/src/features/resource/curriculum/api/curriculumApi.ts @@ -49,6 +49,30 @@ export const curriculumApi = createCrudApi({ 'Curriculum', 'Course' ] + }), + addEmulationToCurriculum: builder.mutation({ + query: ({ curriculumId, emulationIds }) => ({ + url: `/curriculums/${curriculumId}/emulations`, + method: 'POST', + body: { + emulationIds + } + }), + invalidatesTags: (result, error, { curriculumId }) => [ + { type: 'Curriculum', id: curriculumId }, + 'Curriculum', + 'Course' + ] + }), + deleteEmulationFromCurriculum: builder.mutation({ + query: ({ curriculumId, emulationIds }) => ({ + url: `/curriculums/${curriculumId}/emulations`, + method: 'DELETE', + body: { + emulationIds + } + }), + invalidatesTags: (result, error, { curriculumId }) => [{ type: 'Curriculum', id: curriculumId }, 'Curriculum'] }) }) }) @@ -69,5 +93,9 @@ export const { // curriculum courses useAddCourseToCurriculumMutation, useDeleteCourseFromCurriculumMutation, - useUpdateCourseOrderMutation + useUpdateCourseOrderMutation, + + // curriculum emulations + useAddEmulationToCurriculumMutation, + useDeleteEmulationFromCurriculumMutation } = curriculumApi diff --git a/src/features/resource/curriculum/components/detail/AdminCurriculumDetail.tsx b/src/features/resource/curriculum/components/detail/AdminCurriculumDetail.tsx index 9b032b9f..143dec00 100644 --- a/src/features/resource/curriculum/components/detail/AdminCurriculumDetail.tsx +++ b/src/features/resource/curriculum/components/detail/AdminCurriculumDetail.tsx @@ -10,6 +10,7 @@ import { useGetCurriculumByIdQuery } from '@/features/resource/curriculum/api/cu import SEmpty from '@/components/shared/empty/SEmpty' import LoadingComponent from '@/components/shared/loading/LoadingComponent' import KitListSection from '@/features/resource/kit/components/list/KitListSection' +import AdminCurriculumEmulatorList from '@/features/resource/curriculum/components/list/AdminCurriculumEmulatorList' export default function AdminCurriculumDetail() { const { curriculumId } = useParams() @@ -45,6 +46,8 @@ export default function AdminCurriculumDetail() {
+
+ )}
diff --git a/src/features/resource/curriculum/components/list/AdminCurriculumEmulatorList.tsx b/src/features/resource/curriculum/components/list/AdminCurriculumEmulatorList.tsx new file mode 100644 index 00000000..7167cbb5 --- /dev/null +++ b/src/features/resource/curriculum/components/list/AdminCurriculumEmulatorList.tsx @@ -0,0 +1,99 @@ +import { Button } from '@/components/shadcn/button' +import { DataTable } from '@/components/shared/data-table/data-table' +import { EmulatorWithThumbnail } from '@/features/emulator/types/emulator.type' +import { setPageSize } from '@/features/resource/course/slice/courseSlice' +import { useUpdateCourseOrderMutation } from '@/features/resource/curriculum/api/curriculumApi' +import { useGetEmulatorColumn } from '@/features/resource/curriculum/components/list/EmulatorColum' +import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' +import { useModal } from '@/providers/ModalProvider' +import { Plus } from 'lucide-react' +import { useTranslations } from 'next-intl' +import React, { useEffect } from 'react' +import { toast } from 'sonner' + +type AdminCurriculumEmulatorListProps = { + curriculumId: number + emulations?: EmulatorWithThumbnail[] +} + +export default function AdminCurriculumEmulatorList({ curriculumId, emulations }: AdminCurriculumEmulatorListProps) { + const t = useTranslations('curriculum') + const tc = useTranslations('common') + const tt = useTranslations('toast') + + const rows = React.useMemo( + () => + (emulations ?? []).map((item, idx) => ({ + id: item.emulationId, + ...item + })), + [emulations] + ) + + const { selectedOrganizationId } = useAppSelector((state) => state.selectedOrganization) + const dispatch = useAppDispatch() + const { openModal } = useModal() + const columns = useGetEmulatorColumn() + + const [orderedCourseIds, setOrderedCourseIds] = React.useState([]) + + const visibleKeys = ['select', 'name', 'thumbnailUrl', 'actions'] + const filteredColumns = columns.filter((col) => { + const key = 'accessorKey' in col ? col.accessorKey : col.id + return key ? visibleKeys.includes(key as string) : false + }) + + useEffect(() => { + dispatch(setPageSize(50)) + }, [dispatch]) + + const [updateCourseOrder] = useUpdateCourseOrderMutation() + + const handleSaveOrder = async () => { + try { + await updateCourseOrder({ + curriculumId, + orderedCourseIds + }).unwrap() + toast.success(tt('successMessage.saveOrder')) + setOrderedCourseIds([]) + } catch (e) { + toast.error(tt('errorMessage')) + } + } + + return ( +
+
+

+ {t('list.emulatorListTitle')}{' '} + {emulations?.length} +

+ {!selectedOrganizationId && ( +
+ {orderedCourseIds.length > 0 && ( + + )} + + +
+ )} +
+ + +
+ ) +} diff --git a/src/features/resource/curriculum/components/list/EmulatorColum.tsx b/src/features/resource/curriculum/components/list/EmulatorColum.tsx new file mode 100644 index 00000000..bc43f76a --- /dev/null +++ b/src/features/resource/curriculum/components/list/EmulatorColum.tsx @@ -0,0 +1,70 @@ +'use client' +import React from 'react' +import { useTranslations } from 'next-intl' +import { ColumnDef } from '@tanstack/react-table' +import { useParams } from 'next/navigation' +import { useModal } from '@/providers/ModalProvider' +import { toast } from 'sonner' +import { createActionsColumnFromItems, createSelectColumn } from '@/components/shared/data-table/columns-helpers' +import Image from 'next/image' +import { useDeleteEmulationFromCurriculumMutation } from '@/features/resource/curriculum/api/curriculumApi' +import { EmulatorWithThumbnail } from '@/features/emulator/types/emulator.type' + +export function useGetEmulatorColumn(): ColumnDef[] { + const tc = useTranslations('common') + const tt = useTranslations('toast') + const tm = useTranslations('message') + const { curriculumId } = useParams() + const { openModal } = useModal() + + const [removeEmulatorFromCurriculum] = useDeleteEmulationFromCurriculumMutation() + + const handleRemoveEmulator = async (emulationIds: string[]) => { + try { + await removeEmulatorFromCurriculum({ curriculumId: Number(curriculumId!), emulationIds }).unwrap() + toast.success(tt('successMessage.removeEmulatorFromCurriculum')) + } catch (error) { + toast.error(tt('errorMessage')) + } + } + + return [ + createSelectColumn(), + { + accessorKey: 'name', + header: tc('tableHeader.name'), + enableSorting: true, + cell: ({ row }) => row.original.name + }, + { + accessorKey: 'thumbnailUrl', + header: () =>
{tc('tableHeader.image')}
, + cell: ({ row }) => { + const src = row.original.thumbnailUrl + return ( +
+ {src ? ( + preview + ) : ( +
{tc('noImage')}
+ )} +
+ ) + } + }, + createActionsColumnFromItems([ + { + label: tc('button.remove'), + danger: true, + hidden: () => curriculumId === undefined, + onClick: async ({ original }) => { + // Open the confirmation modal for removing emulator from curriculum + openModal('confirm', { + message: tt('confirmMessage.removeEmulator', { title: original.name }), + onConfirm: () => handleRemoveEmulator([original.emulationId]) + }) + } + } + ]) + ] +} diff --git a/src/features/resource/curriculum/components/list/AdminCurriculumSelectCourseList.tsx b/src/features/resource/curriculum/components/modal/AdminCurriculumSelectCourseList.tsx similarity index 100% rename from src/features/resource/curriculum/components/list/AdminCurriculumSelectCourseList.tsx rename to src/features/resource/curriculum/components/modal/AdminCurriculumSelectCourseList.tsx diff --git a/src/features/resource/curriculum/components/modal/AdminCurriculumSelectEmulatorList.tsx b/src/features/resource/curriculum/components/modal/AdminCurriculumSelectEmulatorList.tsx new file mode 100644 index 00000000..d8a5296a --- /dev/null +++ b/src/features/resource/curriculum/components/modal/AdminCurriculumSelectEmulatorList.tsx @@ -0,0 +1,121 @@ +import { Badge } from '@/components/shadcn/badge' +import { Button } from '@/components/shadcn/button' +import { DataTable } from '@/components/shared/data-table/data-table' +import SearchBar from '@/components/shared/search/SearchBar' +import { useSearchEmulationsQuery } from '@/features/emulator/api/emulatorApi' +import { useAddEmulationToCurriculumMutation } from '@/features/resource/curriculum/api/curriculumApi' +import { useGetEmulatorColumn } from '@/features/resource/curriculum/components/list/EmulatorColum' +import { useModal } from '@/providers/ModalProvider' +import { useTranslations } from 'next-intl' +import React, { useState } from 'react' +import { toast } from 'sonner' + +type AdminCurriculumSelectEmulatorListProps = { + curriculumId: number + onSuccess?: () => void + emulatorIds?: string[] +} + +export default function AdminCurriculumSelectEmulatorList({ + curriculumId, + onSuccess, + emulatorIds +}: AdminCurriculumSelectEmulatorListProps) { + const [searchTerm, setSearchTerm] = useState('') + const [pageNumber, setPageNumber] = useState(1) + const [selectedIds, setSelectedIds] = useState([]) + + const t = useTranslations('curriculum') + const tc = useTranslations('common') + const tt = useTranslations('toast') + const { closeModal } = useModal() + const columns = useGetEmulatorColumn() + const visibleKeys = ['select', 'name', 'thumbnailUrl'] + const filteredColumns = columns.filter((col) => + 'accessorKey' in col ? visibleKeys.includes(col.accessorKey as string) : visibleKeys.includes(col.id ?? '') + ) + const extendedColumns = [ + ...filteredColumns, + { + id: 'selectedStatus', + header: '', + cell: ({ row }: any) => { + const id = row.original.id + if (emulatorIds?.includes(id)) { + return ( + + {tc('badge.selected')} + + ) + } + return null + } + } + ] + + const queryParams = { + search: searchTerm, + page: pageNumber, + limit: 6 + } + + const { data } = useSearchEmulationsQuery(queryParams) + const [addEmulationToCurriculum] = useAddEmulationToCurriculumMutation() + const rows = React.useMemo( + () => + (data?.data.items ?? []).map((item) => ({ + id: item.emulationId, + ...item + })), + [data] + ) + const handlePageChange = (newPage: number) => { + setPageNumber(newPage) + } + + const handleAddEmulatorsToCurriculum = async (emulatorIds: string[]) => { + await addEmulationToCurriculum({ curriculumId, emulationIds: emulatorIds }) + toast.success(tt('successMessage.addToCurriculum')) + onSuccess?.() + } + + if (!data) return null + return ( +
+
+ setSearchTerm(value)} + /> + +
+ + {t('custom.selectedEmulators')}: {selectedIds.length} + +
+ + +
+
+
+ { + setSelectedIds(ids.map((id) => String(id))) + }} + disabledRowIds={emulatorIds} + /> +
+ ) +} diff --git a/src/features/resource/curriculum/components/list/CurriculumSelectCourseListModal.tsx b/src/features/resource/curriculum/components/modal/CurriculumSelectCourseListModal.tsx similarity index 94% rename from src/features/resource/curriculum/components/list/CurriculumSelectCourseListModal.tsx rename to src/features/resource/curriculum/components/modal/CurriculumSelectCourseListModal.tsx index b7d36d6a..70dcbac0 100644 --- a/src/features/resource/curriculum/components/list/CurriculumSelectCourseListModal.tsx +++ b/src/features/resource/curriculum/components/modal/CurriculumSelectCourseListModal.tsx @@ -1,7 +1,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/shadcn/dialog' import { useModal } from '@/providers/ModalProvider' import React from 'react' -import AdminCurriculumSelectCourseList from '@/features/resource/curriculum/components/list/AdminCurriculumSelectCourseList' +import AdminCurriculumSelectCourseList from '@/features/resource/curriculum/components/modal/AdminCurriculumSelectCourseList' import { useTranslations } from 'next-intl' interface CourseListModalProps { diff --git a/src/features/resource/curriculum/components/modal/CurriculumSelectEmulatorListModal.tsx b/src/features/resource/curriculum/components/modal/CurriculumSelectEmulatorListModal.tsx new file mode 100644 index 00000000..ce06e6c7 --- /dev/null +++ b/src/features/resource/curriculum/components/modal/CurriculumSelectEmulatorListModal.tsx @@ -0,0 +1,37 @@ +import { Dialog, DialogContent, DialogTitle } from '@/components/shadcn/dialog' +import { useModal } from '@/providers/ModalProvider' +import React from 'react' +import { useTranslations } from 'next-intl' +import CurriculumSelectEmulatorList from '@/features/resource/curriculum/components/modal/AdminCurriculumSelectEmulatorList' + +interface CurriculumSelectEmulatorListModalProps { + curriculumId: number + onConfirm?: () => void + emulatorIds?: string[] +} + +export default function CurriculumSelectEmulatorListModal({ + curriculumId, + onConfirm, + emulatorIds +}: CurriculumSelectEmulatorListModalProps) { + const t = useTranslations('curriculum') + const { closeModal } = useModal() + + const handleSuccess = () => { + if (typeof onConfirm === 'function') { + onConfirm() + } + closeModal() + } + + return ( + + + {t('custom.emulatorListTitle')} + + + + + ) +} diff --git a/src/features/resource/curriculum/types/curriculum.type.ts b/src/features/resource/curriculum/types/curriculum.type.ts index f1e95cfa..92c5e8e2 100644 --- a/src/features/resource/curriculum/types/curriculum.type.ts +++ b/src/features/resource/curriculum/types/curriculum.type.ts @@ -1,6 +1,7 @@ import { SearchPaginatedRequestParams } from '@/types/baseModel' import { Course } from '../../course/types/course.type' import { SliceQueryParams } from '@/libs/redux/createQuerySlice' +import { EmulatorWithThumbnail } from '@/features/emulator/types/emulator.type' export type Curriculum = { id: number @@ -20,6 +21,7 @@ export type Curriculum = { kitIds?: number[] price: number learningOutcomes: string[] + emulations: EmulatorWithThumbnail[] } export type CurriculumSliceParams = { diff --git a/src/features/resource/lesson/components/pacing-guide/GuideLessonDetails.tsx b/src/features/resource/lesson/components/pacing-guide/GuideLessonDetails.tsx index f3a111ec..a3c5f7c8 100644 --- a/src/features/resource/lesson/components/pacing-guide/GuideLessonDetails.tsx +++ b/src/features/resource/lesson/components/pacing-guide/GuideLessonDetails.tsx @@ -10,7 +10,7 @@ import { Clock, SquarePen, Trash2 } from 'lucide-react' import { useModal } from '@/providers/ModalProvider' import { Badge } from '@/components/shadcn/badge' import { getStatusBadgeClass } from '@/utils/badgeColor' -import { capitalizeFirst } from '@/utils/index' +import { capitalizeFirst, useStatusTranslation } from '@/utils/index' import { SCard } from '@/components/shared/card/SCard' import Image from 'next/image' @@ -25,6 +25,7 @@ export default function GuideLessonDetails({ lesson }: GuideLessonDetailsProps) const tt = useTranslations('toast') const role = useAppSelector((state) => state.auth.user?.userRole) const user = useAppSelector((state) => state.auth.user) + const statusTranslation = useStatusTranslation() const [updateLesson] = useUpdateLessonMutation() const [deleteLesson] = useDeleteLessonMutation() @@ -70,10 +71,9 @@ export default function GuideLessonDetails({ lesson }: GuideLessonDetailsProps)
-

- By {lesson.createdByUserName || 'STEMify'} -

- {capitalizeFirst(lesson.status)} + + {capitalizeFirst(statusTranslation(lesson.status))} + {tc('unit.age')} {lesson.ageRangeLabel} diff --git a/src/providers/ModalProvider.tsx b/src/providers/ModalProvider.tsx index 6cdf9e62..beea344e 100644 --- a/src/providers/ModalProvider.tsx +++ b/src/providers/ModalProvider.tsx @@ -18,7 +18,7 @@ import LessonDetailModal from '@/features/resource/lesson/components/detail/Less import UpsertUserModal from '@/components/shared/modals/UpsertUserModal' import UpsertLearningOutcomeModal from '@/features/resource/learning-outcome/components/upsert/UpsertLearningOutcomeModal' import UpsertCurriculumModal from '@/features/resource/curriculum/components/upsert/UpsertCurriculumModal' -import CurriculumSelectCourseListModal from '@/features/resource/curriculum/components/list/CurriculumSelectCourseListModal' +import CurriculumSelectCourseListModal from '@/features/resource/curriculum/components/modal/CurriculumSelectCourseListModal' import UpsertCourseModal from '@/features/resource/course/components/modal/UpsertCourseModal' import ContentDetailModal from '@/features/resource/content/components/detail/ContentDetailModal' import UpsertContentModal from '@/features/resource/content/components/upsert/UpsertContentModal' @@ -48,6 +48,7 @@ import UpdateGroupModal from '@/features/group/components/modal/UpdateGroupModal import CreateQuizModal from '@/features/resource/quiz/components/modal/CreateQuizModal' import { UpsertStudentGroup } from '@/features/group/components/upsert/UpsertStudentGroup' import AddStudentToGroupModal from '@/features/group/components/upsert/AddStudentToGroupModal' +import CurriculumSelectEmulatorListModal from '@/features/resource/curriculum/components/modal/CurriculumSelectEmulatorListModal' const ModalContext = createContext({ openModal: () => {}, closeModal: () => {}, @@ -118,6 +119,7 @@ export const ModalProvider = ({ children }: { children: React.ReactNode }) => { {/* other */} {modalType === 'pacingGuide' && } {modalType === 'curriculumSelectCourseListModal' && } + {modalType === 'curriculumSelectEmulatorListModal' && } {modalType === 'kitListTableModal' && } {modalType === 'selectComponentListModal' && } {modalType === 'quizAI' && } diff --git a/src/types/general.ts b/src/types/general.ts index 43fd8b06..acec7d8b 100644 --- a/src/types/general.ts +++ b/src/types/general.ts @@ -56,6 +56,7 @@ export type ModalType = // orther | 'pacingGuide' | 'curriculumSelectCourseListModal' + | 'curriculumSelectEmulatorListModal' | 'kitListTableModal' | 'selectComponentListModal' | 'upsertAssembly' From b6178d7ba15aea93e94fc28e485c3668ee80f243 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 14 Dec 2025 20:05:15 +0700 Subject: [PATCH 08/36] feat: Restore CertificatePage component with SpecificCertificatePage integration --- .../(protected)/{ => verify}/[verificationCode]/page.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/app/[locale]/certificate/(protected)/{ => verify}/[verificationCode]/page.tsx (100%) diff --git a/src/app/[locale]/certificate/(protected)/[verificationCode]/page.tsx b/src/app/[locale]/certificate/(protected)/verify/[verificationCode]/page.tsx similarity index 100% rename from src/app/[locale]/certificate/(protected)/[verificationCode]/page.tsx rename to src/app/[locale]/certificate/(protected)/verify/[verificationCode]/page.tsx From f90a12fe27ffdc72dc72ce66eab6ba8bf72f33e1 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 14 Dec 2025 21:33:19 +0700 Subject: [PATCH 09/36] feat: Add CurriculumEmulatorSection component and integrate emulator list with descriptions in both English and Vietnamese --- messages/en/curriculum/en_curriculum.json | 3 +- messages/vi/curriculum/vi_curriculum.json | 3 +- .../components/detail/CurriculumDetail.tsx | 4 ++ .../detail/CurriculumEmulatorSection.tsx | 52 +++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/features/resource/curriculum/components/detail/CurriculumEmulatorSection.tsx diff --git a/messages/en/curriculum/en_curriculum.json b/messages/en/curriculum/en_curriculum.json index 40361a02..827f3027 100644 --- a/messages/en/curriculum/en_curriculum.json +++ b/messages/en/curriculum/en_curriculum.json @@ -63,6 +63,7 @@ "selectedCourses": "Selected courses", "searchCoursePlaceholder": "Search courses by code or title...", "emulatorListTitle": "Emulator List", + "emulatorListDescription": "Emulators provide a virtual environment that mimics the behavior of physical devices, allowing users to test and run software applications without the need for actual hardware. They are commonly used in software development, testing, and debugging processes, enabling developers to simulate different device configurations and operating systems.", "selectEmulationTitle": "Select Emulators", "selectedEmulators": "Selected emulators", "searchEmulatorPlaceholder": "Search emulators here...", @@ -72,4 +73,4 @@ "reviewMessage": "Curriculum submission is under review." } } -} \ No newline at end of file +} diff --git a/messages/vi/curriculum/vi_curriculum.json b/messages/vi/curriculum/vi_curriculum.json index e1547c5b..1e6aba30 100644 --- a/messages/vi/curriculum/vi_curriculum.json +++ b/messages/vi/curriculum/vi_curriculum.json @@ -63,6 +63,7 @@ "selectedCourses": "Khóa học đã chọn", "searchCoursePlaceholder": "Tìm kiếm khóa học theo mã hoặc tiêu đề...", "emulatorListTitle": "Danh Sách Mô Hình", + "emulatorListDescription": "Mô hình cung cấp một môi trường ảo mô phỏng hành vi của các thiết bị vật lý, cho phép người dùng thử nghiệm và chạy các ứng dụng phần mềm mà không cần phần cứng thực tế. Chúng thường được sử dụng trong quá trình phát triển phần mềm, kiểm thử và gỡ lỗi, cho phép các nhà phát triển mô phỏng các cấu hình thiết bị và hệ điều hành khác nhau.", "selectEmulationTitle": "Chọn Mô Hình", "selectedEmulators": "Mô hình đã chọn", "searchEmulatorPlaceholder": "Tìm kiếm mô hình ở đây...", @@ -72,4 +73,4 @@ "reviewMessage": "Khung chương trình đang đợi quản trị viên phê duyệt." } } -} \ No newline at end of file +} diff --git a/src/features/resource/curriculum/components/detail/CurriculumDetail.tsx b/src/features/resource/curriculum/components/detail/CurriculumDetail.tsx index e4b263eb..9a232a69 100644 --- a/src/features/resource/curriculum/components/detail/CurriculumDetail.tsx +++ b/src/features/resource/curriculum/components/detail/CurriculumDetail.tsx @@ -17,6 +17,7 @@ import LearningObjectives from '../../../../../components/shared/outcome/Learnin import { useSearchLearningOutcomeQuery } from '@/features/resource/learning-outcome/api/learningOutcomeApi' import SEmpty from '@/components/shared/empty/SEmpty' import { useTranslations } from 'next-intl' +import CurriculumEmulatorSection from '@/features/resource/curriculum/components/detail/CurriculumEmulatorSection' export default function CurriculumDetail() { const { curriculumId } = useParams() @@ -64,6 +65,9 @@ export default function CurriculumDetail() {
+
+ +
{/* Kit Information Section */}
diff --git a/src/features/resource/curriculum/components/detail/CurriculumEmulatorSection.tsx b/src/features/resource/curriculum/components/detail/CurriculumEmulatorSection.tsx new file mode 100644 index 00000000..b683d452 --- /dev/null +++ b/src/features/resource/curriculum/components/detail/CurriculumEmulatorSection.tsx @@ -0,0 +1,52 @@ +'use client' +import CardLayout from '@/components/shared/card/CardLayout' +import { SCarousel } from '@/components/shared/SCarousel' +import { EmulatorWithThumbnail } from '@/features/emulator/types/emulator.type' +import { Course } from '@/features/resource/course/types/course.type' +import { useTranslations } from 'next-intl' +import { useRouter } from 'next/navigation' +import React from 'react' + +type CurriculumEmulatorSectionProps = { + emulations: EmulatorWithThumbnail[] +} +export default function CurriculumEmulatorSection({ emulations }: CurriculumEmulatorSectionProps) { + const t = useTranslations('curriculum') + const router = useRouter() + return ( +
+
+

{t('custom.emulatorListTitle')}

+

{t('custom.emulatorListDescription')}

+
+ +
+ ( +
+ router.push(`/lab/straw-lib/${emulation.emulationId}`)} + imageSrc={emulation.thumbnailUrl ?? 'images/fallback.png'} + className='rounded-3xl' + > +
+
+

+ {t('custom.courseTag').toLocaleUpperCase()} +

+

{emulation.name}

+

{emulation.description || ''}

+
+
+
+
+ ))} + /> +
+
+ ) +} From 663b38300275abeaf969b970867f27ef718d2dab Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 15 Dec 2025 20:33:08 +0700 Subject: [PATCH 10/36] feat: Implement stringToHslColor utility and update user avatar rendering across components --- .../layout/admin/sidebar/nav-user.tsx | 25 +++++++++++----- .../header/header-action/AuthStatusMenu.tsx | 11 +++++-- .../list/licenseAssignmentColumnTable.tsx | 29 +++++++------------ .../components/list/licenseAssignmentList.tsx | 1 + .../OrganizationSubscriptionColumnTable.tsx | 6 ++-- src/utils/index.ts | 14 +++++++++ 6 files changed, 56 insertions(+), 30 deletions(-) diff --git a/src/components/layout/admin/sidebar/nav-user.tsx b/src/components/layout/admin/sidebar/nav-user.tsx index bf300191..6394c09b 100644 --- a/src/components/layout/admin/sidebar/nav-user.tsx +++ b/src/components/layout/admin/sidebar/nav-user.tsx @@ -23,6 +23,7 @@ import { logout } from '@/features/auth/authSlice' import { clearSelectedOrganization } from '@/features/subscription/slice/selectedOrganizationSlice' import { persistor } from '@/libs/redux/store' import { useRouter } from 'next/navigation' +import { stringToHslColor } from '@/utils/index' export function NavUser({ user }: { @@ -66,10 +67,14 @@ export function NavUser({ size='lg' className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground' > - - - CN - +
+ {user?.name?.charAt(0).toUpperCase()} +
{user?.name} {user?.email} @@ -85,10 +90,14 @@ export function NavUser({ >
- - - CN - +
+ {user?.name?.charAt(0).toUpperCase()} +
{user?.name} {user?.email} diff --git a/src/components/layout/header/header-action/AuthStatusMenu.tsx b/src/components/layout/header/header-action/AuthStatusMenu.tsx index 17a58c34..13674ebb 100644 --- a/src/components/layout/header/header-action/AuthStatusMenu.tsx +++ b/src/components/layout/header/header-action/AuthStatusMenu.tsx @@ -27,6 +27,7 @@ import { useAppDispatch } from '@/hooks/redux-hooks' import { logout } from '@/features/auth/authSlice' import { persistor } from '@/libs/redux/store' import { clearSelectedOrganization } from '@/features/subscription/slice/selectedOrganizationSlice' +import { stringToHslColor } from '@/utils/index' function MenuItem({ children, @@ -93,6 +94,7 @@ export default function AuthStatusMenu() { console.error('Logout failed:', error) } } + return (
{isAuth ? ( @@ -109,8 +111,13 @@ export default function AuthStatusMenu() { - +
+ {session?.user?.name?.charAt(0).toUpperCase()}
} children={ diff --git a/src/features/license-assignment/components/list/licenseAssignmentColumnTable.tsx b/src/features/license-assignment/components/list/licenseAssignmentColumnTable.tsx index 545c94fa..b2b86a4f 100644 --- a/src/features/license-assignment/components/list/licenseAssignmentColumnTable.tsx +++ b/src/features/license-assignment/components/list/licenseAssignmentColumnTable.tsx @@ -8,12 +8,13 @@ import { Badge } from '@/components/shadcn/badge' import { getStatusBadgeClass } from '@/utils/badgeColor' import { CheckCircle } from 'lucide-react' import { LicenseAssignment } from '@/features/license-assignment/types/licenseAssignment' -import { formatDate } from '@/utils/index' +import { formatDate, stringToHslColor, useStatusTranslation } from '@/utils/index' import Image from 'next/image' export function useGetLicenseAssignmentColumnTable(): ColumnDef[] { const { openModal } = useModal() const tc = useTranslations('common') + const statusTranslations = useStatusTranslation() return [ createSelectColumn(), @@ -21,23 +22,15 @@ export function useGetLicenseAssignmentColumnTable(): ColumnDef { - const src = row.original.user.imageUrl - const alt = row.original.user.name.charAt(0) + const alt = row.original.user.name return ( -
- {src ? ( - preview - ) : ( -
- {alt} -
- )} +
+ {alt.charAt(0).toUpperCase()}
) } @@ -56,7 +49,7 @@ export function useGetLicenseAssignmentColumnTable(): ColumnDef { const value = row.original.status.toString() const badgeValue = value.toLocaleUpperCase() as LicenseAssignment['status'] - return {value} + return {statusTranslations(value)} } }, { diff --git a/src/features/license-assignment/components/list/licenseAssignmentList.tsx b/src/features/license-assignment/components/list/licenseAssignmentList.tsx index 4e50f041..1a78f4b9 100644 --- a/src/features/license-assignment/components/list/licenseAssignmentList.tsx +++ b/src/features/license-assignment/components/list/licenseAssignmentList.tsx @@ -15,6 +15,7 @@ import { LicenseAssignmentStatus, LicenseAssignmentType } from '@/features/licen import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' import useDebounce from '@/hooks/useDebounce' import { useModal } from '@/providers/ModalProvider' +import { useStatusTranslation } from '@/utils/index' import { cn } from '@/utils/shadcn/utils' import { or } from 'ajv/dist/compile/codegen' import { CheckCircle, UserPlus } from 'lucide-react' diff --git a/src/features/subscription/components/list/OrganizationSubscriptionColumnTable.tsx b/src/features/subscription/components/list/OrganizationSubscriptionColumnTable.tsx index 7a8cfd03..d6aab44d 100644 --- a/src/features/subscription/components/list/OrganizationSubscriptionColumnTable.tsx +++ b/src/features/subscription/components/list/OrganizationSubscriptionColumnTable.tsx @@ -9,10 +9,12 @@ import { Badge } from '@/components/shadcn/badge' import { OrganizationSubscription, SubscriptionStatus } from '@/features/subscription/types/subscription.type' import { useRouter } from 'next/navigation' import { Sparkles, Users } from 'lucide-react' +import { useStatusTranslation } from '@/utils/index' export function useGetOrganizationSubscriptionColumns(): ColumnDef[] { const t = useTranslations('subscription.list') const tc = useTranslations('common') + const statusTranslations = useStatusTranslation() const router = useRouter() @@ -44,8 +46,8 @@ export function useGetOrganizationSubscriptionColumns(): ColumnDef { - const value = row.getValue('status') - return {value} + const value = row.original.status + return {statusTranslations(value)} } }, { diff --git a/src/utils/index.ts b/src/utils/index.ts index a9119800..e19ed5cc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -251,3 +251,17 @@ export function getColorByInitial(initial: string) { const index = (initial.charCodeAt(0) - 65) % colors.length return colors[index] || 'bg-gray-500' } + +export function stringToHslColor(input: string, saturation = 55, lightness = 60) { + const str = input.toLowerCase().trim().replace(/\s+/g, ' ') + + if (!str) return `hsl(210, ${saturation}%, ${lightness}%)` + + let hash = 5381 + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) + hash + str.charCodeAt(i) + } + + const hue = hash % 360 + return `hsl(${hue}, ${saturation}%, ${lightness}%)` +} From 1ec4492be431fce535bc1181bd1a0665cb6aa6ca Mon Sep 17 00:00:00 2001 From: meewaldor Date: Mon, 15 Dec 2025 21:17:27 +0700 Subject: [PATCH 11/36] fix --- messages/en/common/en_toast.json | 3 +- messages/vi/common/vi_toast.json | 3 +- src/features/group/api/groupApi.ts | 4 +- .../group/components/detail/GroupColumn.tsx | 22 +++++---- .../components/list/UngroupedStudentList.tsx | 4 +- .../upsert/AddStudentToGroupModal.tsx | 47 ++++++++----------- .../group/components/upsert/StudentColumn.tsx | 44 +++++++++++------ src/features/user/api/userApi.ts | 2 +- 8 files changed, 71 insertions(+), 58 deletions(-) diff --git a/messages/en/common/en_toast.json b/messages/en/common/en_toast.json index a9b9de9d..2fe9f2a5 100644 --- a/messages/en/common/en_toast.json +++ b/messages/en/common/en_toast.json @@ -45,9 +45,10 @@ "delete": "Are you sure you want to delete \"{title}\"?", "archive": "Are you sure you want to archive \"{title}\"?", "removeCourse": "Are you sure you want to remove \"{title}\" from this curriculum?", - "removeEmulator": "Are you sure you want to remove \"{title}\" from this curriculum?", + "removeEmulator": "Are you sure you want to remove \"{title}\" from this curriculum?", "removeKit": "Are you sure you want to remove \"{title}\" from this course?", "removeComponent": "Are you sure you want to remove component from this kit?", + "removeStudentFromGroup": "Are you sure you want to remove this student from group?", "discard": "Are you sure you want to discard your changes?", "ask": "Are you sure to make ", "askStatus": "Are you sure to {action} \"{title}\"?", diff --git a/messages/vi/common/vi_toast.json b/messages/vi/common/vi_toast.json index b516f59e..5583f0f2 100644 --- a/messages/vi/common/vi_toast.json +++ b/messages/vi/common/vi_toast.json @@ -48,6 +48,7 @@ "removeEmulator": "Bạn có chắc chắn muốn xóa \"{title}\" khỏi chương trình học này không?", "removeKit": "Bạn có chắc chắn muốn xóa \"{title}\" khỏi khóa học này không?", "removeComponent": "Bạn có chắc chắn muốn xóa thành phần khỏi bộ dụng cụ này không?", + "removeStudentFromGroup": "Bạn có chắn chắn muốn xóa học sinh khỏi nhóm không?", "discard": "Bạn có chắc chắn muốn hủy bỏ các thay đổi của mình không?", "ask": "Bạn có chắc chắn muốn thực hiện ", "askStatus": "Bạn có chắc chắn muốn {action} \"{title}\" không?", @@ -60,4 +61,4 @@ }, "unauthorized": "Truy cập không được phép." } -} \ No newline at end of file +} diff --git a/src/features/group/api/groupApi.ts b/src/features/group/api/groupApi.ts index f87d37bb..4233b5ce 100644 --- a/src/features/group/api/groupApi.ts +++ b/src/features/group/api/groupApi.ts @@ -29,7 +29,7 @@ export const groupApi = createCrudApi({ method: 'POST', body: { studentIds } }), - invalidatesTags: ['Group'] + invalidatesTags: ['Group', 'OrganizationUser'] }), removeStudentFromGroup: builder.mutation< @@ -41,7 +41,7 @@ export const groupApi = createCrudApi({ method: 'DELETE', body: { studentIds } }), - invalidatesTags: ['Group'] + invalidatesTags: ['Group', 'OrganizationUser'] }) }) }) diff --git a/src/features/group/components/detail/GroupColumn.tsx b/src/features/group/components/detail/GroupColumn.tsx index 73257f06..65559177 100644 --- a/src/features/group/components/detail/GroupColumn.tsx +++ b/src/features/group/components/detail/GroupColumn.tsx @@ -4,22 +4,24 @@ 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 { useRemoveStudentFromGroupMutation } from '@/features/group/api/groupApi' import { Badge } from '@/components/shadcn/badge' import { Avatar, AvatarFallback } from '@/components/shadcn/avatar' import { formatDate, useOrgUserStatusTranslation } from '@/utils/index' +import { useParams } from 'next/navigation' export function useGetGroupColumn(): ColumnDef[] { const { openModal } = useModal() const locale = useLocale() - const [deleteGroup] = useDeleteGroupMutation() + const [removeStudentFromGroup] = useRemoveStudentFromGroupMutation() const tc = useTranslations('common') const tt = useTranslations('toast') const orgUserStatusTranslation = useOrgUserStatusTranslation() + const { groupId } = useParams() - const handleDelete = async (id: string) => { + const handleDelete = async (studentId: string) => { try { - await deleteGroup(id).unwrap() + await removeStudentFromGroup({ groupId: Number(groupId), studentIds: [studentId] }).unwrap() toast.success(tt('successMessage.delete')) } catch (error) { toast.error(tt('errorMessage')) @@ -71,11 +73,11 @@ export function useGetGroupColumn(): ColumnDef[] { ) }, - { - accessorKey: 'subscriptionOrderId', - header: tc('tableHeader.subscription'), - cell: ({ row }) =>
#{row.original.subscriptionOrderId}
- }, + // { + // accessorKey: 'subscriptionOrderId', + // header: tc('tableHeader.subscription'), + // cell: ({ row }) =>
#{row.original.subscriptionOrderId}
+ // }, { accessorKey: 'joinedAt', header: tc('tableHeader.joinedAt'), @@ -99,7 +101,7 @@ export function useGetGroupColumn(): ColumnDef[] { danger: true, onClick: async ({ original }) => { openModal('confirm', { - message: `${tt('confirmMessage.remove', { title: original.fullName })}`, + message: `${tt('confirmMessage.removeStudentFromGroup', { title: original.fullName })}`, onConfirm: () => handleDelete(original.organizationUserId) }) } diff --git a/src/features/group/components/list/UngroupedStudentList.tsx b/src/features/group/components/list/UngroupedStudentList.tsx index da94d6a6..69c02e0c 100644 --- a/src/features/group/components/list/UngroupedStudentList.tsx +++ b/src/features/group/components/list/UngroupedStudentList.tsx @@ -45,13 +45,13 @@ export function UngroupedStudentList({ students, onCreateGroup, onSearchChange }
{/* HEADER */}
-

Students Without Group

+

Học sinh chưa có nhóm

diff --git a/src/features/group/components/upsert/AddStudentToGroupModal.tsx b/src/features/group/components/upsert/AddStudentToGroupModal.tsx index ad4dba18..53add52a 100644 --- a/src/features/group/components/upsert/AddStudentToGroupModal.tsx +++ b/src/features/group/components/upsert/AddStudentToGroupModal.tsx @@ -2,53 +2,46 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/shadcn/dialog' import { DataTable } from '@/components/shared/data-table/data-table' import { useAddStudentToGroupMutation } from '@/features/group/api/groupApi' import StudentColumn from '@/features/group/components/upsert/StudentColumn' +import { useGetOrganizationUserQuery } from '@/features/user/api/userApi' +import { useAppSelector } from '@/hooks/redux-hooks' import { useModal } from '@/providers/ModalProvider' +import { LicenseType } from '@/types/userRole' import { useParams } from 'next/navigation' -import React, { useState } from 'react' - -type Student = { - id: string - name: string - email: string - avatar: string -} - -// id is guid type -const MOCK_STUDENTS: Student[] = [ - { id: 'b3b4f7e2-5f92-4c44-9c8a-42f7c15af101', name: 'Nguyễn Văn A', email: 'nguyenvana@example.com', avatar: '' }, - { id: 'e8c1cb2a-0c3b-4db2-9fa7-cb0abf9ad3c2', name: 'Trần Thị B', email: 'tranthib@example.com', avatar: '' }, - { id: '9f37d6d8-0b15-4a19-9e6f-4d40f1a8f93f', name: 'Lê Văn C', email: 'levanc@example.com', avatar: '' }, - { id: 'c5a3ef0e-7de1-442c-b7c7-9f0e239aeba4', name: 'Phạm Thị D', email: 'phamthid@example.com', avatar: '' }, - { id: 'f4c9829e-0f3e-41f9-9c53-6d3b2897b51a', name: 'Hoàng Văn E', email: 'hoangvane@example.com', avatar: '' }, - { id: 'd1bffec6-0bc0-4d22-b5b7-fac2e57c3b0a', name: 'Vũ Thị F', email: 'vuthif@example.com', avatar: '' }, - { id: 'a8b72631-6c46-4f67-8c73-0c9e3e35a9fd', name: 'Đặng Văn G', email: 'dangvang@example.com', avatar: '' }, - { id: '7c3e0a4d-2e0c-4e66-9e12-712a1c4f2eb4', name: 'Bùi Thị H', email: 'buithih@example.com', avatar: '' }, - { id: 'fddc6f8e-0d4c-4777-8dbb-0f1e3af1a21e', name: 'Đỗ Văn I', email: 'dovani@example.com', avatar: '' }, - { id: '4d96b7e2-5a28-42b2-a28a-8fd1a0c3f6df', name: 'Ngô Thị K', email: 'ngothik@example.com', avatar: '' } -] +import React, { useMemo, useState } from 'react' +import { toast } from 'sonner' export default function AddStudentToGroupModal() { const [selectedStudentIds, setSelectedStudentIds] = useState([]) + const { selectedOrganizationId } = useAppSelector((state) => state.selectedOrganization) const { closeModal } = useModal() const { groupId } = useParams() const columns = StudentColumn() + const { data: ungroupedStudents } = useGetOrganizationUserQuery( + { organizationId: selectedOrganizationId!, pageNumber: 1, pageSize: 50, role: LicenseType.STUDENT }, + { skip: !selectedOrganizationId } + ) + const rows = useMemo( + () => ungroupedStudents?.data.items.map((student) => ({ ...student, id: student.organizationUserId })) ?? [], + [ungroupedStudents] + ) const [addStudents] = useAddStudentToGroupMutation() const handleAddStudents = () => { addStudents({ groupId: Number(groupId), studentIds: selectedStudentIds }) - // closeModal() + toast.success('Đã thêm học sinh vào nhóm thành công') + closeModal() } return ( - AddStudentToGroupModal + Thêm Học Sinh Vào Nhóm
- Add Selected Students + Thêm
diff --git a/src/features/group/components/upsert/StudentColumn.tsx b/src/features/group/components/upsert/StudentColumn.tsx index e569c915..4701816e 100644 --- a/src/features/group/components/upsert/StudentColumn.tsx +++ b/src/features/group/components/upsert/StudentColumn.tsx @@ -1,29 +1,45 @@ +import { Avatar, AvatarFallback } from '@/components/shadcn/avatar' import { createSelectColumn } from '@/components/shared/data-table/columns-helpers' +import { OrganizationUser } from '@/features/user/types/user.type' import { ColumnDef } from '@tanstack/react-table' import { useTranslations } from 'next-intl' -type Student = { - id: string - name: string - email: string - avatar: string -} +export default function StudentColumn(): ColumnDef[] { + const tc = useTranslations('common') -export default function StudentColumn(): ColumnDef[] { - const to = useTranslations('common.tableHeader') + const getInitials = (fullName: string) => { + return fullName + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2) + } return [ - createSelectColumn(), + createSelectColumn(), { - accessorKey: 'id', - header: 'ID' + accessorKey: 'imageUrl', + header: tc('tableHeader.image'), + cell: ({ row }) => { + const student = row.original + return ( +
+ + + {getInitials(student.fullName)} + + +
+ ) + } }, { - accessorKey: 'name', - header: to('name') + accessorKey: 'fullName', + header: tc('tableHeader.name') }, { accessorKey: 'email', - header: to('email') + header: tc('tableHeader.email') } ] } diff --git a/src/features/user/api/userApi.ts b/src/features/user/api/userApi.ts index 630c2621..ef249353 100644 --- a/src/features/user/api/userApi.ts +++ b/src/features/user/api/userApi.ts @@ -33,7 +33,7 @@ export const userApi = createCrudApi({ method: 'GET', params: { pageNumber, pageSize, role, email } }), - providesTags: ['User'] + providesTags: ['OrganizationUser'] }) }) }) From 6149c7924b0bd8676b55231b8a851daaa99d77c1 Mon Sep 17 00:00:00 2001 From: meewaldor Date: Mon, 15 Dec 2025 21:58:06 +0700 Subject: [PATCH 12/36] feat: add activate and deactivate functionality with corresponding confirmation messages in English and Vietnamese --- messages/en/common/en_common.json | 4 ++- messages/en/common/en_toast.json | 4 ++- messages/vi/common/vi_common.json | 4 ++- messages/vi/common/vi_toast.json | 4 ++- .../list/SystemOrganizationColumn.tsx | 28 +++++++++++++------ 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 1192a22e..d1a2d0c8 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -143,7 +143,9 @@ "uploadFile": "Upload File", "exportGLB": "Export GLB", "restore": "Restore", - "removeStudents": "Remove {student} Students" + "removeStudents": "Remove {student} Students", + "deactivate": "Deactivate", + "activate": "Activate" }, "message": { "courseCreateSuccess": "Course created successfully!", diff --git a/messages/en/common/en_toast.json b/messages/en/common/en_toast.json index 2fe9f2a5..240fb24f 100644 --- a/messages/en/common/en_toast.json +++ b/messages/en/common/en_toast.json @@ -58,7 +58,9 @@ "clearCart": "Are you sure you want to clear the cart?", "deleteUserEmail": "Are you sure you want to delete the user with email \"{title}\"?", "restore": "Are you sure you want to restore \"{title}\"?", - "cancelledSubscriptions": "Are you sure you want to cancel this subscription?" + "cancelledSubscriptions": "Are you sure you want to cancel this subscription?", + "deactivate": "Are you sure you want to deactivate \"{title}\"?", + "activate": "Are you sure you want to activate \"{title}\"?" }, "unauthorized": "Unauthorized access." } diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index 914fc969..e43f0ff8 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -143,7 +143,9 @@ "uploadFile": "Tải Lên Tệp", "exportGLB": "Xuất GLB", "restore": "Khôi Phục", - "removeStudents": "Xóa {student} Học Sinh" + "removeStudents": "Xóa {student} Học Sinh", + "deactivate": "Vô Hiệu Hóa", + "activate": "Kích Hoạt" }, "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 5583f0f2..40fdae12 100644 --- a/messages/vi/common/vi_toast.json +++ b/messages/vi/common/vi_toast.json @@ -57,7 +57,9 @@ "removeItemFromCart": "Bạn có chắc chắn muốn xóa sản phẩm này khỏi giỏ hàng không?", "clearCart": "Bạn có chắc chắn muốn xóa giỏ hàng không?", "restore": "Bạn có chắc chắn muốn khôi phục \"{title}\" không?", - "cancelledSubscriptions": "Bạn có chắc chắn muốn hủy đăng ký này không?" + "cancelledSubscriptions": "Bạn có chắc chắn muốn hủy đăng ký này không?", + "deactivate": "Bạn có chắc chắn muốn vô hiệu hóa \"{title}\" không?", + "activate": "Bạn có chắc chắn muốn kích hoạt \"{title}\" không?" }, "unauthorized": "Truy cập không được phép." } diff --git a/src/features/organization/components/list/SystemOrganizationColumn.tsx b/src/features/organization/components/list/SystemOrganizationColumn.tsx index 962c2746..437a3659 100644 --- a/src/features/organization/components/list/SystemOrganizationColumn.tsx +++ b/src/features/organization/components/list/SystemOrganizationColumn.tsx @@ -141,24 +141,34 @@ export function useGetOrganizationColumn(): ColumnDef[] { } }, { - label: tc('button.archive'), - archive: true, - hidden: ({ original }) => original.status === OrganizationStatus.ARCHIVED, + label: tc('button.activate'), + hidden: ({ original }) => original.status !== OrganizationStatus.INACTIVE, onClick: async ({ original }) => { openModal('confirm', { - message: tt('confirmMessage.archive', { title: original.name }), - onConfirm: () => handleArchive(original) + message: tt('confirmMessage.activate', { title: original.name }), + onConfirm: () => handleStatusChange(original, OrganizationStatus.ACTIVE) }) } }, { - label: tc('button.delete'), + label: tc('button.deactivate'), danger: true, - hidden: ({ original }) => original.status === OrganizationStatus.ARCHIVED, + hidden: ({ original }) => original.status !== OrganizationStatus.ACTIVE, + onClick: async ({ original }) => { + openModal('confirm', { + message: tt('confirmMessage.deactivate', { title: original.name }), + onConfirm: () => handleStatusChange(original, OrganizationStatus.INACTIVE) + }) + } + }, + { + label: tc('button.archive'), + archive: true, + hidden: ({ original }) => original.status !== OrganizationStatus.INACTIVE, onClick: async ({ original }) => { openModal('confirm', { - message: tt('confirmMessage.delete', { title: original.name }), - onConfirm: () => handleDelete(original.id) + message: tt('confirmMessage.archive', { title: original.name }), + onConfirm: () => handleArchive(original) }) } } From 425ed7a26043d0c27e4e51fb9ce0bfc3e6d880a6 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 15 Dec 2025 23:24:10 +0700 Subject: [PATCH 13/36] feat: Enhance user management with search and filter capabilities; update translations and user type definitions --- messages/en/common/en_common.json | 1 + messages/vi/common/vi_common.json | 2 +- .../components/user/OrganizationUserTable.tsx | 2 +- src/features/user/api/userApi.ts | 8 +-- .../table/UserOrganizationAction.tsx | 26 +++------ .../table/UserOrganizationTable.tsx | 56 ++++++++++++------- src/features/user/types/user.type.ts | 4 +- 7 files changed, 54 insertions(+), 45 deletions(-) diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index d1a2d0c8..2ac37704 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -305,6 +305,7 @@ "advanced": "Advanced" }, "accountType": { + "all": "All", "accountTypeLabel": "Account Type", "admin": "Admin", "guest": "Guest", diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index e43f0ff8..9b992462 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -303,8 +303,8 @@ "active": "Đã xác thực", "inactive": "Chưa xác thực" }, - "accountType": { + "all": "Tất Cả", "accountTypeLabel": "Loại Tài Khoản", "admin": "Quản Trị Viên", "guest": "Khách", diff --git a/src/features/organization/components/user/OrganizationUserTable.tsx b/src/features/organization/components/user/OrganizationUserTable.tsx index dc681a4d..e9f2cb46 100644 --- a/src/features/organization/components/user/OrganizationUserTable.tsx +++ b/src/features/organization/components/user/OrganizationUserTable.tsx @@ -45,7 +45,7 @@ export default function OrganizationUserTable() { pageNumber: userParams.pageNumber ?? 1, pageSize: userParams.pageSize ?? 10, role: selectedRole, - email: debouncedSearchTerm || undefined + search: debouncedSearchTerm || undefined } const { data, isLoading } = useGetOrganizationUserQuery(searchParams, { diff --git a/src/features/user/api/userApi.ts b/src/features/user/api/userApi.ts index ef249353..b51127b3 100644 --- a/src/features/user/api/userApi.ts +++ b/src/features/user/api/userApi.ts @@ -28,12 +28,12 @@ export const userApi = createCrudApi({ ApiSuccessResponse>, OrganizationUserQueryParams >({ - query: ({ organizationId, pageNumber, pageSize, role, email }) => ({ + query: ({ organizationId, pageNumber, pageSize, role, search, status }) => ({ url: `/organizations/${organizationId}/users`, method: 'GET', - params: { pageNumber, pageSize, role, email } - }), - providesTags: ['OrganizationUser'] + params: { pageNumber, pageSize, role, search, status } + }) + // providesTags: ['OrganizationUser'] }) }) }) diff --git a/src/features/user/components/table/UserOrganizationAction.tsx b/src/features/user/components/table/UserOrganizationAction.tsx index d411339b..71c8af03 100644 --- a/src/features/user/components/table/UserOrganizationAction.tsx +++ b/src/features/user/components/table/UserOrganizationAction.tsx @@ -10,7 +10,7 @@ import { User, UserStatus } from '@/features/user/types/user.type' import Image from 'next/image' import { Badge } from '@/components/shadcn/badge' import { getStatusBadgeClass } from '@/utils/badgeColor' -import { useOrgUserStatusTranslation, useStatusTranslation } from '@/utils/index' +import { stringToHslColor, useOrgUserStatusTranslation, useStatusTranslation } from '@/utils/index' export function useGetOrganizationUserAction(): ColumnDef[] { const { openModal } = useModal() @@ -41,23 +41,15 @@ export function useGetOrganizationUserAction(): ColumnDef[] { accessorKey: 'imageUrl', header: t('image'), cell: ({ row }) => { - const src = row.original.imageUrl - const alt = row.original.userName.charAt(0) + const alt = row.original.fullName return ( -
- {src ? ( - preview - ) : ( -
- {alt} -
- )} +
+ {alt?.charAt(0).toUpperCase()}
) } diff --git a/src/features/user/components/table/UserOrganizationTable.tsx b/src/features/user/components/table/UserOrganizationTable.tsx index ef80af41..1beaa150 100644 --- a/src/features/user/components/table/UserOrganizationTable.tsx +++ b/src/features/user/components/table/UserOrganizationTable.tsx @@ -11,7 +11,7 @@ import { setPageIndex, setParam } from '../../slice/userSlice' import useDebounce from '@/hooks/useDebounce' import { useSession } from 'next-auth/react' import SSelect from '@/components/shared/SSelect' -import { UserRole } from '@/types/userRole' +import { LicenseType, UserRole } from '@/types/userRole' import { useStatusTranslation } from '@/utils/index' import { useGetOrganizationUserAction } from '@/features/user/components/table/UserOrganizationAction' import { useParams } from 'next/navigation' @@ -25,19 +25,21 @@ export default function UserOrganizationTable() { const columns = useGetOrganizationUserAction() const dispatch = useAppDispatch() + const [roleFilter, setRoleFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') const [searchQuery, setSearchQuery] = useState('') const debouncedSearchQuery = useDebounce(searchQuery, 500) const userParams = useAppSelector((state) => state.user) - const searchParams: UserSliceParams = { - ...userParams, - search: debouncedSearchQuery, - pageNumber: userParams.pageNumber ?? 0 - } - const { data } = useGetOrganizationUserQuery( - { organizationId: Number(organizationId), pageSize: 20 }, + { + organizationId: Number(organizationId), + pageSize: 20, + search: debouncedSearchQuery?.trim() ? debouncedSearchQuery : undefined, + role: roleFilter === 'all' ? undefined : roleFilter, + status: statusFilter === 'all' ? undefined : statusFilter + }, { skip: !organizationId } ) @@ -50,17 +52,29 @@ export default function UserOrganizationTable() { [data] ) - const userRoleOptions = Object.entries(UserRole) - .filter(([key, _]) => key !== 'GUEST') - .map(([key, value]) => ({ - label: tCommon(`accountType.${value.toLowerCase()}`), + const userRoleOptions = [ + { + label: tCommon('accountType.all'), + value: 'all' + }, + ...Object.entries(LicenseType) + .filter(([key]) => key !== 'GUEST') + .map(([_, value]) => ({ + label: tCommon(`accountType.${value.toLowerCase()}`), + value + })) + ] + + const statusOptions = [ + { + label: tCommon('status.all'), + value: 'all' + }, + ...Object.entries(UserStatus).map(([key, value]) => ({ + label: statusTranslate(value), value: value })) - - const statusOptions = Object.entries(UserStatus).map(([key, value]) => ({ - label: statusTranslate(value), - value: value - })) + ] const handlePageChange = (page: number) => { dispatch(setPageIndex(page)) @@ -80,15 +94,15 @@ export default function UserOrganizationTable() { dispatch(setParam({ key: 'role', value: val as UserRole }))} + value={roleFilter?.toString() ?? ''} + onChange={(val) => setRoleFilter(val as LicenseType)} options={userRoleOptions} /> dispatch(setParam({ key: 'status', value: val as UserStatus }))} + value={statusFilter} + onChange={(val) => setStatusFilter(val as UserStatus | 'all')} options={statusOptions.filter((option) => option.value !== UserStatus.DELETED)} />
diff --git a/src/features/user/types/user.type.ts b/src/features/user/types/user.type.ts index fe30d10a..5de59fac 100644 --- a/src/features/user/types/user.type.ts +++ b/src/features/user/types/user.type.ts @@ -10,6 +10,7 @@ export type User = { userRole: UserRole //'Admin' | 'Staff' | 'Member' | 'Guest' firstName: string lastName: string + fullName: string imageUrl?: string status: UserStatus isActive: boolean @@ -96,5 +97,6 @@ export type OrganizationUserQueryParams = { pageNumber?: number pageSize?: number role?: LicenseType - email?: string + search?: string + status?: UserStatus } From cb44a64a3fd25c51ffef86f5f3ef59def200a87d Mon Sep 17 00:00:00 2001 From: meewaldor Date: Tue, 16 Dec 2025 00:40:27 +0700 Subject: [PATCH 14/36] feat: update Vietnamese toast messages for subscription cancellation and improve organization state management --- messages/vi/common/vi_toast.json | 2 +- .../sidebar/organization-switcher.tsx | 1 - .../detail/SystemOrganizationDetail.tsx | 12 --------- .../list/AdminCurriculumCourseList.tsx | 7 +++-- .../list/SystemSubscriptionColumn.tsx | 27 +++++++------------ .../slice/selectedOrganizationSlice.ts | 1 + 6 files changed, 16 insertions(+), 34 deletions(-) diff --git a/messages/vi/common/vi_toast.json b/messages/vi/common/vi_toast.json index 40fdae12..890c86dd 100644 --- a/messages/vi/common/vi_toast.json +++ b/messages/vi/common/vi_toast.json @@ -57,7 +57,7 @@ "removeItemFromCart": "Bạn có chắc chắn muốn xóa sản phẩm này khỏi giỏ hàng không?", "clearCart": "Bạn có chắc chắn muốn xóa giỏ hàng không?", "restore": "Bạn có chắc chắn muốn khôi phục \"{title}\" không?", - "cancelledSubscriptions": "Bạn có chắc chắn muốn hủy đăng ký này không?", + "cancelledSubscriptions": "Bạn có chắc chắn muốn hủy gói đăng ký này không? Sau khi hủy, người dùng sẽ không thể sử dụng các tính năng của gói.", "deactivate": "Bạn có chắc chắn muốn vô hiệu hóa \"{title}\" không?", "activate": "Bạn có chắc chắn muốn kích hoạt \"{title}\" không?" }, diff --git a/src/components/layout/organization/sidebar/organization-switcher.tsx b/src/components/layout/organization/sidebar/organization-switcher.tsx index eebdcea8..aeb87571 100644 --- a/src/components/layout/organization/sidebar/organization-switcher.tsx +++ b/src/components/layout/organization/sidebar/organization-switcher.tsx @@ -36,7 +36,6 @@ export function OrganizationSwitcher() { if (licenseAssignments.length && !selectedOrganizationId) { const first = licenseAssignments[0] dispatch(setSelectedOrganizationId(first.organizationId)) - dispatch(setSelectedSubscriptionOrderId(first.organizationSubscriptionOrderId)) } }, [licenseAssignments, selectedOrganizationId, dispatch]) diff --git a/src/features/organization/components/detail/SystemOrganizationDetail.tsx b/src/features/organization/components/detail/SystemOrganizationDetail.tsx index 26a12358..51d0a946 100644 --- a/src/features/organization/components/detail/SystemOrganizationDetail.tsx +++ b/src/features/organization/components/detail/SystemOrganizationDetail.tsx @@ -1,7 +1,5 @@ 'use client' import { Badge } from '@/components/shadcn/badge' -import { SCard } from '@/components/shared/card/SCard' -import { DataTable } from '@/components/shared/data-table/data-table' import SEmpty from '@/components/shared/empty/SEmpty' import SLoading from '@/components/shared/SLoading' import { useDeleteOrganizationMutation, useGetOrganizationByIdQuery } from '@/features/organization/api/organizationApi' @@ -68,16 +66,6 @@ export default function SystemOrganizationDetail() { }} /> - - { - openModal('confirm', { - message: `${tt('confirmMessage.delete', { title: organization.data.name })}`, - onConfirm: () => handleDelete(organization.data.id) - }) - }} - /> -
diff --git a/src/features/resource/curriculum/components/list/AdminCurriculumCourseList.tsx b/src/features/resource/curriculum/components/list/AdminCurriculumCourseList.tsx index cb93b73d..a880d35c 100644 --- a/src/features/resource/curriculum/components/list/AdminCurriculumCourseList.tsx +++ b/src/features/resource/curriculum/components/list/AdminCurriculumCourseList.tsx @@ -6,6 +6,7 @@ import { Course } from '@/features/resource/course/types/course.type' import { useUpdateCourseOrderMutation } from '@/features/resource/curriculum/api/curriculumApi' import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' import { useModal } from '@/providers/ModalProvider' +import { UserRole } from '@/types/userRole' import { Plus } from 'lucide-react' import { useTranslations } from 'next-intl' import React, { useEffect } from 'react' @@ -22,7 +23,9 @@ export default function AdminCurriculumCourseList({ curriculumId, courses }: Adm const tt = useTranslations('toast') const [localCourses, setLocalCourses] = React.useState(courses || []) + const { currentRole } = useAppSelector((state) => state.selectedOrganization) const { selectedOrganizationId } = useAppSelector((state) => state.selectedOrganization) + console.log('selectedOrganizationId', selectedOrganizationId) const dispatch = useAppDispatch() const { openModal } = useModal() const columns = useGetCourseColumn({ isPopup: false }) @@ -67,7 +70,7 @@ export default function AdminCurriculumCourseList({ curriculumId, courses }: Adm {t('list.courseListTitle')}{' '} {courses?.length} - {!selectedOrganizationId && ( + {currentRole == UserRole.STAFF || currentRole == UserRole.ADMIN ? (
{orderedCourseIds.length > 0 && (
- )} + ) : null}
{ + const handleCancel = async (subscription: OrganizationSubscription) => { await updateSubscription({ subscriptionId: subscription.id, - body: { status: SubscriptionStatus.ARCHIVED } + body: { status: SubscriptionStatus.CANCELLED } }) - toast.success(tt('successMessage.update', { title: SubscriptionStatus.ARCHIVED })) + toast.success(tt('successMessage.updateNoTitle')) } const handleDelete = async (id: number) => { @@ -74,8 +74,8 @@ export function useSystemSubscriptionColumn(): ColumnDef = { - [SubscriptionStatus.PENDING]: [SubscriptionStatus.PENDING, SubscriptionStatus.ACTIVE, SubscriptionStatus.CANCELLED], - [SubscriptionStatus.ACTIVE]: [SubscriptionStatus.ACTIVE, SubscriptionStatus.CANCELLED], + [SubscriptionStatus.PENDING]: [SubscriptionStatus.PENDING, SubscriptionStatus.ACTIVE], + [SubscriptionStatus.ACTIVE]: [SubscriptionStatus.ACTIVE], [SubscriptionStatus.ARCHIVED]: [ SubscriptionStatus.ACTIVE, SubscriptionStatus.ARCHIVED, @@ -169,22 +169,13 @@ export function useSystemSubscriptionColumn(): ColumnDef { - openModal('confirm', { - message: tt('confirmMessage.archive', { title: original.planName }), - onConfirm: () => handleArchive(original) - }) - } - }, - { - label: tc('button.delete'), + label: tc('button.cancel'), danger: true, + hidden: ({ original }) => original.status !== SubscriptionStatus.ACTIVE, onClick: async ({ original }) => { openModal('confirm', { - message: tt('confirmMessage.delete', { title: original.planName }), - onConfirm: () => handleDelete(original.id) + message: tt('confirmMessage.cancelledSubscriptions', { title: original.planName }), + onConfirm: () => handleCancel(original) }) } } diff --git a/src/features/subscription/slice/selectedOrganizationSlice.ts b/src/features/subscription/slice/selectedOrganizationSlice.ts index 84afcd47..5c6e44be 100644 --- a/src/features/subscription/slice/selectedOrganizationSlice.ts +++ b/src/features/subscription/slice/selectedOrganizationSlice.ts @@ -34,6 +34,7 @@ const selectedOrganizationSlice = createSlice({ clearSelectedOrganization: (state) => { state.selectedOrganizationId = null state.selectedSubscriptionOrderId = null + state.selectedOrgUserId = null state.currentRole = UserRole.GUEST } } From 510788370a94a8a51f3e00f135be719c12027956 Mon Sep 17 00:00:00 2001 From: meewaldor Date: Tue, 16 Dec 2025 01:39:25 +0700 Subject: [PATCH 15/36] feat: refactor organization management to use new API for fetching organizations with user access; update related components and types --- .../header/organization-switcher-header.tsx | 51 ++++++--------- .../sidebar/organization-switcher.tsx | 64 ++++++++----------- .../organization/api/organizationApi.ts | 17 ++++- .../organization/types/organization.type.ts | 16 +++++ src/providers/AuthSessionSync.tsx | 4 +- 5 files changed, 78 insertions(+), 74 deletions(-) diff --git a/src/components/layout/header/organization-switcher-header.tsx b/src/components/layout/header/organization-switcher-header.tsx index 3ab09929..6f4c7515 100644 --- a/src/components/layout/header/organization-switcher-header.tsx +++ b/src/components/layout/header/organization-switcher-header.tsx @@ -10,39 +10,34 @@ import { DropdownMenuTrigger } from '@/components/shadcn/dropdown-menu' import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' -import { - setSelectedOrganizationId, - setSelectedSubscriptionOrderId -} from '@/features/subscription/slice/selectedOrganizationSlice' -import { useSearchLicenseAssignmentQuery } from '@/features/license-assignment/api/licenseAssignmentApi' -import { LicenseAssignmentStatus } from '@/features/license-assignment/types/licenseAssignment' +import { setSelectedOrganizationId } from '@/features/subscription/slice/selectedOrganizationSlice' +import { useGetOrganizationsWithAccessByUserIdQuery } from '@/features/organization/api/organizationApi' export function OrganizationSwitcherHeader() { const dispatch = useAppDispatch() const user = useAppSelector((state) => state.auth.user) const selectedOrganizationId = useAppSelector((state) => state.selectedOrganization.selectedOrganizationId) - const { data: licenseAssignmentData, isLoading } = useSearchLicenseAssignmentQuery( - { userId: user?.userId, status: LicenseAssignmentStatus.ACTIVE, pageSize: 5, pageNumber: 1 }, - { skip: !user?.userId } + const userId = user?.userId + const { data: organizationData, isLoading } = useGetOrganizationsWithAccessByUserIdQuery( + { userId: userId! }, + { skip: !userId } ) - const licenseAssignments = licenseAssignmentData?.data?.items ?? [] + const licenseAssignments = organizationData?.data?.organizations ?? [] - // ✅ Chỉ gán mặc định nếu chưa có org được chọn + // Chỉ gán mặc định nếu chưa có org được chọn React.useEffect(() => { if (licenseAssignments.length && !selectedOrganizationId) { const first = licenseAssignments[0] - dispatch(setSelectedOrganizationId(first.organizationId)) - dispatch(setSelectedSubscriptionOrderId(first.organizationSubscriptionOrderId)) + dispatch(setSelectedOrganizationId(first.id)) } }, [licenseAssignments, selectedOrganizationId, dispatch]) if (isLoading || !licenseAssignments.length) return null // ✅ Tìm org đang được chọn (hoặc lấy org đầu tiên nếu chưa có) - const selectedOrg = - licenseAssignments.find((x) => x.organizationId === selectedOrganizationId) ?? licenseAssignments[0] + const selectedOrg = licenseAssignments.find((x) => x.id === selectedOrganizationId) ?? licenseAssignments[0] return (
@@ -50,19 +45,14 @@ export function OrganizationSwitcherHeader() { @@ -74,25 +64,20 @@ export function OrganizationSwitcherHeader() { {licenseAssignments.map((org) => ( dispatch(setSelectedOrganizationId(org.organizationId))} + onClick={() => dispatch(setSelectedOrganizationId(org.id))} className={`flex cursor-pointer items-center gap-2 p-2 ${ - org.organizationId === selectedOrganizationId ? 'bg-accent/40' : '' + org.id === selectedOrganizationId ? 'bg-accent/40' : '' }`} >
- {org.organizationImageUrl ? ( - {org.organizationName} + {org.imageUrl ? ( + {org.name} ) : ( )}
- {org.organizationName} - {org.planName} + {org.name}
))} diff --git a/src/components/layout/organization/sidebar/organization-switcher.tsx b/src/components/layout/organization/sidebar/organization-switcher.tsx index aeb87571..b61e09ea 100644 --- a/src/components/layout/organization/sidebar/organization-switcher.tsx +++ b/src/components/layout/organization/sidebar/organization-switcher.tsx @@ -11,12 +11,8 @@ import { } from '@/components/shadcn/dropdown-menu' import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/shadcn/sidebar' import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' -import { - setSelectedOrganizationId, - setSelectedSubscriptionOrderId -} from '@/features/subscription/slice/selectedOrganizationSlice' -import { useSearchLicenseAssignmentQuery } from '@/features/license-assignment/api/licenseAssignmentApi' -import { LicenseAssignmentStatus } from '@/features/license-assignment/types/licenseAssignment' +import { setSelectedOrganizationId } from '@/features/subscription/slice/selectedOrganizationSlice' +import { useGetOrganizationsWithAccessByUserIdQuery } from '@/features/organization/api/organizationApi' export function OrganizationSwitcher() { const { isMobile } = useSidebar() @@ -24,27 +20,26 @@ export function OrganizationSwitcher() { const user = useAppSelector((state) => state.auth.user) const selectedOrganizationId = useAppSelector((state) => state.selectedOrganization.selectedOrganizationId) - const { data: licenseAssignmentData, isLoading } = useSearchLicenseAssignmentQuery( - { userId: user?.userId, status: LicenseAssignmentStatus.ACTIVE, pageSize: 10, pageNumber: 1 }, - { skip: !user?.userId } + const userId = user?.userId + const { data: organizationData, isLoading } = useGetOrganizationsWithAccessByUserIdQuery( + { userId: userId! }, + { skip: !userId } ) - const licenseAssignments = licenseAssignmentData?.data?.items ?? [] + const organizations = organizationData?.data?.organizations ?? [] // Nếu chưa chọn org nào => mặc định chọn org đầu tiên React.useEffect(() => { - if (licenseAssignments.length && !selectedOrganizationId) { - const first = licenseAssignments[0] - dispatch(setSelectedOrganizationId(first.organizationId)) + if (organizations.length && !selectedOrganizationId) { + const first = organizations[0] + dispatch(setSelectedOrganizationId(first.id)) } - }, [licenseAssignments, selectedOrganizationId, dispatch]) + }, [organizations, selectedOrganizationId, dispatch]) - if (isLoading || !licenseAssignments.length) return null + if (isLoading || !organizations.length) return null // Organization đang được chọn - const selectedOrg = - licenseAssignments.find((org) => org.organizationId === selectedOrganizationId) ?? licenseAssignments[0] - + const selectedOrg = organizations.find((org) => org.id === selectedOrganizationId) ?? organizations[0] return ( @@ -55,19 +50,15 @@ export function OrganizationSwitcher() { className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground' >
- {selectedOrg.organizationImageUrl ? ( - {selectedOrg.organizationName} + {selectedOrg.imageUrl ? ( + {selectedOrg.name} ) : ( )}
- {selectedOrg.organizationName} - {selectedOrg.planName} + {selectedOrg.name} + Đang hoạt động
@@ -81,26 +72,25 @@ export function OrganizationSwitcher() { > Tổ chức - {licenseAssignments.map((org) => ( + {organizations.map((org) => ( dispatch(setSelectedOrganizationId(org.organizationId))} - className={`gap-2 p-2 ${org.organizationId === selectedOrganizationId ? 'bg-sidebar-accent/50' : ''}`} + onClick={() => { + console.log('Switching organization to:', org.id) + dispatch(setSelectedOrganizationId(org.id)) + }} + className={`gap-2 p-2 ${org.id === selectedOrganizationId ? 'bg-slate-100' : ''}`} >
- {org.organizationImageUrl ? ( - {org.organizationName} + {org.imageUrl ? ( + {org.name} ) : ( )}
- {org.organizationName} -

{org.planName}

+ {org.name} +

{org.subscriptions.length} gói đang hoạt động

))} diff --git a/src/features/organization/api/organizationApi.ts b/src/features/organization/api/organizationApi.ts index 0a73442d..dc8001fa 100644 --- a/src/features/organization/api/organizationApi.ts +++ b/src/features/organization/api/organizationApi.ts @@ -3,7 +3,8 @@ import { OrganizationCurriculum, OrganizationQueryParams, OrganizationSliceParams, - OrganizationType + OrganizationType, + OrganizationWithAccess } from '@/features/organization/types/organization.type' import { Curriculum } from '@/features/resource/curriculum/types/curriculum.type' import { createCrudApi } from '@/libs/redux/baseApi' @@ -31,6 +32,15 @@ export const organizationApi = createCrudApi, + { userId: string } + >({ + query: ({ userId }) => ({ + url: `/users/${userId}/organizations/access` + }), + providesTags: ['Organization'] }) }) }) @@ -48,5 +58,8 @@ export const { useGetAllOrganizationTypesQuery, // Org Curriculums - useGetCurriculumsByOrganizationIdQuery + useGetCurriculumsByOrganizationIdQuery, + + // Orgs with access + useGetOrganizationsWithAccessByUserIdQuery } = organizationApi diff --git a/src/features/organization/types/organization.type.ts b/src/features/organization/types/organization.type.ts index b3a31d82..1c9c57d2 100644 --- a/src/features/organization/types/organization.type.ts +++ b/src/features/organization/types/organization.type.ts @@ -1,3 +1,4 @@ +import { LicenseAssignmentType } from '@/features/license-assignment/types/licenseAssignment' import { CourseLevel } from '@/features/resource/course/types/course.type' import { OrganizationSubscription } from '@/features/subscription/types/subscription.type' import { SliceQueryParams } from '@/libs/redux/createQuerySlice' @@ -81,3 +82,18 @@ export type OrganizationCurriculumCourse = { courseOrderIndex: number lessons: any[] } + +export type OrganizationWithAccess = { + id: number + name: string + code: string + imageUrl?: string + subscriptions: { + id: number + status: string + licenseType: LicenseAssignmentType + curriculumIds: number[] + courseIds: number[] + emulatorModelIds: number[] + }[] +} diff --git a/src/providers/AuthSessionSync.tsx b/src/providers/AuthSessionSync.tsx index c7d48139..2247ef79 100644 --- a/src/providers/AuthSessionSync.tsx +++ b/src/providers/AuthSessionSync.tsx @@ -57,8 +57,8 @@ export default function AuthSessionSync() { reduxUser.userRole === UserRole.MEMBER && reduxUser.organizations && reduxUser.organizations?.organizations?.length > 0 && - reduxUser.organizations.organizations[0].roles?.length > 0 - // (!reduxSelectedOrganizationId || !reduxSelectedSubscriptionOrderId || !reduxCurrentRole) + reduxUser.organizations.organizations[0].roles?.length > 0 && + (!reduxSelectedOrganizationId || !reduxCurrentRole) ) { const firstOrg = reduxUser.organizations.organizations[0] console.log('First organization:', firstOrg) From e6575be2e83ad4b0777a19c59f6ed43a3fc7a7d1 Mon Sep 17 00:00:00 2001 From: meewaldor Date: Tue, 16 Dec 2025 11:30:50 +0700 Subject: [PATCH 16/36] feat: integrate SearchBar component for improved organization search functionality; refactor search query parameters --- .../components/upsert/CreateClassroom.tsx | 73 +++---------------- .../list/SystemOrganizationList.tsx | 17 ++--- 2 files changed, 17 insertions(+), 73 deletions(-) diff --git a/src/features/classroom/components/upsert/CreateClassroom.tsx b/src/features/classroom/components/upsert/CreateClassroom.tsx index 03b2cb29..6a8d3e18 100644 --- a/src/features/classroom/components/upsert/CreateClassroom.tsx +++ b/src/features/classroom/components/upsert/CreateClassroom.tsx @@ -205,74 +205,19 @@ export default function CreateClassroom() { return ( isActive && setSelectedSubscriptionId(sub.id)} + className={`group relative overflow-hidden rounded-xl border transition-all duration-300 ${isActive ? 'cursor-pointer hover:shadow-lg' : 'cursor-not-allowed opacity-50'} ${isSelected ? '-translate-y-1 border-blue-500 shadow-blue-200/50' : 'border-gray-200'} `} > - - {/* Header */} -
-

{sub.planName}

- - {statusTranslate(sub.status.toLowerCase())} - -
- - {/* Price */} -
- {formatPrice(sub.netAmount)} -
+ {/* Left accent */} + {isSelected &&
} - {/* Period */} -
- - {tClassroom(sub.planBillingCycle.toLowerCase())} - - - {formatDate(sub.startDate, { locale })} - {formatDate(sub.endDate, { locale })} - -
- - {/* Divider */} -
- - {/* Stats */} -
-
-
- - {tClassroom('students')} -
- - {sub.currentStudentSeats}/{sub.maxStudentSeats} - -
- -
-
- - {tClassroom('teachers')} -
- - {sub.currentTeacherSeats}/{sub.maxTeacherSeats} - -
- -
-
- - {tClassroom('curricula')} -
- {sub.curriculumCount} -
-
+ + {/* Plan name */} +

{sub.planName}

- {/* Code */} -
-

{sub.code}

+ {/* Date range */} +
+ {formatDate(sub.startDate, { locale })} – {formatDate(sub.endDate, { locale })}
diff --git a/src/features/organization/components/list/SystemOrganizationList.tsx b/src/features/organization/components/list/SystemOrganizationList.tsx index f7912b89..7c165c8c 100644 --- a/src/features/organization/components/list/SystemOrganizationList.tsx +++ b/src/features/organization/components/list/SystemOrganizationList.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/shadcn/button' import { Input } from '@/components/shadcn/input' import { DataTable } from '@/components/shared/data-table/data-table' import LoadingComponent from '@/components/shared/loading/LoadingComponent' +import SearchBar from '@/components/shared/search/SearchBar' import SSelect from '@/components/shared/SSelect' import { useSearchOrganizationsQuery } from '@/features/organization/api/organizationApi' import { useGetOrganizationColumn } from '@/features/organization/components/list/SystemOrganizationColumn' @@ -28,7 +29,12 @@ export default function SystemOrganizationList() { const queryParams = useAppSelector((state) => state.organization) const columns = useGetOrganizationColumn() - const { data, isLoading } = useSearchOrganizationsQuery(queryParams) + const { data, isLoading } = useSearchOrganizationsQuery({ + search: search || undefined, + status: queryParams.status, + pageNumber: queryParams.pageNumber, + pageSize: queryParams.pageSize + }) const organizationStatusOptions = [ { label: tc('status.all'), value: 'all' }, @@ -69,14 +75,7 @@ export default function SystemOrganizationList() {
{/* Search Input */}
- setSearch(e.target.value)} - className='border-gray-300 bg-white pl-10 hover:border-blue-400 focus:border-blue-500 focus:ring-2 focus:ring-blue-200' - /> - + setSearch(v)} />
Date: Tue, 16 Dec 2025 11:34:12 +0700 Subject: [PATCH 17/36] feat: add guide text for classroom creation in English and Vietnamese; update CreateClassroom component to use new translations --- messages/en/classroom/en_classroom.json | 6 ++++-- messages/vi/classroom/vi_classroom.json | 6 ++++-- .../classroom/components/upsert/CreateClassroom.tsx | 9 ++------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/messages/en/classroom/en_classroom.json b/messages/en/classroom/en_classroom.json index 5f67450b..8cfb9ffc 100644 --- a/messages/en/classroom/en_classroom.json +++ b/messages/en/classroom/en_classroom.json @@ -92,7 +92,9 @@ "semiAnnual": "Semi-Annual", "students": "Students", "teachers": "Teachers", - "curricula": "Curricula" + "curricula": "Curricula", + "guideText": "Creation Guide", + "guide": "Courses can belong to multiple active subscriptions. Please select a subscription before creating the classroom. The selected subscription will determine the number of students, teachers, and access to learning content. After selecting a subscription, choose student groups, assign teachers, and set the classroom duration. Click Create to finalize the classroom creation." }, "studentClassroom": { "list": { @@ -118,4 +120,4 @@ "grade": "Grade" } } -} \ No newline at end of file +} diff --git a/messages/vi/classroom/vi_classroom.json b/messages/vi/classroom/vi_classroom.json index fcfbd785..901c7a37 100644 --- a/messages/vi/classroom/vi_classroom.json +++ b/messages/vi/classroom/vi_classroom.json @@ -91,7 +91,9 @@ "semiAnnual": "Nửa năm", "students": "Học sinh", "teachers": "Giáo viên", - "curricula": "Chương trình học" + "curricula": "Chương trình học", + "guideText": "Hướng dẫn tạo lớp học", + "guide": "Khóa học có thể thuộc nhiều gói đăng ký đang hoạt động. Vui lòng chọn một gói đăng ký trước khi tạo lớp học. Gói đăng ký được chọn sẽ quyết định số lượng học sinh, giáo viên và quyền truy cập nội dung học tập. Sau khi chọn gói, hãy chọn nhóm học sinh, gán giáo viên và thiết lập thời gian lớp học. Nhấn Tạo để hoàn tất việc tạo lớp học." }, "studentClassroom": { "list": { @@ -117,4 +119,4 @@ "grade": "Khối" } } -} \ No newline at end of file +} diff --git a/src/features/classroom/components/upsert/CreateClassroom.tsx b/src/features/classroom/components/upsert/CreateClassroom.tsx index 6a8d3e18..e10b3ade 100644 --- a/src/features/classroom/components/upsert/CreateClassroom.tsx +++ b/src/features/classroom/components/upsert/CreateClassroom.tsx @@ -173,14 +173,9 @@ export default function CreateClassroom() {
-

Hướng dẫn tạo lớp học

+

{tClassroom('guideText')}

-
- Khóa học có thể thuộc nhiều gói đăng ký đang hoạt động. Vui lòng chọn một gói đăng ký trước khi tạo lớp học. - Gói đăng ký được chọn sẽ quyết định số lượng học sinh, giáo viên và quyền truy cập nội dung học tập. Sau khi - chọn gói, hãy chọn nhóm học sinh, gán giáo viên và thiết lập thời gian lớp học. Nhấn Create để hoàn tất việc - tạo lớp học. -
+
{tClassroom('guide')}
From ad11690274ef7c7695337b6348e1d5c64759fdb3 Mon Sep 17 00:00:00 2001 From: meewaldor Date: Tue, 16 Dec 2025 12:01:56 +0700 Subject: [PATCH 18/36] feat: update CreateClassroom component to display course title in subheader; enhance organization state management with courseTitle --- .../components/upsert/CreateClassroom.tsx | 4 ++- .../slice/organizationSpecialSlice.ts | 8 +++++- .../OrganizationCourseClassroom.tsx | 25 ++++++++----------- .../organization/OrganizationCourseDetail.tsx | 18 ++++++------- src/libs/redux/store.ts | 3 ++- 5 files changed, 31 insertions(+), 27 deletions(-) diff --git a/src/features/classroom/components/upsert/CreateClassroom.tsx b/src/features/classroom/components/upsert/CreateClassroom.tsx index e10b3ade..6e8c748b 100644 --- a/src/features/classroom/components/upsert/CreateClassroom.tsx +++ b/src/features/classroom/components/upsert/CreateClassroom.tsx @@ -65,6 +65,8 @@ export default function CreateClassroom() { const searchParams = useSearchParams() const courseId = searchParams.get('courseId') + const { courseTitle } = useAppSelector((state) => state.organizationSpecial) + const [selectedGroups, setSelectedGroups] = useState< { groupCode: string @@ -165,7 +167,7 @@ export default function CreateClassroom() {

{tClassroom('header')}

-

{tClassroom('subheader')}

+

{courseTitle}

diff --git a/src/features/organization/slice/organizationSpecialSlice.ts b/src/features/organization/slice/organizationSpecialSlice.ts index ffd2ea5a..b7159adc 100644 --- a/src/features/organization/slice/organizationSpecialSlice.ts +++ b/src/features/organization/slice/organizationSpecialSlice.ts @@ -2,11 +2,13 @@ import { createSlice } from '@reduxjs/toolkit' type OrganizationSpecialState = { courseId: number | null + courseTitle?: string isRefetchOrganization?: boolean } const initialState: OrganizationSpecialState = { courseId: null, + courseTitle: undefined, isRefetchOrganization: false } @@ -17,6 +19,9 @@ export const organizationSpecialSlice = createSlice({ setCourseId(state, action) { state.courseId = action.payload }, + setCourseTitle(state, action) { + state.courseTitle = action.payload + }, triggerRefetchOrganization(state) { state.isRefetchOrganization = true @@ -27,4 +32,5 @@ export const organizationSpecialSlice = createSlice({ } }) -export const { setCourseId, triggerRefetchOrganization, clearRefetchOrganization } = organizationSpecialSlice.actions +export const { setCourseId, setCourseTitle, triggerRefetchOrganization, clearRefetchOrganization } = + organizationSpecialSlice.actions diff --git a/src/features/resource/course/components/detail/organization/OrganizationCourseClassroom.tsx b/src/features/resource/course/components/detail/organization/OrganizationCourseClassroom.tsx index 9bd06f42..fb892109 100644 --- a/src/features/resource/course/components/detail/organization/OrganizationCourseClassroom.tsx +++ b/src/features/resource/course/components/detail/organization/OrganizationCourseClassroom.tsx @@ -15,9 +15,15 @@ import useDebounce from '@/hooks/useDebounce' import { getOptions } from '@/utils/index' import { useSearchCourseQuery } from '@/features/resource/course/api/courseApi' import { useGetOrganizationCourseClassroomColumn } from '@/features/resource/course/components/detail/organization/OrganizationCourseClassroomCoulum' -import { setCourseId } from '@/features/organization/slice/organizationSpecialSlice' +import { setCourseId, setCourseTitle } from '@/features/organization/slice/organizationSpecialSlice' +import SearchBar from '@/components/shared/search/SearchBar' -export default function OrganizationCourseClassroom() { +type OrganizationCourseClassroomProps = { + courseTitle?: string +} + +export default function OrganizationCourseClassroom({ courseTitle }: OrganizationCourseClassroomProps) { + console.log({ courseTitle }) const router = useRouter() const locale = useLocale() @@ -37,7 +43,7 @@ export default function OrganizationCourseClassroom() { ...queryParams, organizationId: organizationId, courseId: Number(courseId), - search: debouncedSearchQuery + search: debouncedSearchQuery.trim() || undefined }) const rows = React.useMemo(() => data?.data.items ?? [], [data]) @@ -50,10 +56,6 @@ export default function OrganizationCourseClassroom() { { label: tc('status.completed'), value: 'completed' } ] - useEffect(() => { - dispatch(setSearchTerm(debouncedSearchQuery)) - }, [debouncedSearchQuery, dispatch]) - return (
{/* Header */} @@ -69,6 +71,7 @@ export default function OrganizationCourseClassroom() { className='bg-sky-600 text-white hover:bg-sky-700' onClick={() => { dispatch(setCourseId(Number(courseId))) + dispatch(setCourseTitle(courseTitle || '')) router.push(`/${locale}/organization/classroom/create?courseId=${courseId}`) }} > @@ -80,13 +83,7 @@ export default function OrganizationCourseClassroom() { {/* Filters */}
- setSearch(e.target.value)} - className='w-80 bg-white py-4.5' - style={{ width: '420px' }} - /> + setSearch(v)} />
('lesson') - if (isLoading || outcomeLoading || outcomeFetching || enrollmentLoading) + if (isLoading || outcomeLoading || outcomeFetching) return (
) - if (error) return
Error loading course details.
- if (!course?.data) return (
@@ -97,7 +95,7 @@ export default function OrganizationCourseDetail() {
{activeTab === 'lesson' && } - {activeTab === 'classroom' && } + {activeTab === 'classroom' && }
diff --git a/src/libs/redux/store.ts b/src/libs/redux/store.ts index 158bcee0..2fbf2777 100644 --- a/src/libs/redux/store.ts +++ b/src/libs/redux/store.ts @@ -15,7 +15,8 @@ const persistConfig = { 'organizationSpecial', 'selectedCurriculum', 'quizPlayer', - 'lessonDetail' + 'lessonDetail', + 'organizationSpecial' ] } From 5b87e55eac203a32b2e240d383dafd0ad00c1e89 Mon Sep 17 00:00:00 2001 From: LeThanhNhan91 Date: Tue, 16 Dec 2025 12:13:26 +0700 Subject: [PATCH 19/36] feat: add user detail modal and related API for fetching organization user details; update organization user table to support viewing user details --- messages/en/organization/en_organization.json | 21 ++ messages/vi/organization/vi_organization.json | 21 ++ .../user/OrganizationUserColumns.tsx | 11 +- .../user/OrganizationUserDetailModal.tsx | 202 ++++++++++++++++++ .../components/user/OrganizationUserTable.tsx | 17 +- src/features/user/api/userApi.ts | 11 +- src/features/user/types/user.type.ts | 33 +++ 7 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 src/features/organization/components/user/OrganizationUserDetailModal.tsx diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index b0c55957..abebe985 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -263,6 +263,27 @@ "email": "Search by email...", "license": "Select License" } + }, + "userDetail": { + "title": "User Details", + "description": "View detailed information about this user.", + "contactInfo": "Contact Information", + "email": "Email Address", + "activity": "Activity", + "joinedAt": "Joined At", + "lastLogin": "Last Login", + "never": "Never", + "professionalDetails": "Professional Details", + "groupName": "Group Name", + "groupCode": "Group Code", + "specialization": "Specialization", + "major": "Major", + "bio": "Bio", + "noData": "No user data found.", + "status": { + "active": "Active", + "inactive": "Inactive" + } } } } diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index 5fdef521..8e3252c4 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -262,6 +262,27 @@ "email": "Tìm kiếm theo email...", "license": "Chọn vai trò" } + }, + "userDetail": { + "title": "Chi tiết người dùng", + "description": "Xem thông tin chi tiết về người dùng này.", + "contactInfo": "Thông tin liên hệ", + "email": "Địa chỉ Email", + "activity": "Hoạt động", + "joinedAt": "Ngày tham gia", + "lastLogin": "Đăng nhập lần cuối", + "never": "Chưa từng", + "professionalDetails": "Thông tin chuyên môn", + "groupName": "Tên nhóm", + "groupCode": "Mã nhóm", + "specialization": "Chuyên môn", + "major": "Chuyên ngành", + "bio": "Giới thiệu", + "noData": "Không tìm thấy dữ liệu người dùng.", + "status": { + "active": "Hoạt động", + "inactive": "Không hoạt động" + } } } } diff --git a/src/features/organization/components/user/OrganizationUserColumns.tsx b/src/features/organization/components/user/OrganizationUserColumns.tsx index 3754dd7a..a5b6cdb8 100644 --- a/src/features/organization/components/user/OrganizationUserColumns.tsx +++ b/src/features/organization/components/user/OrganizationUserColumns.tsx @@ -33,10 +33,11 @@ const getRoleBadgeVariant = (licenseType: string) => { } } -export const useOrganizationUserColumns = (): ColumnDef[] => { - const handleViewDetail = (user: OrganizationUserTableItem) => { - console.log('View detail', user.id) - } +interface UseOrganizationUserColumnsProps { + onViewDetail: (user: OrganizationUserTableItem) => void; +} + +export const useOrganizationUserColumns = ({ onViewDetail }: UseOrganizationUserColumnsProps): ColumnDef[] => { const handleUpdate = (user: OrganizationUserTableItem) => { console.log('Update', user.id) } @@ -136,7 +137,7 @@ export const useOrganizationUserColumns = (): ColumnDef {tc('button.actions')} - handleViewDetail(user)}> + onViewDetail(user)}> {tc('button.view')} handleUpdate(user)}> diff --git a/src/features/organization/components/user/OrganizationUserDetailModal.tsx b/src/features/organization/components/user/OrganizationUserDetailModal.tsx new file mode 100644 index 00000000..5921309b --- /dev/null +++ b/src/features/organization/components/user/OrganizationUserDetailModal.tsx @@ -0,0 +1,202 @@ +'use client' + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/shadcn/dialog' +import { useGetOrganizationUserDetailQuery } from '@/features/user/api/userApi' +import LoadingComponent from '@/components/shared/loading/LoadingComponent' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/shadcn/avatar' +import { Badge } from '@/components/shadcn/badge' +import { Mail, User, BookOpen, Briefcase, Hash, Clock, ShieldCheck, XCircle } from 'lucide-react' +import { formatDate } from '@/utils/index' +import { useLocale, useTranslations } from 'next-intl' +import { Separator } from '@/components/shadcn/separator' + +interface OrganizationUserDetailModalProps { + organizationUserId: string | null + open: boolean + onOpenChange: (open: boolean) => void +} + +export function OrganizationUserDetailModal({ + organizationUserId, + open, + onOpenChange, +}: OrganizationUserDetailModalProps) { + const locale = useLocale() + const t = useTranslations('organization.userDetail') + + const { data: response, isLoading } = useGetOrganizationUserDetailQuery( + { organizationUserId: organizationUserId! }, + { skip: !organizationUserId || !open } + ) + + const user = response?.data + + const getInitials = (name: string) => { + return name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .substring(0, 2) + } + + const getRoleBadgeVariant = (role: string) => { + switch (role?.toLowerCase()) { + case 'organizationadmin': + return 'destructive' + case 'teacher': + return 'default' + case 'student': + return 'secondary' + default: + return 'outline' + } + } + + return ( + + + + {t('title')} + {t('description')} + + + {isLoading ? ( +
+ +
+ ) : user ? ( +
+ {/* Header Section */} +
+ + + + {getInitials(user.fullName)} + + +
+

{user.fullName}

+
+ + @{user.userName} +
+
+ {user.subscriptions?.map((sub, idx) => ( + + {sub.licenseType} + + ))} + + {user.isActive ? : } + {user.isActive ? t('status.active') : t('status.inactive')} + +
+
+
+ + + + {/* Detailed Info Grid */} +
+ + {/* Contact Info */} +
+

+ + {t('contactInfo')} +

+
+

{t('email')}

+

{user.email}

+
+
+ + {/* Activity Info */} +
+

+ + {t('activity')} +

+
+
+

{t('joinedAt')}

+

{formatDate(user.joinedAt, { locale })}

+
+
+

{t('lastLogin')}

+

+ {user.lastLoginAt ? formatDate(user.lastLoginAt, { locale }) : t('never')} +

+
+
+
+ + {/* Academic / Professional Info */} + {(user.groupName || user.studentMajor || user.teacherSpecialization || user.bio) && ( +
+

+ + {t('professionalDetails')} +

+
+ {user.groupName && ( +
+

+ {t('groupName')} +

+

{user.groupName}

+
+ )} + {user.groupCode && ( +
+

+ {t('groupCode')} +

+

{user.groupCode}

+
+ )} + {user.teacherSpecialization && ( +
+

+ {t('specialization')} +

+

{user.teacherSpecialization}

+
+ )} + {user.studentMajor && ( +
+

+ {t('major')} +

+

{user.studentMajor}

+
+ )} + {user.bio && ( +
+

{t('bio')}

+

+ "{user.bio}" +

+
+ )} +
+
+ )} +
+
+ ) : ( +
+ {t('noData')} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/src/features/organization/components/user/OrganizationUserTable.tsx b/src/features/organization/components/user/OrganizationUserTable.tsx index e9f2cb46..7b648a9b 100644 --- a/src/features/organization/components/user/OrganizationUserTable.tsx +++ b/src/features/organization/components/user/OrganizationUserTable.tsx @@ -12,6 +12,7 @@ import { Input } from '@/components/shadcn/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/shadcn/select' import { Search } from 'lucide-react' import { LicenseType } from '@/types/userRole' +import { OrganizationUserDetailModal } from './OrganizationUserDetailModal' // Hook debounce function useDebounce(value: T, delay: number): T { @@ -38,6 +39,9 @@ export default function OrganizationUserTable() { const [selectedRole, setSelectedRole] = useState(LicenseType.STUDENT) + const [isDetailModalOpen, setIsDetailModalOpen] = useState(false) + const [selectedUserId, setSelectedUserId] = useState(null) + const debouncedSearchTerm = useDebounce(searchTerm, 500) const searchParams: OrganizationUserQueryParams = { @@ -52,7 +56,12 @@ export default function OrganizationUserTable() { skip: !organizationId }) - const columns = useOrganizationUserColumns() + const handleViewDetail = (user: OrganizationUserTableItem) => { + setSelectedUserId(user.organizationUserId) + setIsDetailModalOpen(true) + } + + const columns = useOrganizationUserColumns({ onViewDetail: handleViewDetail }) const visibleColumns = useMemo(() => { if (selectedRole !== LicenseType.STUDENT) { @@ -120,6 +129,12 @@ export default function OrganizationUserTable() { handlePageChange={handlePageChange} placeholder={isLoading ? 'Đang tải dữ liệu...' : 'Không có người dùng nào khớp với bộ lọc'} /> + +
) } diff --git a/src/features/user/api/userApi.ts b/src/features/user/api/userApi.ts index b51127b3..6e4a2e6e 100644 --- a/src/features/user/api/userApi.ts +++ b/src/features/user/api/userApi.ts @@ -2,6 +2,7 @@ import { createCrudApi } from '@/libs/redux/baseApi' import { ApiSuccessResponse, PaginatedResult } from '@/types/baseModel' import { OrganizationUser, + OrganizationUserProfile, OrganizationUserQueryParams, User, UserQueryParams, @@ -34,6 +35,13 @@ export const userApi = createCrudApi({ params: { pageNumber, pageSize, role, search, status } }) // providesTags: ['OrganizationUser'] + }), + + getOrganizationUserDetail: builder.query, {organizationUserId: string}>({ + query: ({organizationUserId}) => ({ + url: `/organization-users/${organizationUserId}`, + method: 'GET' + }) }) }) }) @@ -52,5 +60,6 @@ export const { useLazyGetByIdQuery: useLazyGetUserByIdQuery, useSearchUserV2Query, - useGetOrganizationUserQuery + useGetOrganizationUserQuery, + useGetOrganizationUserDetailQuery } = userApi diff --git a/src/features/user/types/user.type.ts b/src/features/user/types/user.type.ts index 5de59fac..f0c48e70 100644 --- a/src/features/user/types/user.type.ts +++ b/src/features/user/types/user.type.ts @@ -100,3 +100,36 @@ export type OrganizationUserQueryParams = { search?: string status?: UserStatus } + +export interface Subscription { + subscriptionOrderId: number + licenseType: 'Teacher' | 'Student' | string + licenseAssignmentId: string + isActive: boolean + joinedAt: string // ISO date +} + +export interface OrganizationUserProfile { + userId: string + email: string + userName: string + fullName: string + firstName: string + lastName: string + lastLoginAt: string | null + organizationUserId: string + organizationId: number + organizationRole: 'Teacher' | 'Student' | 'OrganizationAdmin' | string + licenseType: 'Teacher' | 'Student' | string + licenseAssignmentId: string + isActive: boolean + joinedAt: string + groupName: string + groupCode: string + bio: string + studentDateOfBirth: string | null + studentMajor: string + teacherSpecialization: string + subscriptions: Subscription[] +} + From 20af11a7ec45a6f66fe00b41f670fabb3b7f15a3 Mon Sep 17 00:00:00 2001 From: meewaldor Date: Tue, 16 Dec 2025 13:06:57 +0700 Subject: [PATCH 20/36] feat: implement StudentGroupInfoModal for displaying student group details; update modal management and remove UpsertGroupModal --- .../components/list/GroupTableWithTeacher.tsx | 9 ++++- .../modal/StudentGroupInfoModal.tsx | 40 +++++++++++++++++++ .../components/modal/UpdateGroupModal.tsx | 2 +- .../components/modal/UpsertGroupModal.tsx | 15 ------- src/providers/ModalProvider.tsx | 4 +- src/types/general.ts | 2 +- 6 files changed, 52 insertions(+), 20 deletions(-) create mode 100644 src/features/group/components/modal/StudentGroupInfoModal.tsx delete mode 100644 src/features/group/components/modal/UpsertGroupModal.tsx diff --git a/src/features/group/components/list/GroupTableWithTeacher.tsx b/src/features/group/components/list/GroupTableWithTeacher.tsx index 942ddde4..8aedfa03 100644 --- a/src/features/group/components/list/GroupTableWithTeacher.tsx +++ b/src/features/group/components/list/GroupTableWithTeacher.tsx @@ -11,6 +11,7 @@ import { Group } from '@/features/group/types/group.type' import { useTranslations } from 'next-intl' import { LicenseType } from '@/types/userRole' import { Users2 } from 'lucide-react' +import { useModal } from '@/providers/ModalProvider' type GroupTableWithTeacherProps = { grade: string @@ -27,6 +28,7 @@ type GroupTableWithTeacherProps = { export default function GroupTableWithTeacher({ grade, onGroupsChange }: GroupTableWithTeacherProps) { const tc = useTranslations('common') const to = useTranslations('organization') + const { openModal } = useModal() const [selectedRows, setSelectedRows] = useState([]) const [teacherAssignments, setTeacherAssignments] = useState>({}) @@ -130,7 +132,12 @@ export default function GroupTableWithTeacher({ grade, onGroupsChange }: GroupTa /> - {group.name} + openModal('studentGroupInfo', { groupId: group.id })} + > + {group.name} + {group.code} diff --git a/src/features/group/components/modal/StudentGroupInfoModal.tsx b/src/features/group/components/modal/StudentGroupInfoModal.tsx new file mode 100644 index 00000000..2d5bfd53 --- /dev/null +++ b/src/features/group/components/modal/StudentGroupInfoModal.tsx @@ -0,0 +1,40 @@ +'use client' +import React, { useMemo } from 'react' +import { Dialog, DialogContent, DialogTitle } from '@/components/shadcn/dialog' +import { useModal } from '@/providers/ModalProvider' +import { useGetGroupByIdQuery } from '@/features/group/api/groupApi' +import { ScrollArea } from '@/components/shadcn/scroll-area' +import { DataTable } from '@/components/shared/data-table/data-table' +import { useGetGroupColumn } from '@/features/group/components/detail/GroupColumn' + +type StudentGroupInfoModalProps = { + groupId: number +} + +export default function StudentGroupInfoModal({ groupId }: StudentGroupInfoModalProps) { + const { closeModal } = useModal() + const { data } = useGetGroupByIdQuery(Number(groupId), { skip: !groupId }) + + const rows = useMemo( + () => + (data?.data.students ?? []).map((item) => ({ + id: item.userId, + ...item + })), + [data] + ) + const columns = useGetGroupColumn() + + return ( + + + Student Group Info + +
+ +
+
+
+
+ ) +} diff --git a/src/features/group/components/modal/UpdateGroupModal.tsx b/src/features/group/components/modal/UpdateGroupModal.tsx index 4c850394..5f9d9eb5 100644 --- a/src/features/group/components/modal/UpdateGroupModal.tsx +++ b/src/features/group/components/modal/UpdateGroupModal.tsx @@ -8,7 +8,7 @@ export default function UpdateGroupModal() { return ( - Update Group + Cập nhật nhóm học sinh diff --git a/src/features/group/components/modal/UpsertGroupModal.tsx b/src/features/group/components/modal/UpsertGroupModal.tsx deleted file mode 100644 index 68d03d09..00000000 --- a/src/features/group/components/modal/UpsertGroupModal.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/shadcn/dialog' -import { useModal } from '@/providers/ModalProvider' - -export default function UpsertGroupModal() { - const { closeModal } = useModal() - return ( - - - - Create / Update Group - - - - ) -} diff --git a/src/providers/ModalProvider.tsx b/src/providers/ModalProvider.tsx index beea344e..e65d530b 100644 --- a/src/providers/ModalProvider.tsx +++ b/src/providers/ModalProvider.tsx @@ -43,12 +43,12 @@ import QuizCSVUploadModal from '@/features/resource/quiz/components/modal/QuizCS import SectionAIModal from '@/features/chat/components/SectionAIModal' import AssignmentCSVUploadModal from '@/features/assignment/components/detail/modal/AssignmentCSVUploadModal' import CreateAssignmentInfoModal from '@/features/assignment/components/upsert/CreateAssignmentInfoModal' -import UpsertGroupModal from '@/features/group/components/modal/UpsertGroupModal' import UpdateGroupModal from '@/features/group/components/modal/UpdateGroupModal' import CreateQuizModal from '@/features/resource/quiz/components/modal/CreateQuizModal' import { UpsertStudentGroup } from '@/features/group/components/upsert/UpsertStudentGroup' import AddStudentToGroupModal from '@/features/group/components/upsert/AddStudentToGroupModal' import CurriculumSelectEmulatorListModal from '@/features/resource/curriculum/components/modal/CurriculumSelectEmulatorListModal' +import StudentGroupInfoModal from '@/features/group/components/modal/StudentGroupInfoModal' const ModalContext = createContext({ openModal: () => {}, closeModal: () => {}, @@ -103,7 +103,6 @@ export const ModalProvider = ({ children }: { children: React.ReactNode }) => { {modalType === 'upsertOrganization' && } {modalType === 'upsertEmulator' && } {modalType === 'createAssignmentInfo' && } - {modalType === 'upsertGroup' && } {modalType === 'updateGroup' && } {modalType === 'createQuiz' && } {modalType === 'upsertStudentGroup' && } @@ -127,6 +126,7 @@ export const ModalProvider = ({ children }: { children: React.ReactNode }) => { {modalType === 'importQuiz' && } {modalType === 'sectionAI' && } {modalType === 'importAssignment' && } + {modalType === 'studentGroupInfo' && } {/* sheet */} {modalType === 'upsertContact' && } diff --git a/src/types/general.ts b/src/types/general.ts index acec7d8b..64655060 100644 --- a/src/types/general.ts +++ b/src/types/general.ts @@ -40,7 +40,6 @@ export type ModalType = | 'upsertEmulator' | 'upsertEmulator' | 'createAssignmentInfo' - | 'upsertGroup' | 'updateGroup' | 'createQuiz' | 'upsertStudentGroup' @@ -65,6 +64,7 @@ export type ModalType = | 'importQuiz' | 'sectionAI' | 'importAssignment' + | 'studentGroupInfo' // sheet | 'upsertContact' From 9dd7cd110ec11a86fc98a6ed5af1b1909e5ac985 Mon Sep 17 00:00:00 2001 From: LeThanhNhan91 Date: Tue, 16 Dec 2025 13:08:26 +0700 Subject: [PATCH 21/36] feat: enhance assignment submission experience with improved error handling and session restoration; update translations for user feedback --- messages/en/assignment/en_assignment.json | 19 ++- messages/vi/assignment/vi_assignment.json | 19 ++- .../components/attempt/AssigmentAttempt.tsx | 15 +- .../attempt/AssignmentSubmission.tsx | 134 +++++++++++------- .../components/detail/LessonContent.tsx | 16 +-- 5 files changed, 125 insertions(+), 78 deletions(-) diff --git a/messages/en/assignment/en_assignment.json b/messages/en/assignment/en_assignment.json index 7e973a3e..bb958ec0 100644 --- a/messages/en/assignment/en_assignment.json +++ b/messages/en/assignment/en_assignment.json @@ -75,15 +75,20 @@ }, "doAsm": { "deadline": "Deadline", - "deadline2": "Deadline (Days from enrollment)", - "AIGrading": "AI Grading", - "description": "After submitting your assignment and completing your required peer reviews, you'll receive an AI-generated grade based on the assignment rubrics. You'll then have the option to have your assignment reviewed by your peers instead.", - "subDes": "Your data will be used in accordance with", - "subDesLink": "Our Privacy Notice", "mySub": "My Submission", - "projectTitle": "Project Title", "placeholder": "Type your answer here...", - "saveDraft": "Already save draft at {time}." + "saveDraft": "Draft saved at {time}", + "restoring": "Restoring assignment session...", + "fileError": "Only .pdf, .doc, or .docx files are allowed.", + "uploadClick": "Click to upload", + "uploadDrag": "or drag and drop", + "uploadFormat": "PDF, DOC, or DOCX", + "restoreSuccess": "Restored your unsaved draft.", + "submitSuccess": "Assignment submitted successfully!", + "submitFail": "Failed to submit assignment. Please try again later.", + "uploadFail": "Failed to upload file for Question {index}.", + "invalidData": "Cannot submit assignment. Invalid data.", + "noAsmFound": "No assignment found. Please go back and select the assignment again." } }, "upsert": { diff --git a/messages/vi/assignment/vi_assignment.json b/messages/vi/assignment/vi_assignment.json index 8663c0b7..5b905f6e 100644 --- a/messages/vi/assignment/vi_assignment.json +++ b/messages/vi/assignment/vi_assignment.json @@ -75,15 +75,20 @@ }, "doAsm": { "deadline": "Hạn nộp", - "deadline2": "Hạn nộp (Tính từ ngày đăng ký)", - "AIGrading": "Chấm điểm bằng AI", - "description": "Sau khi bạn nộp bài và hoàn thành phần đánh giá chéo bắt buộc, bạn sẽ nhận được điểm do AI chấm dựa trên rubric của bài tập. Sau đó, bạn có thể chọn để bài tập được đánh giá bởi các bạn học khác.", - "subDes": "Dữ liệu của bạn sẽ được sử dụng theo", - "subDesLink": "Thông báo Quyền riêng tư của chúng tôi", "mySub": "Bài nộp của tôi", - "projectTitle": "Tiêu đề dự án", "placeholder": "Nhập câu trả lời của bạn...", - "saveDraft": "Đã lưu bản nháp lúc {time}." + "saveDraft": "Đã lưu bản nháp lúc {time}", + "restoring": "Đang khôi phục dữ liệu bài làm...", + "fileError": "Chỉ chấp nhận tệp .pdf, .doc hoặc .docx.", + "uploadClick": "Nhấn vào để upload", + "uploadDrag": "hoặc kéo thả", + "uploadFormat": "PDF, DOC, hoặc DOCX", + "restoreSuccess": "Đã khôi phục bài làm chưa nộp của bạn.", + "submitSuccess": "Nộp bài thành công!", + "submitFail": "Nộp bài thất bại. Vui lòng thử lại sau.", + "uploadFail": "Tải lên tệp thất bại cho Câu hỏi {index}.", + "invalidData": "Không thể nộp bài. Dữ liệu không hợp lệ.", + "noAsmFound": "Không tìm thấy bài tập. Vui lòng quay lại và chọn bài tập." } }, "upsert": { diff --git a/src/features/assignment/components/attempt/AssigmentAttempt.tsx b/src/features/assignment/components/attempt/AssigmentAttempt.tsx index 86f55d59..cd43c1b3 100644 --- a/src/features/assignment/components/attempt/AssigmentAttempt.tsx +++ b/src/features/assignment/components/attempt/AssigmentAttempt.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react' import { Button } from '@/components/shadcn/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/shadcn/card' import { Badge } from '@/components/shadcn/badge' -import { CheckCircle, Clock, ExternalLink, FileText, Loader2, RotateCcw, Trophy } from 'lucide-react' +import { CheckCircle, Clock, ExternalLink, FileText, RotateCcw, Trophy } from 'lucide-react' import Link from 'next/link' import { useGetStudentAssignmentByIdQuery } from '@/features/assignment/api/studentAssignmentApi' import LoadingComponent from '@/components/shared/loading/LoadingComponent' @@ -13,7 +13,6 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogTrigger, DialogClose, DialogFooter } from '@/components/shadcn/dialog' @@ -21,7 +20,6 @@ import { StudentAssignmentDetail, StudentAssignmentStatus } from '../../types/as import { useParams, useRouter } from 'next/navigation' import { useAppDispatch } from '@/hooks/redux-hooks' import { setSelectedAssignment, setSelectedStudentAssignment } from '@/features/assignment/slice/studentAssignmentSlice' -import { Separator } from 'radix-ui' import { useTranslations } from 'next-intl' // --- Helper Functions --- @@ -174,6 +172,15 @@ export default function AssignmentAttempt({ studentAssignmentId, assignmentId }: dispatch(setSelectedAssignment(assignmentDetail.data)) dispatch(setSelectedStudentAssignment(studentAssignmentResponse.data)) + localStorage.setItem( + 'assignment_session_backup', + JSON.stringify({ + assignment: assignmentDetail.data, + studentAssignment: studentAssignmentResponse.data, + timestamp: Date.now() + }) + ) + router.push(`${lessonId}/assignment/${assignmentId}`) } @@ -376,4 +383,4 @@ export default function AssignmentAttempt({ studentAssignmentId, assignmentId }:
) -} +} \ No newline at end of file diff --git a/src/features/assignment/components/attempt/AssignmentSubmission.tsx b/src/features/assignment/components/attempt/AssignmentSubmission.tsx index f1b1f65e..201a642f 100644 --- a/src/features/assignment/components/attempt/AssignmentSubmission.tsx +++ b/src/features/assignment/components/attempt/AssignmentSubmission.tsx @@ -1,24 +1,23 @@ 'use client' -import { Sparkles, FileText, UploadCloud, X, Loader2, Save } from 'lucide-react' +import { FileText, UploadCloud, X, Loader2, Save } from 'lucide-react' import { useCreateAssignmentAttemptMutation } from '@/features/assignment/api/studentAssignmentApi' -import { Assignment, AssignmentQuestion, AssignmentQuestionType } from '@/features/assignment/types/assignment.type' +import { AssignmentQuestionType } from '@/features/assignment/types/assignment.type' import { toast } from 'sonner' import { CreateAttemptPayload, QuestionAttemptPayload } from '@/features/assignment/types/assigmentlistdetail.type' -import { useEffect, useState } from 'react' +import { useState, useEffect } from 'react' import { Button } from '@/components/shadcn/button' -import { useParams, useRouter, useSearchParams } from 'next/navigation' -import { Card, CardContent } from '@/components/shadcn/card' -import { Label } from '@/components/shadcn/label' -import { Input } from '@/components/shadcn/input' +import { useRouter } from 'next/navigation' import { Textarea } from '@/components/shadcn/textarea' -import { useAppSelector } from '@/hooks/redux-hooks' +import { useAppSelector, useAppDispatch } from '@/hooks/redux-hooks' +import { setSelectedAssignment, setSelectedStudentAssignment } from '@/features/assignment/slice/studentAssignmentSlice' import BackButton from '@/components/shared/button/BackButton' import SEmpty from '@/components/shared/empty/SEmpty' -import { fileToBase64, formatDate, formatDateV2 } from '@/utils/index' +import { fileToBase64, formatDate } from '@/utils/index' import { useLocale, useTranslations } from 'next-intl' import { set, get, del } from 'idb-keyval' const FileInput = ({ file, onFileChange }: { file: File | null; onFileChange: (file: File | null) => void }) => { + const t = useTranslations('assignment.student.doAsm') const [isDragging, setIsDragging] = useState(false) const handleDragOver = (e: React.DragEvent) => { @@ -41,7 +40,7 @@ const FileInput = ({ file, onFileChange }: { file: File | null; onFileChange: (f ) { onFileChange(droppedFile) } else { - toast.error('Only .pdf, .doc, or .docx files are allowed.') + toast.error(t('fileError')) } } const handleFileChange = (e: React.ChangeEvent) => { @@ -82,9 +81,9 @@ const FileInput = ({ file, onFileChange }: { file: File | null; onFileChange: (f >

- Nhấn vào để upload hoặc kéo thả + {t('uploadClick')} {t('uploadDrag')}

-

PDF, DOC, hoặc DOCX

+

{t('uploadFormat')}

state.studentAssignmentSelected) - console.log(selectedAssignment, selectedStudentAssignment) - const [createAttempt, { isLoading: isSubmitting }] = useCreateAssignmentAttemptMutation() - const [projectTitle, setProjectTitle] = useState('') const [answers, setAnswers] = useState>({}) - const [isDataRestored, setIsDataRestored] = useState(false) + const [isRestoringSession, setIsRestoringSession] = useState(true) + const [isDraftLoaded, setIsDraftLoaded] = useState(false) const [lastSaved, setLastSaved] = useState(null) const storageKey = selectedStudentAssignment ? `draft_submission_${selectedStudentAssignment.id}` : null + useEffect(() => { + if (!selectedAssignment || !selectedStudentAssignment) { + try { + const backupData = localStorage.getItem('assignment_session_backup') + if (backupData) { + const parsedData = JSON.parse(backupData) + if (parsedData.assignment && parsedData.studentAssignment) { + dispatch(setSelectedAssignment(parsedData.assignment)) + dispatch(setSelectedStudentAssignment(parsedData.studentAssignment)) + } + } + } catch (error) { + console.error('Failed to restore assignment session:', error) + } + } + const timer = setTimeout(() => setIsRestoringSession(false), 500) + return () => clearTimeout(timer) + }, [selectedAssignment, selectedStudentAssignment, dispatch]) + useEffect(() => { const loadDraft = async () => { if (!storageKey) return @@ -121,19 +138,22 @@ export default function AssignmentSubmissionForm() { const savedData = await get(storageKey) if (savedData) { setAnswers(savedData) - toast.info(tt('restoreData'), { duration: 3000 }) + toast.info(tStudent('restoreSuccess'), { duration: 3000 }) } } catch (error) { console.error('Failed to load draft:', error) } finally { - setIsDataRestored(true) + setIsDraftLoaded(true) } } - loadDraft() - }, [storageKey]) + + if (storageKey) { + loadDraft() + } + }, [storageKey, tStudent]) useEffect(() => { - if (!isDataRestored || !storageKey || Object.keys(answers).length === 0) return + if (!isDraftLoaded || !storageKey || Object.keys(answers).length === 0) return const saveDraft = async () => { try { @@ -145,9 +165,9 @@ export default function AssignmentSubmissionForm() { } const timeoutId = setTimeout(saveDraft, 1000) - return () => clearTimeout(timeoutId) - }, [answers, storageKey, isDataRestored]) + }, [answers, storageKey, isDraftLoaded]) + const handleAnswerChange = (questionId: number, value: string) => { setAnswers((prev) => ({ @@ -155,6 +175,7 @@ export default function AssignmentSubmissionForm() { [questionId]: { ...prev[questionId], text: value } })) } + const handleFileChange = (questionId: number, file: File | null) => { setAnswers((prev) => ({ ...prev, @@ -164,7 +185,7 @@ export default function AssignmentSubmissionForm() { const handleSubmit = async () => { if (!selectedAssignment || !selectedStudentAssignment) { - toast.error(tt('submitAsmDataFail')) + toast.error(tStudent('invalidData')) return } @@ -185,7 +206,7 @@ export default function AssignmentSubmissionForm() { const base64File = await fileToBase64(answer.file) attempt.answerFile = base64File } catch (error) { - toast.error(tt('submitFileFail', { questionIndex: question.orderIndex })) + toast.error(tStudent('uploadFail', { index: question.orderIndex })) return } } @@ -200,23 +221,36 @@ export default function AssignmentSubmissionForm() { try { await createAttempt({ body: payload }).unwrap() - + if (storageKey) { await del(storageKey) } - - toast.success(tt('submitAsmSuccess')) + localStorage.removeItem('assignment_session_backup') + + toast.success(tStudent('submitSuccess')) router.back() } catch (error) { - toast.error(tt('submitAsmFail')) + toast.error(tStudent('submitFail')) console.error(error) } } + if (isRestoringSession && (!selectedAssignment || !selectedStudentAssignment)) { + return ( +
+ + {tStudent('restoring')} +
+ ) + } + if (!selectedAssignment || !selectedStudentAssignment) { return (
- + +
+ +
) } @@ -230,20 +264,18 @@ export default function AssignmentSubmissionForm() {

{selectedAssignment.title}

-
-
- {t('student.doAsm.deadline')}{' '} - {formatDate(selectedStudentAssignment.dueDate, { showTime: true, locale: locale === 'vi' ? 'vi' : 'en' })} -
- - {lastSaved && ( -
- - {t('student.doAsm.saveDraft', { - time: formatDate(lastSaved.toISOString(), { showTime: true, locale: locale === 'vi' ? 'vi' : 'en' }) - })} +
+
+ {tStudent('deadline')}{' '} + {formatDate(selectedStudentAssignment.dueDate, { showTime: true, locale: locale === 'vi' ? 'vi' : 'en' })}
- )} + + {lastSaved && ( +
+ + {tStudent('saveDraft', { time: formatDate(lastSaved.toISOString(), { showTime: true, locale: locale === 'vi' ? 'vi' : 'en' }) })} +
+ )}
@@ -251,19 +283,18 @@ export default function AssignmentSubmissionForm() {
{/* Submission Form */} - {true && ( -
+
{questions.map((question) => (

- {t('teacher.modal.question')} {question.orderIndex} ({question.points} {t('teacher.modal.point')}) + {t('teacher.modal.question')} {question.orderIndex} ({question.points} {t('teacher.modal.point')})

{question.content}

@@ -272,7 +303,7 @@ export default function AssignmentSubmissionForm() {