From cf7d90b8d69e5285f11e5763fc1c4bec783e811c Mon Sep 17 00:00:00 2001 From: meewaldor Date: Fri, 28 Nov 2025 13:32:35 +0700 Subject: [PATCH 01/63] fix(ContentDetail): update assignment creation to navigate instead of opening modal --- .../emulator/components/workspace-3d/Workspace3dLibrary.tsx | 2 +- .../resource/content/components/detail/ContentDetail.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx b/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx index 702c0ad72..7098b4c03 100644 --- a/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx +++ b/src/features/emulator/components/workspace-3d/Workspace3dLibrary.tsx @@ -27,7 +27,7 @@ export default function Workspace3dLibrary() { const userId = useAppSelector((state) => state.auth.user?.userId) - const { data, isLoading } = useSearchEmulationsQuery({ page: 1, userId: userId }) +const { data, isLoading } = useSearchEmulationsQuery({ page: 1 }) const [updateEmulation] = useUpdateEmulatorMutation() const emulations = data?.data.items || [] diff --git a/src/features/resource/content/components/detail/ContentDetail.tsx b/src/features/resource/content/components/detail/ContentDetail.tsx index af13fb206..bf0e568f1 100644 --- a/src/features/resource/content/components/detail/ContentDetail.tsx +++ b/src/features/resource/content/components/detail/ContentDetail.tsx @@ -36,7 +36,8 @@ export default function ContentDetail({ item, sectionId }: ContentDetailProps) { const handleCreateAssignment = () => { closeModal() - openModal('createAssignmentInfo', { sectionId: Number(sectionId) }) + router.push(`/${locale}/admin/lesson/${lessonId}/section/${sectionId}/assignment`) + // openModal('createAssignmentInfo', { sectionId: Number(sectionId) }) } // Nếu không có data From 2a46633a189d7671d04c211dd4521868a019f4f0 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sat, 29 Nov 2025 14:45:01 +0700 Subject: [PATCH 02/63] fix --- .../components/SystemOrganizationList.tsx | 247 +----------------- .../components/SystemOrganizationListV1.tsx | 246 +++++++++++++++++ .../detail/system/OrganizationAdmins.tsx | 21 +- 3 files changed, 262 insertions(+), 252 deletions(-) create mode 100644 src/features/organization/components/SystemOrganizationListV1.tsx diff --git a/src/features/organization/components/SystemOrganizationList.tsx b/src/features/organization/components/SystemOrganizationList.tsx index 1146130d0..d51c8a106 100644 --- a/src/features/organization/components/SystemOrganizationList.tsx +++ b/src/features/organization/components/SystemOrganizationList.tsx @@ -1,246 +1,11 @@ 'use client' -import { Fragment, useEffect, useState } from 'react' -import { Button } from '@/components/shadcn/button' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/shadcn/table' -import { Pencil, Trash2, ChevronDown, ChevronRight, Search, GraduationCap, Building2 } from 'lucide-react' -import { useModal } from '@/providers/ModalProvider' -import { toast } from 'sonner' -import { useTranslations } from 'next-intl' -import { - useDeleteOrganizationMutation, - useSearchOrganizationsQuery, - useUpdateOrganizationMutation -} from '@/features/organization/api/organizationApi' -import Image from 'next/image' -import { formatDate, useStatusTranslation } from '@/utils/index' -import SystemSubscriptionTable from '@/features/subscription/components/list/SystemSubscriptionTable' -import { Input } from '@/components/shadcn/input' -import SSelect from '@/components/shared/SSelect' -import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' -import { setPageIndex, setParam, setSearchTerm } from '@/features/organization/slice/organizationSlice' -import { OrganizationStatus } from '@/features/organization/types/organization.type' -import useDebounce from '@/hooks/useDebounce' -import { SPagination } from '@/components/shared/SPagination' -import LoadingComponent from '@/components/shared/loading/LoadingComponent' -import SEmpty from '@/components/shared/empty/SEmpty' -import SStatusDropdown from '@/components/shared/SStatusDropdown' +import { useSearchOrganizationsQuery } from '@/features/organization/api/organizationApi' +import { useAppSelector } from '@/hooks/redux-hooks' +import React from 'react' export default function SystemOrganizationList() { - const t = useTranslations('subscription') - const tc = useTranslations('common') - const tt = useTranslations('toast') - const translateStatus = useStatusTranslation() - - const dispatch = useAppDispatch() - const [search, setSearch] = useState('') - const debouncedSearchQuery = useDebounce(search, 500) - - const { openModal } = useModal() - const [expandedOrganizations, setExpandedOrganizations] = useState([]) - const queryParams = useAppSelector((state) => state.organization) - const { data, isLoading, refetch } = useSearchOrganizationsQuery(queryParams) - const organizations = data?.data.items || [] - - useEffect(() => { - dispatch(setSearchTerm(debouncedSearchQuery)) - }, [debouncedSearchQuery, dispatch]) - const [updateOrganization] = useUpdateOrganizationMutation() - const [deleteOrganization] = useDeleteOrganizationMutation() - const toggleExpand = (organizationId: number) => { - setExpandedOrganizations((prev) => - prev.includes(organizationId) ? prev.filter((id) => id !== organizationId) : [...prev, organizationId] - ) - } - const handlePageChange = (newPage: number) => { - dispatch(setPageIndex(newPage)) - } - - // translate to multilingual options - const organizationStatusOptions = [ - { label: tc('status.all'), value: 'all' }, - ...Object.entries(OrganizationStatus).map(([key, value]) => ({ - label: translateStatus(key), - value - })) - ] - - const organizationStatusOptionsNotTranslated = [ - { label: 'All', value: 'all' }, - ...Object.entries(OrganizationStatus).map(([key, value]) => ({ - label: key.charAt(0).toUpperCase() + key.slice(1).toLowerCase(), - value - })) - ] - - if (isLoading) { - return ( -
- -
- ) - } - if (!data) { - return - } - - const handleStatusChange = (organization: any, newStatus: string) => { - updateOrganization({ id: organization.id, body: { status: newStatus as OrganizationStatus } }) - .unwrap() - .then(() => toast.success(tt('successMessage.update', { title: newStatus }))) - } - return ( -
-
-
-
-

{t('list.organizationSubscriptionTitle')}

-

{t('list.organizationSubscriptionDescription')}

-
-
- -
-
- -
- {/* 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' - /> - -
- { - if (val === 'all') { - dispatch(setParam({ key: 'status', value: undefined })) - } else { - dispatch(setParam({ key: 'status', value: val as OrganizationStatus })) - } - }} - options={organizationStatusOptions} - /> -
- -
- - - - - {tc('tableHeader.image')} - {tc('tableHeader.name')} - {tc('tableHeader.organizationType')} - {tc('tableHeader.status')} - {tc('tableHeader.createdDate')} - {tc('tableHeader.actions')} - - - - {organizations.map((organization) => ( - - toggleExpand(organization.id)}> - - - - -
- {organization.imageUrl ? ( - - ) : ( -
- -
- )} -
-
- - {organization.name} - {organization.organizationType} - - opt.value !== 'all' && opt.value !== OrganizationStatus.ARCHIVED - )} - onChange={(newStatus) => handleStatusChange(organization, newStatus)} - />{' '} - - {formatDate(organization.createdDate)} - - -
- - -
-
-
- - {expandedOrganizations.includes(organization.id) && ( - - - - - - )} -
- ))} -
-
-
-
- {data?.data?.totalPages > 1 && ( - - )} -
-
-
- ) + const planSliceParams = useAppSelector((state) => state.organization) + const { data, isLoading } = useSearchOrganizationsQuery(planSliceParams) + return
SystemOrganizationList
} diff --git a/src/features/organization/components/SystemOrganizationListV1.tsx b/src/features/organization/components/SystemOrganizationListV1.tsx new file mode 100644 index 000000000..1146130d0 --- /dev/null +++ b/src/features/organization/components/SystemOrganizationListV1.tsx @@ -0,0 +1,246 @@ +'use client' + +import { Fragment, useEffect, useState } from 'react' +import { Button } from '@/components/shadcn/button' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/shadcn/table' +import { Pencil, Trash2, ChevronDown, ChevronRight, Search, GraduationCap, Building2 } from 'lucide-react' +import { useModal } from '@/providers/ModalProvider' +import { toast } from 'sonner' +import { useTranslations } from 'next-intl' +import { + useDeleteOrganizationMutation, + useSearchOrganizationsQuery, + useUpdateOrganizationMutation +} from '@/features/organization/api/organizationApi' +import Image from 'next/image' +import { formatDate, useStatusTranslation } from '@/utils/index' +import SystemSubscriptionTable from '@/features/subscription/components/list/SystemSubscriptionTable' +import { Input } from '@/components/shadcn/input' +import SSelect from '@/components/shared/SSelect' +import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' +import { setPageIndex, setParam, setSearchTerm } from '@/features/organization/slice/organizationSlice' +import { OrganizationStatus } from '@/features/organization/types/organization.type' +import useDebounce from '@/hooks/useDebounce' +import { SPagination } from '@/components/shared/SPagination' +import LoadingComponent from '@/components/shared/loading/LoadingComponent' +import SEmpty from '@/components/shared/empty/SEmpty' +import SStatusDropdown from '@/components/shared/SStatusDropdown' + +export default function SystemOrganizationList() { + const t = useTranslations('subscription') + const tc = useTranslations('common') + const tt = useTranslations('toast') + const translateStatus = useStatusTranslation() + + const dispatch = useAppDispatch() + const [search, setSearch] = useState('') + const debouncedSearchQuery = useDebounce(search, 500) + + const { openModal } = useModal() + const [expandedOrganizations, setExpandedOrganizations] = useState([]) + const queryParams = useAppSelector((state) => state.organization) + const { data, isLoading, refetch } = useSearchOrganizationsQuery(queryParams) + const organizations = data?.data.items || [] + + useEffect(() => { + dispatch(setSearchTerm(debouncedSearchQuery)) + }, [debouncedSearchQuery, dispatch]) + const [updateOrganization] = useUpdateOrganizationMutation() + const [deleteOrganization] = useDeleteOrganizationMutation() + const toggleExpand = (organizationId: number) => { + setExpandedOrganizations((prev) => + prev.includes(organizationId) ? prev.filter((id) => id !== organizationId) : [...prev, organizationId] + ) + } + const handlePageChange = (newPage: number) => { + dispatch(setPageIndex(newPage)) + } + + // translate to multilingual options + const organizationStatusOptions = [ + { label: tc('status.all'), value: 'all' }, + ...Object.entries(OrganizationStatus).map(([key, value]) => ({ + label: translateStatus(key), + value + })) + ] + + const organizationStatusOptionsNotTranslated = [ + { label: 'All', value: 'all' }, + ...Object.entries(OrganizationStatus).map(([key, value]) => ({ + label: key.charAt(0).toUpperCase() + key.slice(1).toLowerCase(), + value + })) + ] + + if (isLoading) { + return ( +
+ +
+ ) + } + if (!data) { + return + } + + const handleStatusChange = (organization: any, newStatus: string) => { + updateOrganization({ id: organization.id, body: { status: newStatus as OrganizationStatus } }) + .unwrap() + .then(() => toast.success(tt('successMessage.update', { title: newStatus }))) + } + return ( +
+
+
+
+

{t('list.organizationSubscriptionTitle')}

+

{t('list.organizationSubscriptionDescription')}

+
+
+ +
+
+ +
+ {/* 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' + /> + +
+ { + if (val === 'all') { + dispatch(setParam({ key: 'status', value: undefined })) + } else { + dispatch(setParam({ key: 'status', value: val as OrganizationStatus })) + } + }} + options={organizationStatusOptions} + /> +
+ +
+ + + + + {tc('tableHeader.image')} + {tc('tableHeader.name')} + {tc('tableHeader.organizationType')} + {tc('tableHeader.status')} + {tc('tableHeader.createdDate')} + {tc('tableHeader.actions')} + + + + {organizations.map((organization) => ( + + toggleExpand(organization.id)}> + + + + +
+ {organization.imageUrl ? ( + + ) : ( +
+ +
+ )} +
+
+ + {organization.name} + {organization.organizationType} + + opt.value !== 'all' && opt.value !== OrganizationStatus.ARCHIVED + )} + onChange={(newStatus) => handleStatusChange(organization, newStatus)} + />{' '} + + {formatDate(organization.createdDate)} + + +
+ + +
+
+
+ + {expandedOrganizations.includes(organization.id) && ( + + + + + + )} +
+ ))} +
+
+
+
+ {data?.data?.totalPages > 1 && ( + + )} +
+
+
+ ) +} diff --git a/src/features/subscription/components/detail/system/OrganizationAdmins.tsx b/src/features/subscription/components/detail/system/OrganizationAdmins.tsx index 42e3bd0c7..5fc39016e 100644 --- a/src/features/subscription/components/detail/system/OrganizationAdmins.tsx +++ b/src/features/subscription/components/detail/system/OrganizationAdmins.tsx @@ -157,7 +157,7 @@ export default function OrganizationAdmins({ organizationSubscriptionOrderId }: - +
Email @@ -170,13 +170,11 @@ export default function OrganizationAdmins({ organizationSubscriptionOrderId }:
{to('license.role')} - {to('license.status')} -
- - {to('license.assignedAt')} -
+ {to('license.assignedAt')}
+ {to('license.status')} +
@@ -215,16 +213,17 @@ export default function OrganizationAdmins({ organizationSubscriptionOrderId }: {tc(`accountType.${assignment.type.toLowerCase()}`)} - - - {statusTranslate(assignment.status)} - - + {formatDate(assignment.assignedAt, { locale: locale as 'en' | 'vi' })} + + + {statusTranslate(assignment.status)} + + + + + +
+ {/* 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' + /> + +
+ { + if (val === 'all') { + dispatch(setParam({ key: 'status', value: undefined })) + } else { + dispatch(setParam({ key: 'status', value: val as OrganizationStatus })) + } + }} + options={organizationStatusOptions} + /> +
+ + + + + ) } From 237c8b1fdf91fd7f0172956d6897a5d79753ea58 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sat, 29 Nov 2025 16:59:41 +0700 Subject: [PATCH 05/63] feat: Implement organization management features with CRUD operations - Added SystemOrganizationColumn component for displaying organization data in a table format. - Created SystemOrganizationList component to manage the list of organizations, including search and filter functionalities. - Developed UpsertOrganization component for creating and updating organization details. - Introduced UpsertOrganizationModal for handling modal interactions for organization upsert actions. - Enhanced SystemSubscriptionColumn and SystemSubscriptionTable components for managing organization subscriptions. - Integrated status management for organizations and subscriptions with dropdowns for status updates. - Implemented loading states and error handling for API interactions. - Added translations for various UI elements to support internationalization. --- messages/en/organization/en_organization.json | 3 + messages/vi/common/vi_common.json | 4 +- messages/vi/organization/vi_organization.json | 3 + .../organization/[organizationId]/page.tsx | 12 +- .../admin/(biz)/organization/page.tsx | 2 +- .../components/detail/OrganizationDetail.tsx | 101 +++++++++ .../{ => list}/SystemOrganizationColumn.tsx | 0 .../{ => list}/SystemOrganizationList.tsx | 2 +- .../{ => list}/SystemOrganizationListV1.tsx | 0 .../{ => upsert}/UpsertOrganization.tsx | 4 +- .../{ => upsert}/UpsertOrganizationModal.tsx | 2 +- .../organization/types/organization.type.ts | 2 +- .../list/SystemSubscriptionColumn.tsx | 192 ++++++++++++++++ .../list/SystemSubscriptionTable copy.tsx | 209 ++++++++++++++++++ .../list/SystemSubscriptionTable.tsx | 203 ++--------------- src/providers/ModalProvider.tsx | 2 +- 16 files changed, 543 insertions(+), 198 deletions(-) create mode 100644 src/features/organization/components/detail/OrganizationDetail.tsx rename src/features/organization/components/{ => list}/SystemOrganizationColumn.tsx (100%) rename src/features/organization/components/{ => list}/SystemOrganizationList.tsx (98%) rename src/features/organization/components/{ => list}/SystemOrganizationListV1.tsx (100%) rename src/features/organization/components/{ => upsert}/UpsertOrganization.tsx (98%) rename src/features/organization/components/{ => upsert}/UpsertOrganizationModal.tsx (97%) create mode 100644 src/features/subscription/components/list/SystemSubscriptionColumn.tsx create mode 100644 src/features/subscription/components/list/SystemSubscriptionTable copy.tsx diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index 14967151d..2ea2072b5 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -9,6 +9,8 @@ "noData": "No organization data available.", "noSubscription": "No subscriptions found for this organization.", "header": "Organization Details", + "status": "Status", + "description": "Description", "organizationType": "Organization Type", "createdAt": "Created At", "updatedAt": "Updated At", @@ -70,6 +72,7 @@ "subscription": { "header": "Create Organization Subscription", "subHeader": "Follow the steps to set up your organization", + "months": " months", "step1": { "title": "Configure Subscription", "description": "Select plan and options" diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index ce107a48f..ceff61b30 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -168,8 +168,8 @@ "netAmount": "Số Tiền Sau Giảm", "lastModified": "Lần Chỉnh Sửa Cuối", "grossAmount": "Tổng Số Tiền", - "studentSeats": "Ghế Học Sinh", - "teacherSeats": "Ghế Giáo Viên", + "studentSeats": "Học Sinh", + "teacherSeats": "Giáo Viên", "avatar": "Ảnh Đại Diện", "action": "Hành Động", "createPlan": "Tạo Gói", diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index 9fb46bc0d..f52b7d805 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -9,6 +9,8 @@ "noData": "Không có dữ liệu tổ chức nào.", "noSubscription": "Không có gói đăng ký nào cho tổ chức này.", "header": "Chi tiết tổ chức", + "status": "Trạng thái", + "description": "Mô tả", "organizationType": "Loại tổ chức", "createdAt": "Ngày tạo", "updatedAt": "Ngày cập nhật", @@ -69,6 +71,7 @@ "subscription": { "header": "Tạo gói đăng ký tổ chức", "subHeader": "Làm theo các bước để thiết lập tổ chức của bạn", + "months": " tháng", "step1": { "title": "Cấu hình gói đăng ký", "description": "Chọn gói và các tùy chọn" diff --git a/src/app/[locale]/admin/(biz)/organization/[organizationId]/page.tsx b/src/app/[locale]/admin/(biz)/organization/[organizationId]/page.tsx index f8f8d5c29..e0631d73c 100644 --- a/src/app/[locale]/admin/(biz)/organization/[organizationId]/page.tsx +++ b/src/app/[locale]/admin/(biz)/organization/[organizationId]/page.tsx @@ -1,9 +1,17 @@ -import OrganizationDetail from '@/features/subscription/components/detail/system/SystemSubscriptionDetail' +'use client' +import BackButton from '@/components/shared/button/BackButton' +import OrganizationDetail from '@/features/organization/components/detail/OrganizationDetail' +import { useTranslations } from 'next-intl' import React from 'react' export default function OrganizationDetailPage() { + const t = useTranslations('organization') return ( -
+
+
+ +

{t('detail.header')}

+
) diff --git a/src/app/[locale]/admin/(biz)/organization/page.tsx b/src/app/[locale]/admin/(biz)/organization/page.tsx index cb91ee5ab..06cf0dee6 100644 --- a/src/app/[locale]/admin/(biz)/organization/page.tsx +++ b/src/app/[locale]/admin/(biz)/organization/page.tsx @@ -1,4 +1,4 @@ -import SystemOrganizationList from '@/features/organization/components/SystemOrganizationList' +import SystemOrganizationList from '@/features/organization/components/list/SystemOrganizationList' import React from 'react' export default function OrganizationSubscriptionListPage() { diff --git a/src/features/organization/components/detail/OrganizationDetail.tsx b/src/features/organization/components/detail/OrganizationDetail.tsx new file mode 100644 index 000000000..767e21cf7 --- /dev/null +++ b/src/features/organization/components/detail/OrganizationDetail.tsx @@ -0,0 +1,101 @@ +'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 { useDeleteOrganizationMutation, useGetOrganizationByIdQuery } from '@/features/organization/api/organizationApi' +import SystemSubscriptionTable from '@/features/subscription/components/list/SystemSubscriptionTable' +import { useModal } from '@/providers/ModalProvider' +import { getStatusBadgeClass } from '@/utils/badgeColor' +import { formatDate, useStatusTranslation } from '@/utils/index' +import { SquarePen, Trash2 } from 'lucide-react' +import { useLocale, useTranslations } from 'next-intl' +import Image from 'next/image' +import { useParams } from 'next/navigation' +import React from 'react' + +export default function OrganizationDetail() { + const locale = useLocale() + + const to = useTranslations('organization.detail') + const tt = useTranslations('toast') + const translateStatus = useStatusTranslation() + + const { openModal } = useModal() + const { organizationId } = useParams() + const { data: organization, isLoading } = useGetOrganizationByIdQuery(Number(organizationId)) + const [deleteOrganization] = useDeleteOrganizationMutation() + + const handleDelete = async (id: number) => { + await deleteOrganization(id) + } + + if (!organization) { + return + } + return ( +
+
+
+ {/*

{organization.data.code}

*/} +
+

{organization.data.name}

+ + { + openModal('upsertOrganization', { organizationId: organization.data.id }) + }} + /> + + + { + openModal('confirm', { + message: `${tt('confirmMessage.delete', { title: organization.data.name })}`, + onConfirm: () => handleDelete(organization.data.id) + }) + }} + /> + +
+ +
+

Ngày tạo: {formatDate(organization.data.createdDate, { locale: locale as 'en' | 'vi' | undefined })}

+

+ Chỉnh sửa gần nhất:{' '} + {formatDate(organization.data.lastModifiedDate, { locale: locale as 'en' | 'vi' | undefined })} +

+
+
+ + {to('status')}:{' '} + + {translateStatus(organization.data.status)} + + +
+
+ + {/* Description */} +

{to('description')}

+

{organization.data.description}

+
+ + {/* RIGHT: Thumbnail, Metadata, Actions */} +
+ {/* Thumbnail */} +
+ +
+
+
+ + +
+ ) +} diff --git a/src/features/organization/components/SystemOrganizationColumn.tsx b/src/features/organization/components/list/SystemOrganizationColumn.tsx similarity index 100% rename from src/features/organization/components/SystemOrganizationColumn.tsx rename to src/features/organization/components/list/SystemOrganizationColumn.tsx diff --git a/src/features/organization/components/SystemOrganizationList.tsx b/src/features/organization/components/list/SystemOrganizationList.tsx similarity index 98% rename from src/features/organization/components/SystemOrganizationList.tsx rename to src/features/organization/components/list/SystemOrganizationList.tsx index 314a83d2e..3de3bd260 100644 --- a/src/features/organization/components/SystemOrganizationList.tsx +++ b/src/features/organization/components/list/SystemOrganizationList.tsx @@ -5,7 +5,7 @@ import { Input } from '@/components/shadcn/input' import { DataTable } from '@/components/shared/data-table/data-table' import SSelect from '@/components/shared/SSelect' import { useSearchOrganizationsQuery } from '@/features/organization/api/organizationApi' -import { useGetOrganizationColumn } from '@/features/organization/components/SystemOrganizationColumn' +import { useGetOrganizationColumn } from '@/features/organization/components/list/SystemOrganizationColumn' import { setPageIndex, setParam } from '@/features/organization/slice/organizationSlice' import { OrganizationStatus } from '@/features/organization/types/organization.type' import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' diff --git a/src/features/organization/components/SystemOrganizationListV1.tsx b/src/features/organization/components/list/SystemOrganizationListV1.tsx similarity index 100% rename from src/features/organization/components/SystemOrganizationListV1.tsx rename to src/features/organization/components/list/SystemOrganizationListV1.tsx diff --git a/src/features/organization/components/UpsertOrganization.tsx b/src/features/organization/components/upsert/UpsertOrganization.tsx similarity index 98% rename from src/features/organization/components/UpsertOrganization.tsx rename to src/features/organization/components/upsert/UpsertOrganization.tsx index f5c134dd3..921cd5ab9 100644 --- a/src/features/organization/components/UpsertOrganization.tsx +++ b/src/features/organization/components/upsert/UpsertOrganization.tsx @@ -89,7 +89,7 @@ export default function UpsertOrganization({ organizationId, onSuccess }: Upsert }) useEffect(() => { - if (organizationId && orgData?.data) { + if (organizationId && orgData?.data && orgTypes.length > 0) { const matchedType = orgTypes.find( (type) => type.name.toLowerCase() === orgData.data.organizationType.toLowerCase() ) @@ -102,7 +102,7 @@ export default function UpsertOrganization({ organizationId, onSuccess }: Upsert imageUrl: orgData.data.imageUrl }) } - }, [organizationId, orgData, form]) + }, [organizationId, orgData, orgTypes, form]) if ((organizationId && (!orgData || isOrgLoading)) || isOrgTypesLoading) { return ( diff --git a/src/features/organization/components/UpsertOrganizationModal.tsx b/src/features/organization/components/upsert/UpsertOrganizationModal.tsx similarity index 97% rename from src/features/organization/components/UpsertOrganizationModal.tsx rename to src/features/organization/components/upsert/UpsertOrganizationModal.tsx index e0173e276..cd625b7dc 100644 --- a/src/features/organization/components/UpsertOrganizationModal.tsx +++ b/src/features/organization/components/upsert/UpsertOrganizationModal.tsx @@ -1,6 +1,6 @@ import { Dialog, DialogContent } from '@/components/shadcn/dialog' import { ScrollArea } from '@/components/shadcn/scroll-area' -import UpsertOrganization from '@/features/organization/components/UpsertOrganization' +import UpsertOrganization from '@/features/organization/components/upsert/UpsertOrganization' import { useModal } from '@/providers/ModalProvider' import { DialogTitle } from '@radix-ui/react-dialog' import { useTranslations } from 'next-intl' diff --git a/src/features/organization/types/organization.type.ts b/src/features/organization/types/organization.type.ts index 4f0718185..a48e3f897 100644 --- a/src/features/organization/types/organization.type.ts +++ b/src/features/organization/types/organization.type.ts @@ -11,7 +11,7 @@ export type Organization = { status: OrganizationStatus createdDate: string lastModifiedDate: string - subscriptions: Partial[] + subscriptions: OrganizationSubscription[] } export type AdminOrganization = { diff --git a/src/features/subscription/components/list/SystemSubscriptionColumn.tsx b/src/features/subscription/components/list/SystemSubscriptionColumn.tsx new file mode 100644 index 000000000..a76ede483 --- /dev/null +++ b/src/features/subscription/components/list/SystemSubscriptionColumn.tsx @@ -0,0 +1,192 @@ +import React from 'react' +import { useTranslations } from 'next-intl' +import { ColumnDef } from '@tanstack/react-table' +import { useRouter } from 'next/navigation' +import { useModal } from '@/providers/ModalProvider' +import { toast } from 'sonner' +import { createActionsColumnFromItems, createSelectColumn } from '@/components/shared/data-table/columns-helpers' +import { useLocale } from 'next-intl' +import SStatusDropdown from '@/components/shared/SStatusDropdown' +import { formatDate, formatPrice, useStatusTranslation } from '@/utils/index' +import { + useDeleteSubscriptionMutation, + useUpdateSubscriptionMutation +} from '@/features/subscription/api/subscriptionApi' +import { OrganizationSubscription, SubscriptionStatus } from '@/features/subscription/types/subscription.type' +import { BillingCycle } from '@/features/plan/types/plan.type' + +export function useSystemSubscriptionColumn(): ColumnDef[] { + const router = useRouter() + const locale = useLocale() + const { openModal } = useModal() + const [deleteSubscription] = useDeleteSubscriptionMutation() + const [updateSubscription] = useUpdateSubscriptionMutation() + const tc = useTranslations('common') + const tt = useTranslations('toast') + const to = useTranslations('organization.subscription') + + const getBillingCycleLabel = (cycle: BillingCycle | string) => { + switch (cycle) { + case BillingCycle.SEMIANNUAL: + return `6 ${to('months')}` + case BillingCycle.ANNUAL: + return `12 ${to('months')}` + default: + return cycle + } + } + + const handleNavigate = (id: number) => { + router.push(`/${locale}/admin/organization/${id}`) + } + + const handleStatusChange = (subscription: any, newStatus: string) => { + updateSubscription({ + subscriptionId: subscription.id, + body: { + status: newStatus as SubscriptionStatus, + // add curriculumIds to avoid removing them unintentionally (for grpc compatibility) + curriculumIds: subscription.curriculumIds || [] + } + }) + } + + const handleArchive = async (subscription: OrganizationSubscription) => { + await updateSubscription({ + subscriptionId: subscription.id, + body: { status: SubscriptionStatus.ARCHIVED } + }) + toast.success(tt('successMessage.update', { title: SubscriptionStatus.ARCHIVED })) + } + + const handleDelete = async (id: number) => { + await deleteSubscription(id).unwrap() + toast.success(tt('successMessage.delete')) + } + + const SubscriptionStatusOption = [ + { label: 'Pending', value: SubscriptionStatus.PENDING }, + { label: 'Active', value: SubscriptionStatus.ACTIVE }, + { label: 'Archived', value: SubscriptionStatus.ARCHIVED }, + { label: 'Cancelled', value: SubscriptionStatus.CANCELLED }, + { label: 'Expired', value: SubscriptionStatus.EXPIRED } + ] + + const SubscriptionStatusFlow: Record = { + [SubscriptionStatus.PENDING]: [SubscriptionStatus.PENDING, SubscriptionStatus.ACTIVE, SubscriptionStatus.CANCELLED], + [SubscriptionStatus.ACTIVE]: [SubscriptionStatus.ACTIVE, SubscriptionStatus.CANCELLED], + [SubscriptionStatus.ARCHIVED]: [ + SubscriptionStatus.ACTIVE, + SubscriptionStatus.ARCHIVED, + SubscriptionStatus.CANCELLED + ], + [SubscriptionStatus.CANCELLED]: [], + [SubscriptionStatus.EXPIRED]: [] + } + + return [ + createSelectColumn(), + { + accessorKey: 'planName', + header: () =>
{tc('tableHeader.name')}
, + cell: ({ row }) => { + const subscriptionId = row.original.id + return ( +
+
handleNavigate(subscriptionId)} + className='cursor-pointer font-bold transition hover:opacity-80' + > + {row.original.planName} +
+
{getBillingCycleLabel(row.original.planBillingCycle)}
+
+ ) + }, + enableSorting: true + }, + + { + accessorKey: 'netAmount', + header: () =>
{tc('tableHeader.netAmount')}
, + cell: ({ row }) => { + return
{formatPrice(row.original.netAmount)}
+ } + }, + + { + accessorKey: 'teacherSeats', + header: () =>
{tc('tableHeader.teacherSeats')}
, + cell: ({ row }) => { + return
{row.original.maxTeacherSeats}
+ } + }, + { + accessorKey: 'studentSeats', + header: () =>
{tc('tableHeader.studentSeats')}
, + cell: ({ row }) => { + return
{row.original.maxStudentSeats}
+ } + }, + { + accessorKey: 'startDate', + header: () =>
{tc('tableHeader.startDate')}
, + cell: ({ row }) => { + return
{formatDate(row.original.startDate, { locale: locale as 'en' | 'vi' })}
+ } + }, + { + accessorKey: 'endDate', + header: () =>
{tc('tableHeader.endDate')}
, + cell: ({ row }) => { + return
{formatDate(row.original.endDate, { locale: locale as 'en' | 'vi' })}
+ } + }, + { + accessorKey: 'status', + header: () =>
{tc('tableHeader.status')}
, + cell: ({ row }) => { + const subscription = row.original + const allowedOptions = SubscriptionStatusOption.filter( + (sub) => + subscription.status && SubscriptionStatusFlow[subscription.status].includes(sub.value as SubscriptionStatus) + ) + return ( + handleStatusChange(row.original, newStatus)} + /> + ) + } + }, + createActionsColumnFromItems([ + { + label: tc('button.view'), + onClick: ({ original }) => { + router.push(`/${locale}/admin/organization/${original.id}`) + } + }, + { + label: tc('button.archive'), + archive: true, + onClick: async ({ original }) => { + openModal('confirm', { + message: tt('confirmMessage.archive', { title: original.planName }), + onConfirm: () => handleArchive(original) + }) + } + }, + { + label: tc('button.delete'), + danger: true, + onClick: async ({ original }) => { + openModal('confirm', { + message: tt('confirmMessage.delete', { title: original.planName }), + onConfirm: () => handleDelete(original.id) + }) + } + } + ]) + ] +} diff --git a/src/features/subscription/components/list/SystemSubscriptionTable copy.tsx b/src/features/subscription/components/list/SystemSubscriptionTable copy.tsx new file mode 100644 index 000000000..22b976e99 --- /dev/null +++ b/src/features/subscription/components/list/SystemSubscriptionTable copy.tsx @@ -0,0 +1,209 @@ +'use client' + +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/shadcn/table' +import { Organization } from '@/features/organization/types/organization.type' +import { BillingCycle } from '@/features/plan/types/plan.type' +import { formatDate, formatPrice } from '@/utils/index' +import React from 'react' +import { Card } from '@/components/shadcn/card' +import { useLocale, useTranslations } from 'next-intl' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/shadcn/button' +import { Edit2, Plus, Trash2 } from 'lucide-react' +import { useAppDispatch } from '@/hooks/redux-hooks' +import { useModal } from '@/providers/ModalProvider' +import { SubscriptionStatus } from '@/features/subscription/types/subscription.type' +import SStatusDropdown from '@/components/shared/SStatusDropdown' +import { useUpdateSubscriptionMutation } from '@/features/subscription/api/subscriptionApi' +import { toast } from 'sonner' + +type SystemSubscriptionTableProps = { + organization: Organization + refetchOrganization?: () => void +} + +export default function SystemSubscriptionTable({ organization, refetchOrganization }: SystemSubscriptionTableProps) { + const tc = useTranslations('common') + const tt = useTranslations('toast') + const to = useTranslations('organization.subscription') + + const router = useRouter() + const locale = useLocale() + const dispatch = useAppDispatch() + const { openModal } = useModal() + + const [updateSubscription] = useUpdateSubscriptionMutation() + + const getBillingCycleLabel = (cycle: BillingCycle | string) => { + switch (cycle) { + case BillingCycle.SEMIANNUAL: + return '6 Months' + case BillingCycle.ANNUAL: + return '12 Months' + default: + return cycle + } + } + + const subscriptionStatusOptions = [ + { label: 'All', value: 'all' }, + ...Object.entries(SubscriptionStatus).map(([key, value]) => ({ + label: key.charAt(0).toUpperCase() + key.slice(1).toLowerCase(), + value + })) + ] + + const handleStatusChange = (subscription: any, newStatus: string) => { + updateSubscription({ + subscriptionId: subscription.id, + body: { + status: newStatus as SubscriptionStatus, + // add curriculumIds to avoid removing them unintentionally (for grpc compatibility) + curriculumIds: subscription.curriculumIds || [] + } + }) + .unwrap() + .then(() => { + toast.success(tt('successMessage.update', { title: newStatus })) + refetchOrganization?.() + }) + .catch((error) => { + toast.error('Failed to update status') + console.error(error) + }) + } + + const SubscriptionStatusOption = [ + { label: 'Pending', value: SubscriptionStatus.PENDING }, + { label: 'Active', value: SubscriptionStatus.ACTIVE }, + { label: 'Archived', value: SubscriptionStatus.ARCHIVED }, + { label: 'Cancelled', value: SubscriptionStatus.CANCELLED }, + { label: 'Expired', value: SubscriptionStatus.EXPIRED } + ] + + const SubscriptionStatusFlow: Record = { + [SubscriptionStatus.PENDING]: [SubscriptionStatus.PENDING, SubscriptionStatus.ACTIVE, SubscriptionStatus.CANCELLED], + [SubscriptionStatus.ACTIVE]: [SubscriptionStatus.ACTIVE, SubscriptionStatus.CANCELLED, SubscriptionStatus.ARCHIVED], + [SubscriptionStatus.ARCHIVED]: [ + SubscriptionStatus.ACTIVE, + SubscriptionStatus.ARCHIVED, + SubscriptionStatus.CANCELLED + ], + [SubscriptionStatus.CANCELLED]: [], + [SubscriptionStatus.EXPIRED]: [] + } + + return ( +
+
+

{to('title')}

+ + +
+ + {organization.subscriptions.length === 0 ? ( +

{to('noData')}

+ ) : ( + +
+ + + {tc('tableHeader.planName')} + {tc('tableHeader.planBillingCycle')} + {tc('tableHeader.grossAmount')} + {tc('tableHeader.netAmount')} + {tc('tableHeader.studentSeats')} + {tc('tableHeader.teacherSeats')} + {tc('tableHeader.status')} + {tc('tableHeader.startDate')} + {tc('tableHeader.endDate')} + {tc('tableHeader.action')} + + + + + {organization.subscriptions.map((subscription, index) => { + const allowedOptions = SubscriptionStatusOption.filter( + (sub) => + subscription.status && + SubscriptionStatusFlow[subscription.status].includes(sub.value as SubscriptionStatus) + ) + return ( + + + router.push(`/${locale}/admin/organization/${organization.id}/subscription/${subscription.id}`) + } + > + {subscription.planName} + + {getBillingCycleLabel(subscription.planBillingCycle ?? 'N/A')} + + + {formatPrice(subscription.grossAmount ?? 0) ?? '-'} + + + + {formatPrice(subscription.netAmount ?? 0) ?? '-'} + + + +

+ {subscription.maxStudentSeats ?? '-'} +

+
+ + +

+ {subscription.maxTeacherSeats ?? '-'} +

+
+ + handleStatusChange(subscription, newStatus)} + />{' '} + + + + {formatDate(subscription.startDate ?? 'N/A')} + + + {formatDate(subscription.endDate ?? 'N/A')} + + +
+ + +
+
+
+ ) + })} +
+
+ + )} + + ) +} diff --git a/src/features/subscription/components/list/SystemSubscriptionTable.tsx b/src/features/subscription/components/list/SystemSubscriptionTable.tsx index 22b976e99..c98e54fda 100644 --- a/src/features/subscription/components/list/SystemSubscriptionTable.tsx +++ b/src/features/subscription/components/list/SystemSubscriptionTable.tsx @@ -1,209 +1,38 @@ -'use client' - -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/shadcn/table' -import { Organization } from '@/features/organization/types/organization.type' -import { BillingCycle } from '@/features/plan/types/plan.type' -import { formatDate, formatPrice } from '@/utils/index' -import React from 'react' -import { Card } from '@/components/shadcn/card' -import { useLocale, useTranslations } from 'next-intl' -import { useRouter } from 'next/navigation' import { Button } from '@/components/shadcn/button' -import { Edit2, Plus, Trash2 } from 'lucide-react' -import { useAppDispatch } from '@/hooks/redux-hooks' -import { useModal } from '@/providers/ModalProvider' -import { SubscriptionStatus } from '@/features/subscription/types/subscription.type' -import SStatusDropdown from '@/components/shared/SStatusDropdown' -import { useUpdateSubscriptionMutation } from '@/features/subscription/api/subscriptionApi' -import { toast } from 'sonner' - +import { DataTable } from '@/components/shared/data-table/data-table' +import { useSystemSubscriptionColumn } from '@/features/subscription/components/list/SystemSubscriptionColumn' +import { OrganizationSubscription } from '@/features/subscription/types/subscription.type' +import { Plus } from 'lucide-react' +import { useLocale, useTranslations } from 'next-intl' +import { useParams, useRouter } from 'next/navigation' +import React from 'react' type SystemSubscriptionTableProps = { - organization: Organization - refetchOrganization?: () => void + subscription: OrganizationSubscription[] } - -export default function SystemSubscriptionTable({ organization, refetchOrganization }: SystemSubscriptionTableProps) { - const tc = useTranslations('common') - const tt = useTranslations('toast') - const to = useTranslations('organization.subscription') - +export default function SystemSubscriptionTable({ subscription }: SystemSubscriptionTableProps) { const router = useRouter() const locale = useLocale() - const dispatch = useAppDispatch() - const { openModal } = useModal() - - const [updateSubscription] = useUpdateSubscriptionMutation() - - const getBillingCycleLabel = (cycle: BillingCycle | string) => { - switch (cycle) { - case BillingCycle.SEMIANNUAL: - return '6 Months' - case BillingCycle.ANNUAL: - return '12 Months' - default: - return cycle - } - } - - const subscriptionStatusOptions = [ - { label: 'All', value: 'all' }, - ...Object.entries(SubscriptionStatus).map(([key, value]) => ({ - label: key.charAt(0).toUpperCase() + key.slice(1).toLowerCase(), - value - })) - ] - - const handleStatusChange = (subscription: any, newStatus: string) => { - updateSubscription({ - subscriptionId: subscription.id, - body: { - status: newStatus as SubscriptionStatus, - // add curriculumIds to avoid removing them unintentionally (for grpc compatibility) - curriculumIds: subscription.curriculumIds || [] - } - }) - .unwrap() - .then(() => { - toast.success(tt('successMessage.update', { title: newStatus })) - refetchOrganization?.() - }) - .catch((error) => { - toast.error('Failed to update status') - console.error(error) - }) - } - - const SubscriptionStatusOption = [ - { label: 'Pending', value: SubscriptionStatus.PENDING }, - { label: 'Active', value: SubscriptionStatus.ACTIVE }, - { label: 'Archived', value: SubscriptionStatus.ARCHIVED }, - { label: 'Cancelled', value: SubscriptionStatus.CANCELLED }, - { label: 'Expired', value: SubscriptionStatus.EXPIRED } - ] - - const SubscriptionStatusFlow: Record = { - [SubscriptionStatus.PENDING]: [SubscriptionStatus.PENDING, SubscriptionStatus.ACTIVE, SubscriptionStatus.CANCELLED], - [SubscriptionStatus.ACTIVE]: [SubscriptionStatus.ACTIVE, SubscriptionStatus.CANCELLED, SubscriptionStatus.ARCHIVED], - [SubscriptionStatus.ARCHIVED]: [ - SubscriptionStatus.ACTIVE, - SubscriptionStatus.ARCHIVED, - SubscriptionStatus.CANCELLED - ], - [SubscriptionStatus.CANCELLED]: [], - [SubscriptionStatus.EXPIRED]: [] - } + const to = useTranslations('organization.subscription') + const tc = useTranslations('common') + const columns = useSystemSubscriptionColumn() + const { organizationId } = useParams() return ( -
+

{to('title')}

- - {organization.subscriptions.length === 0 ? ( -

{to('noData')}

- ) : ( - - - - - {tc('tableHeader.planName')} - {tc('tableHeader.planBillingCycle')} - {tc('tableHeader.grossAmount')} - {tc('tableHeader.netAmount')} - {tc('tableHeader.studentSeats')} - {tc('tableHeader.teacherSeats')} - {tc('tableHeader.status')} - {tc('tableHeader.startDate')} - {tc('tableHeader.endDate')} - {tc('tableHeader.action')} - - - - - {organization.subscriptions.map((subscription, index) => { - const allowedOptions = SubscriptionStatusOption.filter( - (sub) => - subscription.status && - SubscriptionStatusFlow[subscription.status].includes(sub.value as SubscriptionStatus) - ) - return ( - - - router.push(`/${locale}/admin/organization/${organization.id}/subscription/${subscription.id}`) - } - > - {subscription.planName} - - {getBillingCycleLabel(subscription.planBillingCycle ?? 'N/A')} - - - {formatPrice(subscription.grossAmount ?? 0) ?? '-'} - - - - {formatPrice(subscription.netAmount ?? 0) ?? '-'} - - - -

- {subscription.maxStudentSeats ?? '-'} -

-
- - -

- {subscription.maxTeacherSeats ?? '-'} -

-
- - handleStatusChange(subscription, newStatus)} - />{' '} - - - - {formatDate(subscription.startDate ?? 'N/A')} - - - {formatDate(subscription.endDate ?? 'N/A')} - - -
- - -
-
-
- ) - })} -
-
-
- )} +
) } diff --git a/src/providers/ModalProvider.tsx b/src/providers/ModalProvider.tsx index ff2ef7466..ca5482e87 100644 --- a/src/providers/ModalProvider.tsx +++ b/src/providers/ModalProvider.tsx @@ -35,7 +35,7 @@ import UpsertPlanSheet from '@/features/plan/components/sheet/UpsertPlanSheet' import UpsertClassroomModal from '@/features/classroom/components/upsert/UpsertClassroomModal' import SuccessModal from '@/components/shared/modals/SuccessModal' import AddPeopleModal from '@/features/user/components/modal/AddPeopleModal' -import UpsertOrganizationModal from '@/features/organization/components/UpsertOrganizationModal' +import UpsertOrganizationModal from '@/features/organization/components/upsert/UpsertOrganizationModal' import UpdateSubsctiptionSheet from '@/features/subscription/components/upsert/UpdateSubsctiptionSheet' import UpdateClassroomOrganizationModal from '@/features/classroom/components/upsert/UpdateClassroomOrganizationModal' import { UpsertEmulator } from '@/features/creator-3d/components/creator3d/ExportDialog' From 5791f7adfe9c6eb3d80aea8f3c22e3a984cc3a46 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sat, 29 Nov 2025 17:02:23 +0700 Subject: [PATCH 06/63] feat: Add missing translations for organization management in English and Vietnamese --- messages/en/organization/en_organization.json | 3 + messages/vi/organization/vi_organization.json | 2 + .../list/SystemOrganizationListV1.tsx | 246 ------------------ 3 files changed, 5 insertions(+), 246 deletions(-) delete mode 100644 src/features/organization/components/list/SystemOrganizationListV1.tsx diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index 2ea2072b5..37dc53bde 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -4,6 +4,8 @@ "type": "Organization Type", "description": "Description", "image": "Organization Image", + "noOrganization": "No organizations found", + "noOrganizationSubtitle": "Please create an organization before creating a subscription.", "imageSize": "Image must be less than 5MB", "detail": { "noData": "No organization data available.", @@ -73,6 +75,7 @@ "header": "Create Organization Subscription", "subHeader": "Follow the steps to set up your organization", "months": " months", + "step1": { "title": "Configure Subscription", "description": "Select plan and options" diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index f52b7d805..06c7297d9 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -4,6 +4,8 @@ "type": "Loại tổ chức", "description": "Mô tả", "image": "Ảnh tổ chức", + "noOrganization": "Không tìm thấy tổ chức nào", + "noOrganizationSubtitle": "Vui lòng tạo tổ chức trước khi tạo gói đăng ký.", "imageSize": "Ảnh phải nhỏ hơn 5MB", "detail": { "noData": "Không có dữ liệu tổ chức nào.", diff --git a/src/features/organization/components/list/SystemOrganizationListV1.tsx b/src/features/organization/components/list/SystemOrganizationListV1.tsx deleted file mode 100644 index 1146130d0..000000000 --- a/src/features/organization/components/list/SystemOrganizationListV1.tsx +++ /dev/null @@ -1,246 +0,0 @@ -'use client' - -import { Fragment, useEffect, useState } from 'react' -import { Button } from '@/components/shadcn/button' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/shadcn/table' -import { Pencil, Trash2, ChevronDown, ChevronRight, Search, GraduationCap, Building2 } from 'lucide-react' -import { useModal } from '@/providers/ModalProvider' -import { toast } from 'sonner' -import { useTranslations } from 'next-intl' -import { - useDeleteOrganizationMutation, - useSearchOrganizationsQuery, - useUpdateOrganizationMutation -} from '@/features/organization/api/organizationApi' -import Image from 'next/image' -import { formatDate, useStatusTranslation } from '@/utils/index' -import SystemSubscriptionTable from '@/features/subscription/components/list/SystemSubscriptionTable' -import { Input } from '@/components/shadcn/input' -import SSelect from '@/components/shared/SSelect' -import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' -import { setPageIndex, setParam, setSearchTerm } from '@/features/organization/slice/organizationSlice' -import { OrganizationStatus } from '@/features/organization/types/organization.type' -import useDebounce from '@/hooks/useDebounce' -import { SPagination } from '@/components/shared/SPagination' -import LoadingComponent from '@/components/shared/loading/LoadingComponent' -import SEmpty from '@/components/shared/empty/SEmpty' -import SStatusDropdown from '@/components/shared/SStatusDropdown' - -export default function SystemOrganizationList() { - const t = useTranslations('subscription') - const tc = useTranslations('common') - const tt = useTranslations('toast') - const translateStatus = useStatusTranslation() - - const dispatch = useAppDispatch() - const [search, setSearch] = useState('') - const debouncedSearchQuery = useDebounce(search, 500) - - const { openModal } = useModal() - const [expandedOrganizations, setExpandedOrganizations] = useState([]) - const queryParams = useAppSelector((state) => state.organization) - const { data, isLoading, refetch } = useSearchOrganizationsQuery(queryParams) - const organizations = data?.data.items || [] - - useEffect(() => { - dispatch(setSearchTerm(debouncedSearchQuery)) - }, [debouncedSearchQuery, dispatch]) - const [updateOrganization] = useUpdateOrganizationMutation() - const [deleteOrganization] = useDeleteOrganizationMutation() - const toggleExpand = (organizationId: number) => { - setExpandedOrganizations((prev) => - prev.includes(organizationId) ? prev.filter((id) => id !== organizationId) : [...prev, organizationId] - ) - } - const handlePageChange = (newPage: number) => { - dispatch(setPageIndex(newPage)) - } - - // translate to multilingual options - const organizationStatusOptions = [ - { label: tc('status.all'), value: 'all' }, - ...Object.entries(OrganizationStatus).map(([key, value]) => ({ - label: translateStatus(key), - value - })) - ] - - const organizationStatusOptionsNotTranslated = [ - { label: 'All', value: 'all' }, - ...Object.entries(OrganizationStatus).map(([key, value]) => ({ - label: key.charAt(0).toUpperCase() + key.slice(1).toLowerCase(), - value - })) - ] - - if (isLoading) { - return ( -
- -
- ) - } - if (!data) { - return - } - - const handleStatusChange = (organization: any, newStatus: string) => { - updateOrganization({ id: organization.id, body: { status: newStatus as OrganizationStatus } }) - .unwrap() - .then(() => toast.success(tt('successMessage.update', { title: newStatus }))) - } - return ( -
-
-
-
-

{t('list.organizationSubscriptionTitle')}

-

{t('list.organizationSubscriptionDescription')}

-
-
- -
-
- -
- {/* 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' - /> - -
- { - if (val === 'all') { - dispatch(setParam({ key: 'status', value: undefined })) - } else { - dispatch(setParam({ key: 'status', value: val as OrganizationStatus })) - } - }} - options={organizationStatusOptions} - /> -
- -
- - - - - {tc('tableHeader.image')} - {tc('tableHeader.name')} - {tc('tableHeader.organizationType')} - {tc('tableHeader.status')} - {tc('tableHeader.createdDate')} - {tc('tableHeader.actions')} - - - - {organizations.map((organization) => ( - - toggleExpand(organization.id)}> - - - - -
- {organization.imageUrl ? ( - - ) : ( -
- -
- )} -
-
- - {organization.name} - {organization.organizationType} - - opt.value !== 'all' && opt.value !== OrganizationStatus.ARCHIVED - )} - onChange={(newStatus) => handleStatusChange(organization, newStatus)} - />{' '} - - {formatDate(organization.createdDate)} - - -
- - -
-
-
- - {expandedOrganizations.includes(organization.id) && ( - - - - - - )} -
- ))} -
-
-
-
- {data?.data?.totalPages > 1 && ( - - )} -
-
-
- ) -} From f4181e24c497891706fea065b514b69328731e99 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sat, 29 Nov 2025 17:59:30 +0700 Subject: [PATCH 07/63] feat: Refactor organization detail and user management components; add loading state and user table --- messages/en/organization/en_organization.json | 2 - src/components/shared/SLoading.tsx | 10 ++ .../components/detail/OrganizationDetail.tsx | 12 +- .../table/UserOrganizationAction.tsx | 119 ++++++++++++++++++ .../table/UserOrganizationTable.tsx | 104 +++++++++++++++ 5 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 src/components/shared/SLoading.tsx create mode 100644 src/features/user/components/table/UserOrganizationAction.tsx create mode 100644 src/features/user/components/table/UserOrganizationTable.tsx diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index 37dc53bde..16e3edc10 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -4,8 +4,6 @@ "type": "Organization Type", "description": "Description", "image": "Organization Image", - "noOrganization": "No organizations found", - "noOrganizationSubtitle": "Please create an organization before creating a subscription.", "imageSize": "Image must be less than 5MB", "detail": { "noData": "No organization data available.", diff --git a/src/components/shared/SLoading.tsx b/src/components/shared/SLoading.tsx new file mode 100644 index 000000000..58f0d69f5 --- /dev/null +++ b/src/components/shared/SLoading.tsx @@ -0,0 +1,10 @@ +import LoadingComponent from '@/components/shared/loading/LoadingComponent' +import React from 'react' + +export default function SLoading() { + return ( +
+ +
+ ) +} diff --git a/src/features/organization/components/detail/OrganizationDetail.tsx b/src/features/organization/components/detail/OrganizationDetail.tsx index 767e21cf7..30ae79f21 100644 --- a/src/features/organization/components/detail/OrganizationDetail.tsx +++ b/src/features/organization/components/detail/OrganizationDetail.tsx @@ -3,8 +3,10 @@ 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' import SystemSubscriptionTable from '@/features/subscription/components/list/SystemSubscriptionTable' +import UserOrganizationTable from '@/features/user/components/table/UserOrganizationTable' import { useModal } from '@/providers/ModalProvider' import { getStatusBadgeClass } from '@/utils/badgeColor' import { formatDate, useStatusTranslation } from '@/utils/index' @@ -30,8 +32,12 @@ export default function OrganizationDetail() { await deleteOrganization(id) } + if (isLoading) { + return + } + if (!organization) { - return + return } return (
@@ -95,7 +101,11 @@ export default function OrganizationDetail() {
+ {/* Subscription List */} + + {/* Organization User List */} + ) } diff --git a/src/features/user/components/table/UserOrganizationAction.tsx b/src/features/user/components/table/UserOrganizationAction.tsx new file mode 100644 index 000000000..05fe08da0 --- /dev/null +++ b/src/features/user/components/table/UserOrganizationAction.tsx @@ -0,0 +1,119 @@ +'use client' +import { createActionsColumnFromItems, createSelectColumn } from '@/components/shared/data-table/columns-helpers' +import { useTranslations } from 'next-intl' + +import { useModal } from '@/providers/ModalProvider' +import { ColumnDef } from '@tanstack/react-table' +import { toast } from 'sonner' +import { useDeleteUserMutation } from '../../api/userApi' +import { User, UserStatus } from '@/features/user/types/user.type' +import Image from 'next/image' +import { Badge } from '@/components/shadcn/badge' +import { getStatusBadgeClass } from '@/utils/badgeColor' +import { useStatusTranslation } from '@/utils/index' + +export function useGetOrganizationUserAction(): ColumnDef[] { + const { openModal } = useModal() + const translationStatus = useStatusTranslation() + const [deleteUser] = useDeleteUserMutation() + const t = useTranslations('tableHeader') + const tt = useTranslations('toast') + const tm = useTranslations('message') + const tc = useTranslations('common') + + const handleDelete = async (id: string, userName: string) => { + try { + await deleteUser(id).unwrap() + toast.success(tt('successMessage.delete', { title: userName })) + } catch (error) { + toast.error(tt('errorMessage')) + } + } + + return [ + createSelectColumn(), + { + accessorKey: 'userId', + header: '', + cell: ({ row }) => {} + }, + { + accessorKey: 'imageUrl', + header: t('image'), + cell: ({ row }) => { + const src = row.original.imageUrl + const alt = row.original.userName.charAt(0) + return ( +
+ {src ? ( + preview + ) : ( +
+ {alt} +
+ )} +
+ ) + } + }, + { + accessorKey: 'userName', + header: t('userName') + }, + { + accessorKey: 'email', + header: t('email') + }, + { + accessorKey: 'firstName', + header: t('firstName') + }, + { + accessorKey: 'lastName', + header: t('lastName') + }, + { + accessorKey: 'userRole', + header: t('userRole'), + cell: ({ row }) => { + const role = row.original.userRole + return
{tc(`accountType.${role.toLowerCase()}`)}
+ } + }, + { + accessorKey: 'status', + header: t('status'), + cell: ({ row }) => { + return ( + + {translationStatus(row.original.status)} + + ) + } + }, + createActionsColumnFromItems([ + { + label: t('edit'), + onClick: ({ original }) => { + openModal('upsertUser', { id: original.userId }) + } + }, + { + label: t('disable'), + danger: true, + onClick: async ({ original }) => { + openModal('confirm', { + message: tm('confirmDelMessage', { title: original.userName }), + onConfirm: () => handleDelete(original.userId, original.userName) + }) + } + } + ]) + ] +} diff --git a/src/features/user/components/table/UserOrganizationTable.tsx b/src/features/user/components/table/UserOrganizationTable.tsx new file mode 100644 index 000000000..9970764e9 --- /dev/null +++ b/src/features/user/components/table/UserOrganizationTable.tsx @@ -0,0 +1,104 @@ +'use client' +import { Input } from '@/components/shadcn/input' +import { DataTable } from '@/components/shared/data-table/data-table' +import { useModal } from '@/providers/ModalProvider' +import React, { useState } from 'react' +import { useSearchUserQuery } from '../../api/userApi' +import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' +import { UserSliceParams, UserStatus } from '../../types/user.type' +import { useTranslations } from 'next-intl' +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 { useStatusTranslation } from '@/utils/index' +import { useGetOrganizationUserAction } from '@/features/user/components/table/UserOrganizationAction' + +export default function UserOrganizationTable() { + const t = useTranslations('Admin.placeholder') + const tCommon = useTranslations('common') + const statusTranslate = useStatusTranslation() + const { openModal } = useModal() + + const columns = useGetOrganizationUserAction() + const dispatch = useAppDispatch() + const { status, data: session } = useSession() + + 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 } = useSearchUserQuery(searchParams, { skip: status !== 'authenticated' }) + + const rows = React.useMemo( + () => + (data?.data.items ?? []).map((item, idx) => ({ + id: item.userId, + ...item + })), + [data] + ) + + const userRoleOptions = Object.entries(UserRole) + .filter(([key, _]) => key !== 'GUEST') + .map(([key, value]) => ({ + label: tCommon(`accountType.${value.toLowerCase()}`), + value: value + })) + + const statusOptions = Object.entries(UserStatus).map(([key, value]) => ({ + label: statusTranslate(value), + value: value + })) + + const handlePageChange = (page: number) => { + dispatch(setPageIndex(page)) + } + + return ( +
+

User Management

+
+
+ setSearchQuery(e.target.value)} + className='w-[400px]' + /> + dispatch(setParam({ key: 'role', value: val as UserRole }))} + options={userRoleOptions} + /> + dispatch(setParam({ key: 'status', value: val as UserStatus }))} + options={statusOptions.filter((option) => option.value !== UserStatus.DELETED)} + /> +
+
+ + +
+ ) +} From 6ee9c0b5d3cec40235dffcc2c8a885baad47a3ba Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sat, 29 Nov 2025 17:59:50 +0700 Subject: [PATCH 08/63] feat: Remove unused translation keys for organization management --- messages/vi/organization/vi_organization.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index 06c7297d9..f52b7d805 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -4,8 +4,6 @@ "type": "Loại tổ chức", "description": "Mô tả", "image": "Ảnh tổ chức", - "noOrganization": "Không tìm thấy tổ chức nào", - "noOrganizationSubtitle": "Vui lòng tạo tổ chức trước khi tạo gói đăng ký.", "imageSize": "Ảnh phải nhỏ hơn 5MB", "detail": { "noData": "Không có dữ liệu tổ chức nào.", From 11e3639b11c646dfab7aca524d9f121bb8e6f907 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sat, 29 Nov 2025 18:41:12 +0700 Subject: [PATCH 09/63] feat: Add created date and last modified date translations to organization detail --- messages/en/organization/en_organization.json | 2 ++ messages/vi/organization/vi_organization.json | 2 ++ .../organization/components/detail/OrganizationDetail.tsx | 7 +++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index 16e3edc10..5af21cbad 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -11,6 +11,8 @@ "header": "Organization Details", "status": "Status", "description": "Description", + "createdDate": "Created Date", + "lastModifiedDate": "Last Modified Date", "organizationType": "Organization Type", "createdAt": "Created At", "updatedAt": "Updated At", diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index f52b7d805..43c782b37 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -12,6 +12,8 @@ "status": "Trạng thái", "description": "Mô tả", "organizationType": "Loại tổ chức", + "createdDate": "Ngày tạo", + "lastModifiedDate": "Ngày cập nhật", "createdAt": "Ngày tạo", "updatedAt": "Ngày cập nhật", "package": "Gói", diff --git a/src/features/organization/components/detail/OrganizationDetail.tsx b/src/features/organization/components/detail/OrganizationDetail.tsx index 30ae79f21..2b4a737cc 100644 --- a/src/features/organization/components/detail/OrganizationDetail.tsx +++ b/src/features/organization/components/detail/OrganizationDetail.tsx @@ -66,9 +66,12 @@ export default function OrganizationDetail() {
-

Ngày tạo: {formatDate(organization.data.createdDate, { locale: locale as 'en' | 'vi' | undefined })}

- Chỉnh sửa gần nhất:{' '} + {to('createdDate')}{' '} + {formatDate(organization.data.createdDate, { locale: locale as 'en' | 'vi' | undefined })} +

+

+ {to('lastModifiedDate')}{' '} {formatDate(organization.data.lastModifiedDate, { locale: locale as 'en' | 'vi' | undefined })}

From 70403d58c80de18071f31eb1ddd285fc7dbe033f Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sat, 29 Nov 2025 19:10:42 +0700 Subject: [PATCH 10/63] feat: Update organization subscription labels and improve layout in organization detail and user management components --- messages/en/admin/en_admin.json | 2 +- messages/vi/admin/vi_admin.json | 2 +- .../components/detail/OrganizationDetail.tsx | 81 ++++++++++--------- .../list/SystemSubscriptionColumn.tsx | 7 +- .../table/UserOrganizationTable.tsx | 2 +- 5 files changed, 50 insertions(+), 44 deletions(-) diff --git a/messages/en/admin/en_admin.json b/messages/en/admin/en_admin.json index 427d14c7d..a7790121c 100644 --- a/messages/en/admin/en_admin.json +++ b/messages/en/admin/en_admin.json @@ -27,7 +27,7 @@ "plan": "Plan", "resource": "Resource", "operationCenter": "Operation Center", - "organizationSubscription": "Organization Subscription", + "organizationSubscription": "Organization", "contact": "Contact" }, "course_management": { diff --git a/messages/vi/admin/vi_admin.json b/messages/vi/admin/vi_admin.json index 76e752783..574180683 100644 --- a/messages/vi/admin/vi_admin.json +++ b/messages/vi/admin/vi_admin.json @@ -27,7 +27,7 @@ "plan": "Gói", "resource": "Tài nguyên", "operationCenter": "Hệ thống", - "organizationSubscription": "Các Gói đăng ký tổ chức", + "organizationSubscription": "Tổ chức", "contact": "Liên hệ" }, "course_management": { diff --git a/src/features/organization/components/detail/OrganizationDetail.tsx b/src/features/organization/components/detail/OrganizationDetail.tsx index 2b4a737cc..0acab7e2a 100644 --- a/src/features/organization/components/detail/OrganizationDetail.tsx +++ b/src/features/organization/components/detail/OrganizationDetail.tsx @@ -44,49 +44,54 @@ export default function OrganizationDetail() {
{/*

{organization.data.code}

*/} -
-

{organization.data.name}

- - { - openModal('upsertOrganization', { organizationId: organization.data.id }) - }} - /> - - - { - openModal('confirm', { - message: `${tt('confirmMessage.delete', { title: organization.data.name })}`, - onConfirm: () => handleDelete(organization.data.id) - }) - }} - /> - +
+
+

{organization.data.name}

+ + + {translateStatus(organization.data.status)} + + +
+
+ + { + openModal('upsertOrganization', { organizationId: organization.data.id }) + }} + /> + + + { + openModal('confirm', { + message: `${tt('confirmMessage.delete', { title: organization.data.name })}`, + onConfirm: () => handleDelete(organization.data.id) + }) + }} + /> + +
-
-

- {to('createdDate')}{' '} - {formatDate(organization.data.createdDate, { locale: locale as 'en' | 'vi' | undefined })} -

-

- {to('lastModifiedDate')}{' '} - {formatDate(organization.data.lastModifiedDate, { locale: locale as 'en' | 'vi' | undefined })} -

-
-
- - {to('status')}:{' '} - - {translateStatus(organization.data.status)} - - +
+
+ {to('createdDate')}: + {formatDate(organization.data.createdDate, { locale: locale as 'en' | 'vi' | undefined })} +
+ +
+ {to('lastModifiedDate')}: + + {formatDate(organization.data.lastModifiedDate, { locale: locale as 'en' | 'vi' | undefined })} + +
-
+ +
{/* Description */} -

{to('description')}

+

{to('description')}

{organization.data.description}

diff --git a/src/features/subscription/components/list/SystemSubscriptionColumn.tsx b/src/features/subscription/components/list/SystemSubscriptionColumn.tsx index a76ede483..29b9d6d64 100644 --- a/src/features/subscription/components/list/SystemSubscriptionColumn.tsx +++ b/src/features/subscription/components/list/SystemSubscriptionColumn.tsx @@ -1,7 +1,7 @@ import React from 'react' import { useTranslations } from 'next-intl' import { ColumnDef } from '@tanstack/react-table' -import { useRouter } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { useModal } from '@/providers/ModalProvider' import { toast } from 'sonner' import { createActionsColumnFromItems, createSelectColumn } from '@/components/shared/data-table/columns-helpers' @@ -16,6 +16,7 @@ import { OrganizationSubscription, SubscriptionStatus } from '@/features/subscri import { BillingCycle } from '@/features/plan/types/plan.type' export function useSystemSubscriptionColumn(): ColumnDef[] { + const { organizationId } = useParams() const router = useRouter() const locale = useLocale() const { openModal } = useModal() @@ -37,7 +38,7 @@ export function useSystemSubscriptionColumn(): ColumnDef { - router.push(`/${locale}/admin/organization/${id}`) + router.push(`/${locale}/admin/organization/${organizationId}/subscription/${id}`) } const handleStatusChange = (subscription: any, newStatus: string) => { @@ -164,7 +165,7 @@ export function useSystemSubscriptionColumn(): ColumnDef { - router.push(`/${locale}/admin/organization/${original.id}`) + router.push(`/${locale}/admin/organization/${organizationId}/subscription/${original.id}`) } }, { diff --git a/src/features/user/components/table/UserOrganizationTable.tsx b/src/features/user/components/table/UserOrganizationTable.tsx index 9970764e9..517dd918d 100644 --- a/src/features/user/components/table/UserOrganizationTable.tsx +++ b/src/features/user/components/table/UserOrganizationTable.tsx @@ -64,7 +64,7 @@ export default function UserOrganizationTable() { } return ( -
+

User Management

From 2901d080f3d5e70b243f0d7a39adbd404fcb3084 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sat, 29 Nov 2025 19:16:51 +0700 Subject: [PATCH 11/63] feat: Add user section to organization detail with translations for members --- messages/en/organization/en_organization.json | 4 ++++ messages/vi/organization/vi_organization.json | 4 ++++ src/features/user/components/table/UserOrganizationTable.tsx | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index 5af21cbad..711d0fc46 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -58,6 +58,10 @@ "status": "Status", "assignedAt": "Assigned At", "notSet": "Not set" + }, + "user": { + "title": "Organization Members", + "noData": "No members found for this organization." } }, "form": { diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index 43c782b37..e73935f05 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -57,6 +57,10 @@ "status": "Trạng thái", "assignedAt": "Ngày được cấp", "notSet": "Chưa đặt" + }, + "user": { + "title": "Thành viên tổ chức", + "noData": "Không tìm thấy thành viên nào cho tổ chức này." } }, "form": { diff --git a/src/features/user/components/table/UserOrganizationTable.tsx b/src/features/user/components/table/UserOrganizationTable.tsx index 517dd918d..5de1d77a0 100644 --- a/src/features/user/components/table/UserOrganizationTable.tsx +++ b/src/features/user/components/table/UserOrganizationTable.tsx @@ -17,6 +17,7 @@ import { useGetOrganizationUserAction } from '@/features/user/components/table/U export default function UserOrganizationTable() { const t = useTranslations('Admin.placeholder') + const to = useTranslations('organization.detail') const tCommon = useTranslations('common') const statusTranslate = useStatusTranslation() const { openModal } = useModal() @@ -65,7 +66,7 @@ export default function UserOrganizationTable() { return (
-

User Management

+

{to('user.title')}

Date: Sun, 30 Nov 2025 12:57:45 +0700 Subject: [PATCH 12/63] feat: Add organization groups management feature with translations and UI components --- messages/en/admin/en_admin.json | 3 +- messages/en/common/en_common.json | 3 +- messages/en/organization/en_organization.json | 6 +- messages/vi/admin/vi_admin.json | 3 +- messages/vi/common/vi_common.json | 3 +- messages/vi/organization/vi_organization.json | 5 + src/app/[locale]/organization/group/page.tsx | 10 + .../sidebar/organization-sidebar.tsx | 7 +- .../components/list/OrganizationGroupList.tsx | 183 ++++++++++++++++++ 9 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 src/app/[locale]/organization/group/page.tsx create mode 100644 src/features/group/components/list/OrganizationGroupList.tsx diff --git a/messages/en/admin/en_admin.json b/messages/en/admin/en_admin.json index a7790121c..ba7c31681 100644 --- a/messages/en/admin/en_admin.json +++ b/messages/en/admin/en_admin.json @@ -28,7 +28,8 @@ "resource": "Resource", "operationCenter": "Operation Center", "organizationSubscription": "Organization", - "contact": "Contact" + "contact": "Contact", + "group": "Group" }, "course_management": { "title": "Resource Management" diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index a6f52fcee..60c94acaa 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -102,7 +102,8 @@ "close": "Close", "createPlan": "Create Plan", "addQuestion": "Add Question", - "addCriterion": "Add Criterion" + "addCriterion": "Add Criterion", + "createGroup": "Create Group" }, "message": { "courseCreateSuccess": "Course created successfully!", diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index 711d0fc46..f08e08ab9 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -79,7 +79,6 @@ "header": "Create Organization Subscription", "subHeader": "Follow the steps to set up your organization", "months": " months", - "step1": { "title": "Configure Subscription", "description": "Select plan and options" @@ -168,6 +167,11 @@ "pleaseUploadCSV": "Please upload a CSV file", "uploadSuccess": "Users have been successfully invited and licenses assigned!", "userType": "User Type" + }, + "group": { + "title": "Organization Student Groups", + "subTitle": "Manage your organization groups", + "noData": "No groups found for this organization." } } } diff --git a/messages/vi/admin/vi_admin.json b/messages/vi/admin/vi_admin.json index 574180683..83a22e72e 100644 --- a/messages/vi/admin/vi_admin.json +++ b/messages/vi/admin/vi_admin.json @@ -28,7 +28,8 @@ "resource": "Tài nguyên", "operationCenter": "Hệ thống", "organizationSubscription": "Tổ chức", - "contact": "Liên hệ" + "contact": "Liên hệ", + "group": "Nhóm" }, "course_management": { "title": "Quản lý tài nguyên", diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index ceff61b30..ce3c33067 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -102,7 +102,8 @@ "seeFeedback": "Xem Phản Hồi", "close": "Đóng", "addQuestion": "Thêm Câu Hỏi", - "addCriterion": "Thêm Tiêu Chí" + "addCriterion": "Thêm Tiêu Chí", + "createGroup": "Tạo Nhóm" }, "message": { "courseCreateSuccess": "Khóa học được tạo thành công!", diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index e73935f05..2d28cf874 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -166,6 +166,11 @@ "pleaseUploadCSV": "Vui lòng tải lên tệp CSV", "uploadSuccess": "Đã gửi lời mời thành công!", "userType": "Loại người dùng" + }, + "group": { + "title": "Nhóm học sinh của tổ chức", + "subTitle": "Quản lý các nhóm của tổ chức bạn", + "noData": "Không tìm thấy nhóm nào cho tổ chức này." } } } diff --git a/src/app/[locale]/organization/group/page.tsx b/src/app/[locale]/organization/group/page.tsx new file mode 100644 index 000000000..a5e39304d --- /dev/null +++ b/src/app/[locale]/organization/group/page.tsx @@ -0,0 +1,10 @@ +import OrganizationGroupList from '@/features/group/components/list/OrganizationGroupList' +import React from 'react' + +export default function OrganizationGroupPage() { + return ( +
+ +
+ ) +} diff --git a/src/components/layout/organization/sidebar/organization-sidebar.tsx b/src/components/layout/organization/sidebar/organization-sidebar.tsx index bf5f04247..126fe1506 100644 --- a/src/components/layout/organization/sidebar/organization-sidebar.tsx +++ b/src/components/layout/organization/sidebar/organization-sidebar.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { IconBook, IconChalkboard, IconListDetails } from '@tabler/icons-react' +import { IconBook, IconChalkboard, IconListDetails, IconUsersGroup } from '@tabler/icons-react' import { Sidebar, @@ -40,6 +40,11 @@ const data = { title: 'side_bar.classroom', url: '/organization/classroom', icon: IconChalkboard + }, + { + title: 'side_bar.group', + url: '/organization/group', + icon: IconUsersGroup } ] } diff --git a/src/features/group/components/list/OrganizationGroupList.tsx b/src/features/group/components/list/OrganizationGroupList.tsx new file mode 100644 index 000000000..44b07ab1b --- /dev/null +++ b/src/features/group/components/list/OrganizationGroupList.tsx @@ -0,0 +1,183 @@ +'use client' +import { useTranslations } from 'next-intl' +import { Card, CardContent } from '@/components/shadcn/card' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/shadcn/avatar' +import { Badge } from '@/components/shadcn/badge' +import { Users, MoreHorizontal } from 'lucide-react' +import { Button } from '@/components/shadcn/button' + +// ===== Mock Data ===== +const mockGroups = [ + { + id: 1, + name: 'Advanced Mathematics', + code: 'MATH301', + students: [ + { + id: 1, + name: 'John Doe', + avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=32&h=32&fit=crop&crop=face' + }, + { + id: 2, + name: 'Jane Smith', + avatar: 'https://images.unsplash.com/photo-1494790108755-2616b332c647?w=32&h=32&fit=crop&crop=face' + }, + { + id: 3, + name: 'Mike Johnson', + avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=32&h=32&fit=crop&crop=face' + } + ], + totalStudents: 25 + }, + { + id: 2, + name: 'Computer Science Fundamentals', + code: 'CS101', + students: [ + { + id: 4, + name: 'Sarah Wilson', + avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=32&h=32&fit=crop&crop=face' + }, + { + id: 5, + name: 'Tom Brown', + avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=32&h=32&fit=crop&crop=face' + } + ], + totalStudents: 18 + }, + { + id: 3, + name: 'Physics Laboratory', + code: 'PHY201', + students: [ + { + id: 6, + name: 'Emma Davis', + avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=32&h=32&fit=crop&crop=face' + }, + { + id: 7, + name: 'Chris Lee', + avatar: 'https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=32&h=32&fit=crop&crop=face' + }, + { + id: 8, + name: 'Alex Kim', + avatar: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=32&h=32&fit=crop&crop=face' + } + ], + totalStudents: 32 + }, + { + id: 4, + name: 'Biology Research Group', + code: 'BIO301', + students: [ + { + id: 9, + name: 'Lisa Wang', + avatar: 'https://images.unsplash.com/photo-1544725176-7c40e5a71c5e?w=32&h=32&fit=crop&crop=face' + } + ], + totalStudents: 12 + }, + { + id: 5, + name: 'Engineering Design Team', + code: 'ENG205', + students: [ + { + id: 10, + name: 'David Chen', + avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=32&h=32&fit=crop&crop=face' + }, + { + id: 11, + name: 'Rachel Green', + avatar: 'https://images.unsplash.com/photo-1494790108755-2616b332c647?w=32&h=32&fit=crop&crop=face' + }, + { + id: 12, + name: 'Mark Taylor', + avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=32&h=32&fit=crop&crop=face' + } + ], + totalStudents: 45 + } +] + +export default function OrganizationGroupList() { + const to = useTranslations('organization.group') + const tc = useTranslations('common') + + return ( +
+
+
+

{to('title')}

+

{to('subTitle')}

+
+ +
+ +
+ {mockGroups.map((group) => ( + + +
+ {/* LEFT AREA */} +
+
+ +
+ +
+
+

{group.name}

+ + {group.code} + +
+ + {/* AVATAR LIST */} +
+ {group.students.slice(0, 3).map((s, i) => ( + + + + {s.name + .split(' ') + .map((n) => n[0]) + .join('')} + + + ))} + {group.totalStudents > 3 && ( +
+ +{group.totalStudents - 3} +
+ )} +
+
+
+ + {/* MENU BTN */} + +
+
+
+ ))} +
+
+ ) +} From f5785304ad7d98b61bba1e2267429378297a1b22 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 30 Nov 2025 15:44:39 +0700 Subject: [PATCH 13/63] feat: Implement upsert group modal and form for group management --- .../components/list/OrganizationGroupList.tsx | 8 +- .../components/modal/UpsertGroupModal.tsx | 17 +++ .../group/components/upsert/UpsertGroup.tsx | 107 ++++++++++++++++++ src/providers/ModalProvider.tsx | 2 + src/types/general.ts | 1 + 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 src/features/group/components/modal/UpsertGroupModal.tsx create mode 100644 src/features/group/components/upsert/UpsertGroup.tsx diff --git a/src/features/group/components/list/OrganizationGroupList.tsx b/src/features/group/components/list/OrganizationGroupList.tsx index 44b07ab1b..148b1a946 100644 --- a/src/features/group/components/list/OrganizationGroupList.tsx +++ b/src/features/group/components/list/OrganizationGroupList.tsx @@ -5,6 +5,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/shadcn/avatar' import { Badge } from '@/components/shadcn/badge' import { Users, MoreHorizontal } from 'lucide-react' import { Button } from '@/components/shadcn/button' +import { useModal } from '@/providers/ModalProvider' // ===== Mock Data ===== const mockGroups = [ @@ -113,6 +114,11 @@ const mockGroups = [ export default function OrganizationGroupList() { const to = useTranslations('organization.group') const tc = useTranslations('common') + const { openModal } = useModal() + + const handleCreateGroup = () => { + openModal('upsertGroup') + } return (
@@ -121,7 +127,7 @@ export default function OrganizationGroupList() {

{to('title')}

{to('subTitle')}

- +
diff --git a/src/features/group/components/modal/UpsertGroupModal.tsx b/src/features/group/components/modal/UpsertGroupModal.tsx new file mode 100644 index 000000000..51bd59238 --- /dev/null +++ b/src/features/group/components/modal/UpsertGroupModal.tsx @@ -0,0 +1,17 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/shadcn/dialog' +import UpsertGroup from '@/features/group/components/upsert/UpsertGroup' +import { useModal } from '@/providers/ModalProvider' + +export default function UpsertGroupModal() { + const { closeModal } = useModal() + return ( + + + + Create / Update Group + + + + + ) +} diff --git a/src/features/group/components/upsert/UpsertGroup.tsx b/src/features/group/components/upsert/UpsertGroup.tsx new file mode 100644 index 000000000..6afecd3a3 --- /dev/null +++ b/src/features/group/components/upsert/UpsertGroup.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useState } from 'react' +import { Input } from '@/components/shadcn/input' +import { Label } from '@/components/shadcn/label' +import { Button } from '@/components/shadcn/button' +import { Select, SelectTrigger, SelectValue, SelectItem, SelectContent } from '@/components/shadcn/select' +import { Table, TableHeader, TableHead, TableRow, TableCell, TableBody } from '@/components/shadcn/table' +import { Checkbox } from '@/components/shadcn/checkbox' +import { Avatar, AvatarImage, AvatarFallback } from '@/components/shadcn/avatar' +import { ScrollArea } from '@/components/shadcn/scroll-area' + +const mockUsers = [ + { id: 1, name: 'John Doe', email: 'john@gmail.com', avatar: 'https://randomuser.me/api/portraits/men/1.jpg' }, + { id: 2, name: 'Jane Smith', email: 'jane@gmail.com', avatar: 'https://randomuser.me/api/portraits/women/2.jpg' }, + { id: 3, name: 'David Brown', email: 'david@gmail.com', avatar: 'https://randomuser.me/api/portraits/men/3.jpg' }, + { id: 4, name: 'Emma Wilson', email: 'emma@gmail.com', avatar: 'https://randomuser.me/api/portraits/women/4.jpg' } +] + +export default function UpsertGroup() { + const [groupName, setGroupName] = useState('') + const [numberOfStudents, setNumberOfStudents] = useState('') + const [grade, setGrade] = useState('') + const [selectedUsers, setSelectedUsers] = useState([]) + + const toggleUser = (id: number) => { + setSelectedUsers((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) + } + + const handleSubmit = () => { + console.log({ groupName, numberOfStudents, grade, selectedUsers }) + alert('Submit Success - Check Console Log') + } + + return ( +
+ {/* FORM */} +
+
+ + setGroupName(e.target.value)} placeholder='Enter group name' /> +
+ +
+ + setNumberOfStudents(e.target.value)} /> +
+ +
+ + +
+
+ + {/* STUDENT TABLE */} + + + + + + + Name + Email + + + + + {mockUsers.map((user) => ( + + + toggleUser(user.id)} /> + + + + + + U + + {user.name} + + + {user.email} + + ))} + +
+
+ + {/* ACTION */} +
+ + +
+
+ ) +} diff --git a/src/providers/ModalProvider.tsx b/src/providers/ModalProvider.tsx index ca5482e87..3bf381c0c 100644 --- a/src/providers/ModalProvider.tsx +++ b/src/providers/ModalProvider.tsx @@ -43,6 +43,7 @@ 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' const ModalContext = createContext({ openModal: () => {}, closeModal: () => {}, @@ -97,6 +98,7 @@ export const ModalProvider = ({ children }: { children: React.ReactNode }) => { {modalType === 'upsertOrganization' && } {modalType === 'upsertEmulator' && } {modalType === 'createAssignmentInfo' && } + {modalType === 'upsertGroup' && } {/* detail */} {modalType === 'lessonDetail' && } diff --git a/src/types/general.ts b/src/types/general.ts index f0092eab6..92ebb2b68 100644 --- a/src/types/general.ts +++ b/src/types/general.ts @@ -40,6 +40,7 @@ export type ModalType = | 'upsertEmulator' | 'upsertEmulator' | 'createAssignmentInfo' + | 'upsertGroup' // detail | 'lessonDetail' From abfbfb598a6a5b6c189e8e5367989e4345ce3f46 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 30 Nov 2025 19:12:25 +0700 Subject: [PATCH 14/63] feat: Implement multi-step group creation process with student selection and group distribution --- messages/en/organization/en_organization.json | 20 ++- messages/vi/organization/vi_organization.json | 20 ++- .../organization/group/create/page.tsx | 6 + .../components/modal/UpsertGroupModal.tsx | 2 - .../upsert/CreateStudentGroupPage.tsx | 59 ++++++++ .../upsert/Step1SelectStudentGroup.tsx | 121 ++++++++++++++++ .../upsert/Step2CreateStudentGroup.tsx | 137 ++++++++++++++++++ .../group/components/upsert/StudentColumn.tsx | 33 +++++ .../group/components/upsert/UpsertGroup.tsx | 107 -------------- 9 files changed, 394 insertions(+), 111 deletions(-) create mode 100644 src/app/[locale]/organization/group/create/page.tsx create mode 100644 src/features/group/components/upsert/CreateStudentGroupPage.tsx create mode 100644 src/features/group/components/upsert/Step1SelectStudentGroup.tsx create mode 100644 src/features/group/components/upsert/Step2CreateStudentGroup.tsx create mode 100644 src/features/group/components/upsert/StudentColumn.tsx delete mode 100644 src/features/group/components/upsert/UpsertGroup.tsx diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index f08e08ab9..6f47e3b09 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -171,7 +171,25 @@ "group": { "title": "Organization Student Groups", "subTitle": "Manage your organization groups", - "noData": "No groups found for this organization." + "noData": "No groups found for this organization.", + "step1": { + "title": "Step 1: Select Students", + "description": "Choose students to include in the groups", + "numberOfStudents": "Number of Students", + "gradeLevel": "Grade Level", + "gradeLevelPlaceholder": "Select Grade Level", + "studentList": "Student List", + "selected": "Selected" + }, + "step2": { + "title": "Step 2: Create Groups", + "numberOfStudents": "Number of Students", + "studentsPerGroup": "Students per Group", + "gradeLevel": "Grade Level", + "groupList": "Group List", + "name": "Name", + "studentCount": "Student Count" + } } } } diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index 2d28cf874..1e348b017 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -170,7 +170,25 @@ "group": { "title": "Nhóm học sinh của tổ chức", "subTitle": "Quản lý các nhóm của tổ chức bạn", - "noData": "Không tìm thấy nhóm nào cho tổ chức này." + "noData": "Không tìm thấy nhóm nào cho tổ chức này.", + "step1": { + "title": "Bước 1: Chọn học sinh cho nhóm", + "description": "Chọn học sinh để thêm vào nhóm", + "numberOfStudents": "Số lượng học sinh", + "gradeLevel": "Cấp lớp", + "gradeLevelPlaceholder": "Chọn cấp lớp", + "studentList": "Danh sách học sinh", + "selected": "Đã chọn" + }, + "step2": { + "title": "Bước 2: Tạo nhóm", + "numberOfStudents": "Số học sinh", + "studentsPerGroup": "Số học sinh mỗi nhóm", + "gradeLevel": "Cấp lớp", + "groupList": "Danh sách nhóm", + "name": "Tên", + "studentCount": "Số học sinh" + } } } } diff --git a/src/app/[locale]/organization/group/create/page.tsx b/src/app/[locale]/organization/group/create/page.tsx new file mode 100644 index 000000000..5e53719a7 --- /dev/null +++ b/src/app/[locale]/organization/group/create/page.tsx @@ -0,0 +1,6 @@ +import CreateStudentGroupPage from '@/features/group/components/upsert/CreateStudentGroupPage' +import React from 'react' + +export default function Page() { + return +} diff --git a/src/features/group/components/modal/UpsertGroupModal.tsx b/src/features/group/components/modal/UpsertGroupModal.tsx index 51bd59238..68d03d091 100644 --- a/src/features/group/components/modal/UpsertGroupModal.tsx +++ b/src/features/group/components/modal/UpsertGroupModal.tsx @@ -1,5 +1,4 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/shadcn/dialog' -import UpsertGroup from '@/features/group/components/upsert/UpsertGroup' import { useModal } from '@/providers/ModalProvider' export default function UpsertGroupModal() { @@ -10,7 +9,6 @@ export default function UpsertGroupModal() { Create / Update Group - ) diff --git a/src/features/group/components/upsert/CreateStudentGroupPage.tsx b/src/features/group/components/upsert/CreateStudentGroupPage.tsx new file mode 100644 index 000000000..0a28dc28b --- /dev/null +++ b/src/features/group/components/upsert/CreateStudentGroupPage.tsx @@ -0,0 +1,59 @@ +'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 new file mode 100644 index 000000000..8a497dadc --- /dev/null +++ b/src/features/group/components/upsert/Step1SelectStudentGroup.tsx @@ -0,0 +1,121 @@ +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 new file mode 100644 index 000000000..dd583f4cd --- /dev/null +++ b/src/features/group/components/upsert/Step2CreateStudentGroup.tsx @@ -0,0 +1,137 @@ +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 new file mode 100644 index 000000000..b175a70b5 --- /dev/null +++ b/src/features/group/components/upsert/StudentColumn.tsx @@ -0,0 +1,33 @@ +import { createSelectColumn } from '@/components/shared/data-table/columns-helpers' +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 to = useTranslations('common.tableHeader') + return [ + createSelectColumn(), + { + accessorKey: 'avatar', + header: to('avatar') + }, + { + accessorKey: 'id', + header: 'ID' + }, + { + accessorKey: 'name', + header: to('name') + }, + { + accessorKey: 'email', + header: to('email') + } + ] +} diff --git a/src/features/group/components/upsert/UpsertGroup.tsx b/src/features/group/components/upsert/UpsertGroup.tsx deleted file mode 100644 index 6afecd3a3..000000000 --- a/src/features/group/components/upsert/UpsertGroup.tsx +++ /dev/null @@ -1,107 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Input } from '@/components/shadcn/input' -import { Label } from '@/components/shadcn/label' -import { Button } from '@/components/shadcn/button' -import { Select, SelectTrigger, SelectValue, SelectItem, SelectContent } from '@/components/shadcn/select' -import { Table, TableHeader, TableHead, TableRow, TableCell, TableBody } from '@/components/shadcn/table' -import { Checkbox } from '@/components/shadcn/checkbox' -import { Avatar, AvatarImage, AvatarFallback } from '@/components/shadcn/avatar' -import { ScrollArea } from '@/components/shadcn/scroll-area' - -const mockUsers = [ - { id: 1, name: 'John Doe', email: 'john@gmail.com', avatar: 'https://randomuser.me/api/portraits/men/1.jpg' }, - { id: 2, name: 'Jane Smith', email: 'jane@gmail.com', avatar: 'https://randomuser.me/api/portraits/women/2.jpg' }, - { id: 3, name: 'David Brown', email: 'david@gmail.com', avatar: 'https://randomuser.me/api/portraits/men/3.jpg' }, - { id: 4, name: 'Emma Wilson', email: 'emma@gmail.com', avatar: 'https://randomuser.me/api/portraits/women/4.jpg' } -] - -export default function UpsertGroup() { - const [groupName, setGroupName] = useState('') - const [numberOfStudents, setNumberOfStudents] = useState('') - const [grade, setGrade] = useState('') - const [selectedUsers, setSelectedUsers] = useState([]) - - const toggleUser = (id: number) => { - setSelectedUsers((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) - } - - const handleSubmit = () => { - console.log({ groupName, numberOfStudents, grade, selectedUsers }) - alert('Submit Success - Check Console Log') - } - - return ( -
- {/* FORM */} -
-
- - setGroupName(e.target.value)} placeholder='Enter group name' /> -
- -
- - setNumberOfStudents(e.target.value)} /> -
- -
- - -
-
- - {/* STUDENT TABLE */} - - - - - - - Name - Email - - - - - {mockUsers.map((user) => ( - - - toggleUser(user.id)} /> - - - - - - U - - {user.name} - - - {user.email} - - ))} - -
-
- - {/* ACTION */} -
- - -
-
- ) -} From c175001ab5381ea702aadf0650679d3c5c43350d Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 30 Nov 2025 23:27:15 +0700 Subject: [PATCH 15/63] feat: Refactor classroom and enrollment components to support course-based structure --- .../[locale]/classroom/[classroomId]/page.tsx | 22 +++---- .../detail/StudentClassroomDetails.tsx | 57 +++++++++---------- .../classroom/types/classroom.type.ts | 3 +- .../enrollment/types/enrollment.type.ts | 2 + 4 files changed, 39 insertions(+), 45 deletions(-) diff --git a/src/app/[locale]/classroom/[classroomId]/page.tsx b/src/app/[locale]/classroom/[classroomId]/page.tsx index 3771a0056..a779d5022 100644 --- a/src/app/[locale]/classroom/[classroomId]/page.tsx +++ b/src/app/[locale]/classroom/[classroomId]/page.tsx @@ -3,15 +3,14 @@ import SEmpty from '@/components/shared/empty/SEmpty' import LoadingComponent from '@/components/shared/loading/LoadingComponent' import { AssignmentList } from '@/features/assignment/components/table/AssignmentList' import { useGetClassroomByIdQuery } from '@/features/classroom/api/classroomApi' -import ClassroomCourseList from '@/features/classroom/components/detail/ClassroomCourseList' import StudentClassroomDetail from '@/features/classroom/components/detail/StudentClassroomDetails' import ClassroomOverview from '@/features/classroom/components/overview/ClassroomOverview' +import { ClassroomSchedule } from '@/features/classroom/components/schedule/ClassroomSchedule' import ClassroomSubHeader from '@/features/classroom/components/ui/ClassroomSubHeader' -import { useSearchCurriculumEnrollmentQuery } from '@/features/enrollment/api/curriculumEnrollmentApi' +import { useSearchCourseEnrollmentQuery } from '@/features/enrollment/api/courseEnrollmentApi' import TeacherQuiz from '@/features/quiz/components/TeacherQuiz' import { useAppSelector } from '@/hooks/redux-hooks' import { LicenseType } from '@/types/userRole' -import { useLocale } from 'next-intl' import { useParams } from 'next/navigation' import React from 'react' @@ -24,15 +23,15 @@ export default function ClassroomDetailPage() { const [currentTab, setCurrentTab] = React.useState('overview') const { data: classroomData, isLoading } = useGetClassroomByIdQuery(Number(classroomId)) - const { data: curriculumEnrollment } = useSearchCurriculumEnrollmentQuery( + const { data: courseEnrollment } = useSearchCourseEnrollmentQuery( { - curriculumId: classroomData?.data.curriculum.id, + courseId: classroomData?.data.course.id, studentId: auth?.user?.userId || '', classroomId: Number(classroomId), pageNumber: 1, pageSize: 20 }, - { skip: !auth.user?.userId || !classroomData?.data.curriculum.id || currentRole !== LicenseType.STUDENT } + { skip: !auth.user?.userId || !classroomData?.data.course.id || currentRole !== LicenseType.STUDENT } ) if (isLoading) { return ( @@ -50,18 +49,11 @@ export default function ClassroomDetailPage() { {currentTab === 'overview' && currentRole === LicenseType.TEACHER ? : null} {currentTab === 'overview' && currentRole === LicenseType.STUDENT ? ( - + ) : null} {currentTab === 'course' ? (
- +
) : null} {currentTab === 'quiz' ? ( diff --git a/src/features/classroom/components/detail/StudentClassroomDetails.tsx b/src/features/classroom/components/detail/StudentClassroomDetails.tsx index 53c6a4412..eabd5c781 100644 --- a/src/features/classroom/components/detail/StudentClassroomDetails.tsx +++ b/src/features/classroom/components/detail/StudentClassroomDetails.tsx @@ -26,7 +26,7 @@ import { ClassroomStatus } from '@/features/classroom/types/classroom.type' import Link from 'next/link' import Image from 'next/image' import { getStatusBadgeClass } from '@/utils/badgeColor' -import { useAppSelector } from '@/hooks/redux-hooks' +import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' import { LicenseType, UserRole } from '@/types/userRole' import { useCreateCurriculumEnrollmentMutation, @@ -35,21 +35,23 @@ import { import { useRouter } from 'next/navigation' import { useLocale, useTranslations } from 'next-intl' import { signIn } from 'next-auth/react' -import { CurriculumEnrollment, EnrollmentStatus } from '@/features/enrollment/types/enrollment.type' +import { CourseEnrollment, CurriculumEnrollment, EnrollmentStatus } from '@/features/enrollment/types/enrollment.type' import { toast } from 'sonner' import { ClassroomNavItems } from 'app/[locale]/classroom/[classroomId]/page' +import { setCourseEnrollmentId } from '@/features/enrollment/slice/enrollmentSlice' export type StudentClassroomDetailProps = { - curriculumEnrollment?: CurriculumEnrollment + courseEnrollment?: CourseEnrollment setCurrentTab: (tab: ClassroomNavItems) => void } -export default function StudentClassroomDetail({ curriculumEnrollment, setCurrentTab }: StudentClassroomDetailProps) { +export default function StudentClassroomDetail({ courseEnrollment, setCurrentTab }: StudentClassroomDetailProps) { const tc = useTranslations('common') const tt = useTranslations('toast') const { classroomId } = useParams() const auth = useAppSelector((state) => state.auth) const router = useRouter() const locale = useLocale() + const dispatch = useAppDispatch() const { data, isLoading } = useGetClassroomByIdQuery(Number(classroomId)) const classroom = data?.data @@ -67,9 +69,9 @@ export default function StudentClassroomDetail({ curriculumEnrollment, setCurren signIn('oidc', { callbackUrl: `/`, prompt: 'login' }) return } - if (classroom?.curriculum.id) { + if (classroom?.course.id) { createEnrollment({ - curriculumId: classroom?.curriculum.id, + curriculumId: classroom?.course.id, studentId: auth?.user?.userId, status: EnrollmentStatus.IN_PROGRESS, classroomId: Number(classroomId) @@ -120,21 +122,21 @@ export default function StudentClassroomDetail({ curriculumEnrollment, setCurren {/* Left Column - Main Info */}
{/* Curriculum Card */} - {classroom.curriculum && ( + {classroom.course && ( - Curriculum + Course
- {classroom.curriculum.imageUrl && ( + {classroom.course.imageUrl && (
{classroom.curriculum.title} @@ -142,29 +144,26 @@ export default function StudentClassroomDetail({ curriculumEnrollment, setCurren )}
-

{classroom.curriculum.title}

+

{classroom.course.title}

- {classroom.curriculum.code} + {classroom.course.code}
-

{classroom.curriculum.description}

-
-
- - {classroom.curriculum.courseCount} Courses -
-
+

{classroom.course.description}

+ {courseEnrollment ? ( + + ) : ( + + )}
- {curriculumEnrollment ? ( - - ) : ( - - )} )} diff --git a/src/features/classroom/types/classroom.type.ts b/src/features/classroom/types/classroom.type.ts index 36d8200ac..fee16894f 100644 --- a/src/features/classroom/types/classroom.type.ts +++ b/src/features/classroom/types/classroom.type.ts @@ -1,3 +1,4 @@ +import { Course } from '@/features/resource/course/types/course.type' import { Curriculum } from '@/features/resource/curriculum/types/curriculum.type' import { SliceQueryParams } from '@/libs/redux/createQuerySlice' import { SearchPaginatedRequestParams } from '@/types/baseModel' @@ -22,7 +23,7 @@ export type Classroom = { status: ClassroomStatus numberOfStudents: number students: any[] - curriculum: Curriculum + course: Course // curriculum: Pick organizationSubscriptionOrderId: number } diff --git a/src/features/enrollment/types/enrollment.type.ts b/src/features/enrollment/types/enrollment.type.ts index 4b8ea9024..b3d435577 100644 --- a/src/features/enrollment/types/enrollment.type.ts +++ b/src/features/enrollment/types/enrollment.type.ts @@ -36,6 +36,7 @@ export type CourseEnrollment = { progressPercentage: number verificationCode?: string curriculumEnrollmentId?: number + classroomId?: number } export type CurriculumEnrollment = { @@ -64,6 +65,7 @@ export type CurriculumEnrollment = { export type CourseEnrollmentQueryParams = { studentId?: string courseId?: number + classroomId?: number } & SearchPaginatedRequestParams export type CurriculumEnrollmentQueryParams = { From a532efbe96848a3ee9a78da06f7293c28df2ec4b Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Sun, 30 Nov 2025 23:37:38 +0700 Subject: [PATCH 16/63] feat: Update terminology from "Courses" to "Lessons" in English and Vietnamese translations --- messages/en/common/en_common.json | 2 +- messages/vi/common/vi_common.json | 2 +- .../components/list/table/ClassroomColumn.tsx | 14 +++++--------- .../components/list/table/ClassroomTable.tsx | 6 +++--- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 60c94acaa..445ba057a 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -180,7 +180,7 @@ "grade": "Grade", "teacher": "Teacher", "numberOfStudents": "No. Students", - "numberOfCourses": "No. Courses", + "numberOfLessons": "No. Lessons", "accountType": "Account Type", "assignedDate": "Assigned Date" }, diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index ce3c33067..1397b1db8 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -179,7 +179,7 @@ "grade": "Lớp", "teacher": "Giáo Viên", "numberOfStudents": "Số Học Sinh", - "numberOfCourses": "Số Khóa Học", + "numberOfLessons": "Số Bài Học", "accountType": "Loại Tài Khoản", "assignedDate": "Ngày Gán" }, diff --git a/src/features/classroom/components/list/table/ClassroomColumn.tsx b/src/features/classroom/components/list/table/ClassroomColumn.tsx index f08e25ee4..f554fafdb 100644 --- a/src/features/classroom/components/list/table/ClassroomColumn.tsx +++ b/src/features/classroom/components/list/table/ClassroomColumn.tsx @@ -27,16 +27,12 @@ export function useGetClassroomColumn(): ColumnDef[] { className: 'border-r border-gray-200' }, cell: ({ row }) => { - const curriculum = row.original.curriculum + const course = row.original.course const classroomId = row.original.id return (
- {curriculum.imageUrl ? ( - {curriculum.title} + {course.imageUrl ? ( + {course.title} ) : (
@@ -49,10 +45,10 @@ export function useGetClassroomColumn(): ColumnDef[] { router.push(`/${locale}/organization/classroom/${classroomId}`) }} > - {curriculum.title} + {course.title}

- {tc('tableHeader.numberOfCourses')}: {curriculum.courseCount} + {tc('tableHeader.numberOfLessons')}: {course.lessonCount}

diff --git a/src/features/classroom/components/list/table/ClassroomTable.tsx b/src/features/classroom/components/list/table/ClassroomTable.tsx index 99e0514fd..34cb34f82 100644 --- a/src/features/classroom/components/list/table/ClassroomTable.tsx +++ b/src/features/classroom/components/list/table/ClassroomTable.tsx @@ -37,12 +37,12 @@ export default function ClassroomTable() { const items = data?.data.items ?? [] // Sắp xếp theo curriculum.id để nhóm các classroom cùng curriculum lại - const sorted = [...items].sort((a, b) => a.curriculum.id - b.curriculum.id) + const sorted = [...items].sort((a, b) => a.course.id - b.course.id) // Đếm số classroom cho mỗi curriculum const curriculumGroups = new Map() sorted.forEach((item) => { - const curriculumId = item.curriculum.id + const curriculumId = item.course.id curriculumGroups.set(curriculumId, (curriculumGroups.get(curriculumId) || 0) + 1) }) @@ -51,7 +51,7 @@ export default function ClassroomTable() { let curriculumRowCount = 0 return sorted.map((item, index) => { - const curriculumId = item.curriculum.id + const curriculumId = item.course.id const isNewCurriculum = curriculumId !== currentCurriculumId // Meta data cho curriculum cell From 7cdb82a9ce3afcf88dfe0c2f9da16760a1afc540 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 1 Dec 2025 00:34:35 +0700 Subject: [PATCH 17/63] feat: Add 'Locked' status to progress and update related components for lesson accessibility --- messages/en/common/en_common.json | 3 +- messages/vi/common/vi_common.json | 3 +- .../detail/enrolled/CourseDetailContent.tsx | 106 ++++++++++++------ .../components/detail/LessonOutline.tsx | 45 ++++++-- .../types/studentProgress.type.ts | 3 +- src/utils/badgeColor.ts | 33 ++++++ 6 files changed, 143 insertions(+), 50 deletions(-) diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 445ba057a..ad7ffdfe5 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -223,7 +223,8 @@ "suspended": "Suspended", "upcoming": "Upcoming", "endsoon": "End Soon", - "inprogress": "In Progress" + "inprogress": "In Progress", + "locked": "Locked" }, "level": { "all": "All Levels", diff --git a/messages/vi/common/vi_common.json b/messages/vi/common/vi_common.json index 1397b1db8..46cfaf32c 100644 --- a/messages/vi/common/vi_common.json +++ b/messages/vi/common/vi_common.json @@ -228,7 +228,8 @@ "suspended": "Tạm Ngưng", "upcoming": "Sắp Diễn Ra", "endsoon": "Kết Thúc Sớm", - "inprogress": "Đang Diễn Ra" + "inprogress": "Đang Diễn Ra", + "locked": "Khóa" }, "accountType": { "accountTypeLabel": "Loại Tài Khoản", diff --git a/src/features/resource/course/components/detail/enrolled/CourseDetailContent.tsx b/src/features/resource/course/components/detail/enrolled/CourseDetailContent.tsx index 5f10ad2aa..da9b1bb98 100644 --- a/src/features/resource/course/components/detail/enrolled/CourseDetailContent.tsx +++ b/src/features/resource/course/components/detail/enrolled/CourseDetailContent.tsx @@ -16,16 +16,19 @@ import { } from '@/features/student-progress/slice/studentProgressSlice' import { ProgressStatus, StudentProgress } from '@/features/student-progress/types/studentProgress.type' import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' -import { formatDuration } from '@/utils/index' +import { formatDuration, useStatusTranslation } from '@/utils/index' +import { getStatusBadgeClass } from '@/utils/badgeColor' +import { cn } from '@/utils/shadcn/utils' import { skipToken } from '@reduxjs/toolkit/query' -import { EllipsisVertical } from 'lucide-react' +import { Lock, CheckCircle2, Clock } from 'lucide-react' import { useTranslations } from 'next-intl' import Link from 'next/link' import { useEffect } from 'react' +import { toast } from 'sonner' type CourseDetailContentProps = { courseId: number - enrollmentId?: number // Optional if not always provided + enrollmentId?: number } export default function CourseDetailContent({ courseId, enrollmentId }: CourseDetailContentProps) { @@ -34,6 +37,8 @@ export default function CourseDetailContent({ courseId, enrollmentId }: CourseDe const dispatch = useAppDispatch() const lessonParams = useAppSelector((state) => state.lesson) + const translateStatus = useStatusTranslation() + useEffect(() => { dispatch(setPageSize(12)) }, [dispatch]) @@ -44,6 +49,7 @@ export default function CourseDetailContent({ courseId, enrollmentId }: CourseDe isFetching } = useSearchLessonQuery({ ...lessonParams, courseId, orderBy: 'orderindex', sortDirection: 'Asc' }) const { data: lessonProgressData } = useGetLessonStudentProgressQuery(enrollmentId ? { enrollmentId } : skipToken) + const progressMap = lessonProgressData?.data?.items?.reduce( (acc, progress) => { if ('lessonId' in progress && progress.lessonId !== undefined) { @@ -59,8 +65,13 @@ export default function CourseDetailContent({ courseId, enrollmentId }: CourseDe const handlePageChange = (newPage: number) => { dispatch(setPageIndex(newPage)) } - const handleSelectLesson = (lessonId: number) => { - const status = progressMap?.[lessonId] + + const handleSelectLesson = (lessonId: number, status?: ProgressStatus) => { + if (status === ProgressStatus.LOCKED) { + toast.error('This lesson is locked. Complete previous lessons to unlock it.') + return + } + if (status) { dispatch(setSelectedLessonStatus(status)) dispatch(setSelectedEnrollmentId(enrollmentId)) @@ -88,20 +99,30 @@ export default function CourseDetailContent({ courseId, enrollmentId }: CourseDe
{lessonData.data.items.map((lesson) => { - return ( -
- handleSelectLesson(lesson.id)} - className='flex w-fit flex-col justify-between' + const status = progressMap?.[lesson.id] + const isLocked = status === ProgressStatus.LOCKED + + if (isLocked) { + return ( +
handleSelectLesson(lesson.id, status)} > +
+
+
+ +
+ {translateStatus(ProgressStatus.LOCKED)} +
+
+ {progressMap[lesson.id]} - ) - } footer={
{lesson.ageRangeLabel} @@ -115,27 +136,40 @@ export default function CourseDetailContent({ courseId, enrollmentId }: CourseDe

{lesson.description}

- - -
- - } - items={[ -

- {tc('button.view')} -

, -

- {tc('button.add')} -

, -

- {tc('button.share')} -

- ]} - />
-
+ ) + } + + // ✅ Normal lesson card + return ( + handleSelectLesson(lesson.id, status)} + > + + {translateStatus(status)} + + ) + } + footer={ +
+ {lesson.ageRangeLabel} + {formatDuration(lesson.duration)} +
+ } + > +
+

{t('details.lesson.cardTitle')}

+

{lesson.title}

+

{lesson.description}

+
+
+ ) })}
diff --git a/src/features/resource/lesson/components/detail/LessonOutline.tsx b/src/features/resource/lesson/components/detail/LessonOutline.tsx index b7e4c2046..c06f7c087 100644 --- a/src/features/resource/lesson/components/detail/LessonOutline.tsx +++ b/src/features/resource/lesson/components/detail/LessonOutline.tsx @@ -22,8 +22,8 @@ export default function LessonOutline({ sectionData, sectionStatus }: LessonOutl const t = useTranslations('LessonDetails') const { data: userData } = useSession() - if (!sectionData || sectionData.length === 0) { - return
{t('notFound.no_section_v2')}
+ const getSectionStatus = (sectionId: number) => { + return sectionStatus?.data.items.find((item) => item.sectionId === sectionId)?.status } const completedSectionIds = new Set( @@ -33,6 +33,10 @@ export default function LessonOutline({ sectionData, sectionStatus }: LessonOutl const isLoggedIn = !!userData const isVisibleSection = role === LicenseType.TEACHER || role === UserRole.ADMIN || role === UserRole.STAFF + if (!sectionData || sectionData.length === 0) { + return
{t('notFound.no_section_v2')}
+ } + return (

{t('sections')}

@@ -47,33 +51,52 @@ export default function LessonOutline({ sectionData, sectionStatus }: LessonOutl return true }) .map((sec) => { + const status = getSectionStatus(sec.id) + const isLocked = status === ProgressStatus.LOCKED + const isCompleted = status === ProgressStatus.COMPLETED const isSelected = sec.id === selectedSectionId - const isCompleted = completedSectionIds.has(sec.id) + + // ✅ Determine if section is clickable + const isClickable = isLoggedIn && !isLocked return (
{ - if (isLoggedIn) { + // ✅ Only allow click if section is clickable + if (isClickable) { dispatch(setSelectedSectionId(sec.id)) } }} >
- {!isLoggedIn ? ( - + {/* ✅ Icon logic: Lock for not logged in OR locked status */} + {!isLoggedIn || isLocked ? ( + ) : ( - isCompleted && + isCompleted && )} + + {/* ✅ Teacher-only section indicator */} {isVisibleSection && !sec.isVisibleToStudent && } -
{sec.title}
+ + {/* ✅ Section title with appropriate styling */} +
{sec.title}
+
+ + {/* ✅ Duration with appropriate styling */} +
+ {sec.duration} mins
-
{sec.duration} mins
) })} diff --git a/src/features/student-progress/types/studentProgress.type.ts b/src/features/student-progress/types/studentProgress.type.ts index f7b957164..caa39b505 100644 --- a/src/features/student-progress/types/studentProgress.type.ts +++ b/src/features/student-progress/types/studentProgress.type.ts @@ -3,7 +3,8 @@ import { SearchPaginatedRequestParams } from '@/types/baseModel' export enum ProgressStatus { NOT_STARTED = 'NotStarted', IN_PROGRESS = 'InProgress', - COMPLETED = 'Completed' + COMPLETED = 'Completed', + LOCKED = 'Locked' } export type ProgressType = 'lesson' | 'section' diff --git a/src/utils/badgeColor.ts b/src/utils/badgeColor.ts index 9de19f76b..f6364fc5d 100644 --- a/src/utils/badgeColor.ts +++ b/src/utils/badgeColor.ts @@ -17,6 +17,8 @@ export const statusColors: Record = { FAILED: 'bg-red-100 text-red-800 border border-red-300', CANCELLED: 'bg-red-100 text-red-800 border border-red-300', + LOCKED: 'bg-blue-100 text-blue-800 border border-blue-300', + ARCHIVED: 'bg-orange-100 text-orange-800 border border-orange-300' } @@ -37,3 +39,34 @@ export const getLevelBadgeClass = (level: CourseLevel): string => { return 'bg-gray-100 text-gray-800' } } + +export const getStatusWithIcon = (status: string) => { + const statusUpper = status.toUpperCase() + + switch (statusUpper) { + case 'COMPLETED': + return { + className: statusColors[statusUpper], + icon: 'CheckCircle2', + text: status + } + case 'LOCKED': + return { + className: statusColors[statusUpper], + icon: 'Lock', + text: status + } + case 'INPROGRESS': + return { + className: statusColors[statusUpper], + icon: 'Clock', + text: 'In Progress' + } + default: + return { + className: statusColors[statusUpper] ?? statusColors.DRAFT, + icon: null, + text: status + } + } +} From 76e48d0f33bb057648f9bef84b8760f6de90c1e8 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 1 Dec 2025 01:11:32 +0700 Subject: [PATCH 18/63] feat: Update ClassroomList to use 'course' instead of 'curriculum' for image and title display --- src/features/classroom/components/list/ClassroomList.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/classroom/components/list/ClassroomList.tsx b/src/features/classroom/components/list/ClassroomList.tsx index 117cfd2f8..d8cdcbbc5 100644 --- a/src/features/classroom/components/list/ClassroomList.tsx +++ b/src/features/classroom/components/list/ClassroomList.tsx @@ -80,9 +80,9 @@ export default function ClassroomList() { {/* Image Header */}
- {classroom.curriculum?.imageUrl ? ( + {classroom.course?.imageUrl ? ( {classroom.name} @@ -111,10 +111,10 @@ export default function ClassroomList() {
{/* Curriculum */} - {classroom.curriculum && ( + {classroom.course && (
- {classroom.curriculum.title} + {classroom.course.title}
)} From a75ee66084059e9e2d5a3896ef0d2b34797b5cfe Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 1 Dec 2025 01:17:42 +0700 Subject: [PATCH 19/63] feat: Update terminology from "Curriculum" to "Course" in UI components and translations --- messages/vi/classroom/vi_classroom.json | 2 +- .../components/list/ClassroomList.tsx | 8 ++-- .../list/OrganizationClassroomList.tsx | 13 ----- .../components/list/table/ClassroomTable.tsx | 48 +++++++++---------- src/utils/index.ts | 2 +- 5 files changed, 30 insertions(+), 43 deletions(-) diff --git a/messages/vi/classroom/vi_classroom.json b/messages/vi/classroom/vi_classroom.json index a255c54d3..b5ca3ecb8 100644 --- a/messages/vi/classroom/vi_classroom.json +++ b/messages/vi/classroom/vi_classroom.json @@ -3,7 +3,7 @@ "list": { "header": "Danh sách lớp học", "searchPlaceholder": "Tìm kiếm...", - "selectCurriculumPlaceholder": "Chọn chương trình học", + "selectCoursePlaceholder": "Chọn chương trình học", "courses": "khóa học" }, "update": { diff --git a/src/features/classroom/components/list/ClassroomList.tsx b/src/features/classroom/components/list/ClassroomList.tsx index d8cdcbbc5..25a659ee4 100644 --- a/src/features/classroom/components/list/ClassroomList.tsx +++ b/src/features/classroom/components/list/ClassroomList.tsx @@ -15,10 +15,12 @@ import { SkeletonCard } from '@/components/shared/skeleton/SkeletonCard' import SearchBar from '@/components/shared/search/SearchBar' import SSelect from '@/components/shared/SSelect' -import { useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' +import { formatDate } from '@/utils/index' export default function ClassroomList() { const tClassroom = useTranslations('classroom') + const locale = useLocale() const user = useAppSelector((state) => state.auth?.user) const queryParams = useAppSelector((state) => state.classroom) @@ -122,8 +124,8 @@ export default function ClassroomList() {
- {format(new Date(classroom.startDate), 'MMM dd')} -{' '} - {format(new Date(classroom.endDate), 'MMM dd, yyyy')} + {formatDate(classroom.startDate, { locale: locale })} -{' '} + {formatDate(classroom.endDate, { locale: locale })}
diff --git a/src/features/classroom/components/list/OrganizationClassroomList.tsx b/src/features/classroom/components/list/OrganizationClassroomList.tsx index cbf225984..28170b2d9 100644 --- a/src/features/classroom/components/list/OrganizationClassroomList.tsx +++ b/src/features/classroom/components/list/OrganizationClassroomList.tsx @@ -9,19 +9,6 @@ export default function OrganizationClassroomList() { return (
- {/*
-
-

Curriculums

-

Running curriculums

-
-
- -
- {organizationSubscriptionData?.data.items.map((classItem) => ( - // -
- ))} -
*/}
diff --git a/src/features/classroom/components/list/table/ClassroomTable.tsx b/src/features/classroom/components/list/table/ClassroomTable.tsx index 34cb34f82..9d47655de 100644 --- a/src/features/classroom/components/list/table/ClassroomTable.tsx +++ b/src/features/classroom/components/list/table/ClassroomTable.tsx @@ -14,8 +14,8 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' import { resetParams, setParam, setSearchTerm } from '@/features/classroom/slice/classroomSlice' import useDebounce from '@/hooks/useDebounce' import { SingleSelectWithSearch } from '@/components/shared/SingleSelectWithSearch' -import { useSearchCurriculumQuery } from '@/features/resource/curriculum/api/curriculumApi' import { getOptions } from '@/utils/index' +import { useSearchCourseQuery } from '@/features/resource/course/api/courseApi' export default function ClassroomTable() { const tClassroom = useTranslations('classroom') @@ -32,38 +32,36 @@ export default function ClassroomTable() { const debouncedSearchQuery = useDebounce(search, 500) const { data } = useSearchClassroomsQuery({ ...queryParams, organizationId: organizationId }) - // Xử lý data để merge curriculum cells + // Xử lý data để merge course cells const rows = React.useMemo(() => { const items = data?.data.items ?? [] - // Sắp xếp theo curriculum.id để nhóm các classroom cùng curriculum lại const sorted = [...items].sort((a, b) => a.course.id - b.course.id) - // Đếm số classroom cho mỗi curriculum - const curriculumGroups = new Map() + const courseGroups = new Map() sorted.forEach((item) => { - const curriculumId = item.course.id - curriculumGroups.set(curriculumId, (curriculumGroups.get(curriculumId) || 0) + 1) + const courseId = item.course.id + courseGroups.set(courseId, (courseGroups.get(courseId) || 0) + 1) }) // Thêm meta data cho mỗi row để biết cell nào cần merge - let currentCurriculumId: number | null = null - let curriculumRowCount = 0 + let currentcourseId: number | null = null + let courseRowCount = 0 return sorted.map((item, index) => { - const curriculumId = item.course.id - const isNewCurriculum = curriculumId !== currentCurriculumId + const courseId = item.course.id + const isNewcourse = courseId !== currentcourseId - // Meta data cho curriculum cell + // Meta data cho course cell const cellMeta: any = { - curriculum: isNewCurriculum ? { rowSpan: curriculumGroups.get(curriculumId) || 1, skip: false } : { skip: true } + course: isNewcourse ? { rowSpan: courseGroups.get(courseId) || 1, skip: false } : { skip: true } } - if (isNewCurriculum) { - currentCurriculumId = curriculumId - curriculumRowCount = 0 + if (isNewcourse) { + currentcourseId = courseId + courseRowCount = 0 } - curriculumRowCount++ + courseRowCount++ return { ...item, @@ -74,9 +72,9 @@ export default function ClassroomTable() { const columns = useGetClassroomColumn() - const searchCurriculumQuery = useAppSelector((state) => state.curriculum) - const { data: curriculumData } = useSearchCurriculumQuery({ - ...searchCurriculumQuery + const searchcourseQuery = useAppSelector((state) => state.course) + const { data: courseData } = useSearchCourseQuery({ + ...searchcourseQuery }) const statusOptions = [ @@ -86,7 +84,7 @@ export default function ClassroomTable() { { label: tc('status.completed'), value: 'completed' } ] - const curriculumOptions = getOptions(curriculumData?.data.items, 'title', 'imageUrl', 'courseCount').map((opt) => ({ + const courseOptions = getOptions(courseData?.data.items, 'title', 'imageUrl', 'courseCount').map((opt) => ({ ...opt, subLabel: opt.subLabel ? `${opt.subLabel} ${tClassroom('list.courses')}` : undefined })) @@ -126,10 +124,10 @@ export default function ClassroomTable() { style={{ width: '320px' }} /> dispatch(setParam({ key: 'curriculumId', value: Number(val) }))} + value={queryParams.courseId?.toString() ?? ''} + options={courseOptions} + placeholder={tClassroom('list.selectCoursePlaceholder')} + onChange={(val) => dispatch(setParam({ key: 'courseId', value: Number(val) }))} />
diff --git a/src/utils/index.ts b/src/utils/index.ts index a35add1b0..bee002dac 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -42,7 +42,7 @@ export function getDaysRemaining(endDateStr: string): number { */ export type DateFormatPattern = 'dd/MM/yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd' export interface FormatDateOptions { - locale?: 'en' | 'vi' + locale?: string showTime?: boolean pattern?: DateFormatPattern year?: 'numeric' | '2-digit' From c5920f0bfafead9521e46e4274d39020d2832497 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 1 Dec 2025 01:20:53 +0700 Subject: [PATCH 20/63] feat: Enhance date formatting and status translation in ClassroomColumn and SubscriptionPeriod components --- .../components/list/table/ClassroomColumn.tsx | 11 +++++------ .../components/upsert/SubscriptionPeriod.tsx | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/features/classroom/components/list/table/ClassroomColumn.tsx b/src/features/classroom/components/list/table/ClassroomColumn.tsx index f554fafdb..53e3a422b 100644 --- a/src/features/classroom/components/list/table/ClassroomColumn.tsx +++ b/src/features/classroom/components/list/table/ClassroomColumn.tsx @@ -4,7 +4,7 @@ import { createActionsColumnFromItems, createSelectColumn } from '@/components/s import { useDeleteClassroomMutation } from '@/features/classroom/api/classroomApi' import { Classroom, ClassroomStatus } from '@/features/classroom/types/classroom.type' import { getStatusBadgeClass } from '@/utils/badgeColor' -import { formatDateV2 } from '@/utils/index' +import { formatDate, formatDateV2, useStatusTranslation } from '@/utils/index' import { ColumnDef } from '@tanstack/react-table' import { GraduationCap, Users } from 'lucide-react' import { useLocale, useTranslations } from 'next-intl' @@ -13,6 +13,7 @@ import { toast } from 'sonner' export function useGetClassroomColumn(): ColumnDef[] { const tc = useTranslations('common') + const translateStatus = useStatusTranslation() const router = useRouter() const locale = useLocale() @@ -128,23 +129,21 @@ export function useGetClassroomColumn(): ColumnDef[] { header: tc('tableHeader.status'), cell: ({ row }) => { const status = row.original.status - return {status} + return {translateStatus(status)} } }, { accessorKey: 'startDate', header: tc('tableHeader.startDate'), cell: ({ row }) => { - const startDate = row.original.startDate - return {formatDateV2(new Date(startDate))} + return {formatDate(row.original.startDate, { locale })} } }, { accessorKey: 'endDate', header: tc('tableHeader.endDate'), cell: ({ row }) => { - const endDate = row.original.endDate - return {formatDateV2(new Date(endDate))} + return {formatDate(row.original.endDate, { locale })} } }, createActionsColumnFromItems([ diff --git a/src/features/subscription/components/upsert/SubscriptionPeriod.tsx b/src/features/subscription/components/upsert/SubscriptionPeriod.tsx index 5603e11f1..eefd40ce6 100644 --- a/src/features/subscription/components/upsert/SubscriptionPeriod.tsx +++ b/src/features/subscription/components/upsert/SubscriptionPeriod.tsx @@ -32,7 +32,7 @@ export default function SubscriptionPeriod({ startDate, endDate, onStartDateChan From 308ce4f4e4fb409073be27120289c4dff903618f Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 1 Dec 2025 01:26:02 +0700 Subject: [PATCH 21/63] feat: Update enrollment mutation to use course-based API and adjust related logic --- .../detail/StudentClassroomDetails.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/features/classroom/components/detail/StudentClassroomDetails.tsx b/src/features/classroom/components/detail/StudentClassroomDetails.tsx index eabd5c781..f4ce58ef1 100644 --- a/src/features/classroom/components/detail/StudentClassroomDetails.tsx +++ b/src/features/classroom/components/detail/StudentClassroomDetails.tsx @@ -39,6 +39,7 @@ import { CourseEnrollment, CurriculumEnrollment, EnrollmentStatus } from '@/feat import { toast } from 'sonner' import { ClassroomNavItems } from 'app/[locale]/classroom/[classroomId]/page' import { setCourseEnrollmentId } from '@/features/enrollment/slice/enrollmentSlice' +import { useCreateCourseEnrollmentMutation } from '@/features/enrollment/api/courseEnrollmentApi' export type StudentClassroomDetailProps = { courseEnrollment?: CourseEnrollment @@ -56,7 +57,7 @@ export default function StudentClassroomDetail({ courseEnrollment, setCurrentTab const { data, isLoading } = useGetClassroomByIdQuery(Number(classroomId)) const classroom = data?.data - const [createEnrollment, { data: createEnrollmentResponse }] = useCreateCurriculumEnrollmentMutation() + const [createEnrollment, { data: createEnrollmentResponse }] = useCreateCourseEnrollmentMutation() const copyClassCode = () => { if (classroom?.classCode) { @@ -71,13 +72,13 @@ export default function StudentClassroomDetail({ courseEnrollment, setCurrentTab } if (classroom?.course.id) { createEnrollment({ - curriculumId: classroom?.course.id, + courseId: classroom?.course.id, studentId: auth?.user?.userId, status: EnrollmentStatus.IN_PROGRESS, classroomId: Number(classroomId) }) toast.success(tt('successMessage.enroll'), { - description: `${tt('successMessage.enrollDes', { title: createEnrollmentResponse?.data.curriculumTitle || '' })}` + description: `${tt('successMessage.enrollDes', { title: createEnrollmentResponse?.data.courseTitle || '' })}` }) } } @@ -151,10 +152,13 @@ export default function StudentClassroomDetail({ courseEnrollment, setCurrentTab

{classroom.course.description}

{courseEnrollment ? ( - ) : ( From 159eebabf75f0e78a1d0f1d39583bbbebfe03e81 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 1 Dec 2025 01:30:43 +0700 Subject: [PATCH 22/63] feat: Add Organization User API and types for managing organization-specific users --- src/features/user/api/userApi.ts | 26 +++++++++++++++++++--- src/features/user/types/user.type.ts | 32 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/features/user/api/userApi.ts b/src/features/user/api/userApi.ts index 91e95ea92..1c8825f20 100644 --- a/src/features/user/api/userApi.ts +++ b/src/features/user/api/userApi.ts @@ -1,7 +1,12 @@ import { createCrudApi } from '@/libs/redux/baseApi' -import { User, UserQueryParams, UserSliceParams } from '../types/user.type' import { ApiSuccessResponse, PaginatedResult } from '@/types/baseModel' -import { LicenseAssignmentType } from '@/features/license-assignment/types/licenseAssignment' +import { + OrganizationUser, + OrganizationUserQueryParams, + User, + UserQueryParams, + UserSliceParams +} from '@/features/user/types/user.type' export const userApi = createCrudApi({ reducerPath: 'userApi', @@ -16,6 +21,19 @@ export const userApi = createCrudApi({ method: 'GET', params: userSliceParams }) + }), + + // Organization User APIs + getOrganizationUser: builder.query< + ApiSuccessResponse>, + OrganizationUserQueryParams + >({ + query: ({ organizationId, pageNumber, pageSize }) => ({ + url: `/organizations/${organizationId}/users`, + method: 'GET', + params: { pageNumber, pageSize } + }), + providesTags: ['User'] }) }) }) @@ -32,5 +50,7 @@ export const { useLazySearchQuery: useLazySearchUserQuery, useLazyGetAllQuery: useLazyGetAllUserQuery, useLazyGetByIdQuery: useLazyGetUserByIdQuery, - useSearchUserV2Query + useSearchUserV2Query, + + useGetOrganizationUserQuery } = userApi diff --git a/src/features/user/types/user.type.ts b/src/features/user/types/user.type.ts index 720b19da2..95129dc97 100644 --- a/src/features/user/types/user.type.ts +++ b/src/features/user/types/user.type.ts @@ -50,3 +50,35 @@ export type UserSliceParams = { subscription_order_id?: number | null license_type?: string | null } & SliceQueryParams + +// Organization User Types +export type OrganizationUser = { + userId: string + email: string + userName: string + fullName: string + firstName: string + lastName: string + subscriptions: OrganizationUserSubscription[] +} + +export type OrganizationUserSubscription = { + organizationUserId: string + organizationId: number + organizationRole: string + licenseType: string + licenseAssignmentId: string + subscriptionOrderId: number + isActive: boolean + joinedAt: string + classId: string + studentDateOfBirth: string + studentMajor: string + teacherSpecialization: string +} + +export type OrganizationUserQueryParams = { + organizationId: number + pageNumber?: number + pageSize?: number +} From 543c5d40e2bd43d1a435e2e9aa8f5d501367ec2a Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Mon, 1 Dec 2025 01:45:39 +0700 Subject: [PATCH 23/63] feat: Update classroom components to use course data instead of curriculum data and enhance layout in StudentProgressStatistic --- .../components/table/AssignmentList.tsx | 8 ------ .../components/list/TeacherClassroomList.tsx | 9 +++--- .../components/overview/ClassroomOverview.tsx | 21 ++++++++------ .../components/schedule/ClassroomSchedule.tsx | 2 +- .../table/StudentProgressStatistic.tsx | 28 +++++++++---------- 5 files changed, 31 insertions(+), 37 deletions(-) diff --git a/src/features/assignment/components/table/AssignmentList.tsx b/src/features/assignment/components/table/AssignmentList.tsx index 5f40b4a06..d7c965d1f 100644 --- a/src/features/assignment/components/table/AssignmentList.tsx +++ b/src/features/assignment/components/table/AssignmentList.tsx @@ -3,14 +3,6 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { Checkbox } from '@/components/shadcn/checkbox' import { Avatar, AvatarFallback, AvatarImage } from '@/components/shadcn/avatar' import { ChevronUp, Mic, FileText, MoreHorizontal } from 'lucide-react' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/shadcn/dropdown-menu' -import { Button } from '@/components/shadcn/button' -import { StatusBadge } from '@/features/quiz/components/active/badge/StatusBadge' import { ProgressCircle } from '@/features/quiz/components/active/circle/AccuracyCircle' import { useSearchStudentAssignmentQuery } from '../../api/studentAssignmentApi' import LoadingComponent from '@/components/shared/loading/LoadingComponent' diff --git a/src/features/classroom/components/list/TeacherClassroomList.tsx b/src/features/classroom/components/list/TeacherClassroomList.tsx index 35f640147..0c19381f3 100644 --- a/src/features/classroom/components/list/TeacherClassroomList.tsx +++ b/src/features/classroom/components/list/TeacherClassroomList.tsx @@ -79,9 +79,9 @@ export default function TeacherClassroomList() { {/* Image Header */}
- {classroom.curriculum?.imageUrl ? ( + {classroom.course?.imageUrl ? ( {classroom.name} @@ -109,11 +109,10 @@ export default function TeacherClassroomList() {
- {/* Curriculum */} - {classroom.curriculum && ( + {classroom.course && (
- {classroom.curriculum.title} + {classroom.course.title}
)} diff --git a/src/features/classroom/components/overview/ClassroomOverview.tsx b/src/features/classroom/components/overview/ClassroomOverview.tsx index f7bd68820..93f4163e1 100644 --- a/src/features/classroom/components/overview/ClassroomOverview.tsx +++ b/src/features/classroom/components/overview/ClassroomOverview.tsx @@ -20,6 +20,7 @@ import { useTranslations } from 'next-intl' import { Chart as ChartJS, CategoryScale, LinearScale, Title, Tooltip, Legend } from 'chart.js' import { Chart } from 'react-chartjs-2' import { BoxPlotController, BoxAndWiskers } from '@sgratzl/chartjs-chart-boxplot' +import { useGetCourseByIdQuery } from '@/features/resource/course/api/courseApi' // Register ChartJS components ChartJS.register(CategoryScale, LinearScale, Title, Tooltip, Legend, BoxPlotController, BoxAndWiskers) @@ -36,13 +37,17 @@ export default function ClassroomOverview() { }) const classroom = classroomRes?.data - const curriculumId = classroom?.curriculum?.id + const courseId = classroom?.course?.id - const { data: curriculumRes, isLoading: isLoadingCurriculum } = useGetCurriculumByIdQuery(curriculumId!, { - skip: !curriculumId + const { data: courseRes, isLoading: isLoadingCourse } = useGetCourseByIdQuery(courseId!, { + skip: !courseId }) - const { data: statsRes, isLoading: isLoadingStats, refetch: refetchStats } = useGetClassroomStatisticsQuery( + const { + data: statsRes, + isLoading: isLoadingStats, + refetch: refetchStats + } = useGetClassroomStatisticsQuery( { classroomId }, { skip: !classroomId @@ -52,7 +57,7 @@ export default function ClassroomOverview() { const [selectedStudentAssignmentId, setSelectedStudentAssignmentId] = useState(null) const ungradedAssignments = statsRes?.data?.ungradedAssignments || [] - const courses = curriculumRes?.data?.courses || [] + const lessons = courseRes?.data?.lessons || [] const courseStats = statsRes?.data?.courseStats || [] // --- Data Processing for Pie Charts --- @@ -138,7 +143,7 @@ export default function ClassroomOverview() { } } - if (isLoadingClassroom || isLoadingCurriculum || isLoadingStats) { + if (isLoadingClassroom || isLoadingCourse || isLoadingStats) { return } @@ -345,7 +350,7 @@ export default function ClassroomOverview() {
- + {/* */} setSelectedStudentAssignmentId(null)} onSuccess={() => { - refetchStats?.() + refetchStats?.() }} /> )} diff --git a/src/features/classroom/components/schedule/ClassroomSchedule.tsx b/src/features/classroom/components/schedule/ClassroomSchedule.tsx index 078e5a1b2..bf06b79c2 100644 --- a/src/features/classroom/components/schedule/ClassroomSchedule.tsx +++ b/src/features/classroom/components/schedule/ClassroomSchedule.tsx @@ -23,7 +23,7 @@ export function ClassroomSchedule({ classroomId, className }: ClassroomScheduleP const schedule = data.data return ( -
+
{/* Header Info */}
diff --git a/src/features/dashboard/components/table/StudentProgressStatistic.tsx b/src/features/dashboard/components/table/StudentProgressStatistic.tsx index 5a705da6f..07e771ada 100644 --- a/src/features/dashboard/components/table/StudentProgressStatistic.tsx +++ b/src/features/dashboard/components/table/StudentProgressStatistic.tsx @@ -28,7 +28,6 @@ interface StudentProgressStatisticProps { const COLUMN_WIDTH = 'w-[70px] min-w-[70px]' export function StudentProgressStatistic({ classroomId, courses }: StudentProgressStatisticProps) { - const t = useTranslations('dashboard.classroom') const tc = useTranslations('common') @@ -88,7 +87,7 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre } return ( -
+

{t('overview.progress.title')}

@@ -122,22 +121,21 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre - - - - - -

{t('overview.tooltip')}

-
-
- + + + + + +

{t('overview.tooltip')}

+
+
{isFetching && !currentLesson ? ( - + ) : !currentLesson ? (
{t('overview.progress.noLesson')}
) : ( @@ -167,7 +165,7 @@ export function StudentProgressStatistic({ classroomId, courses }: StudentProgre
dispatch(setSearchTerm(e.target.value))} + className='max-w-[400px] flex-1 border-gray-300 bg-white pl-10 hover:border-blue-400 focus:border-blue-500 focus:ring-2 focus:ring-blue-200' + /> + + + dispatch(setParam({ key: 'status', value: val as CurriculumStatus }))} + options={statusOptions} + onOpen={() => {}} + /> + + {/* Create action */} + +
+ +
+ ) + } + + return ( +
+

{t('list.title')}

+ +
+ {/* Search input */} + dispatch(setSearchTerm(e.target.value))} + className='max-w-[400px] flex-1 border-gray-300 bg-white pl-10 hover:border-blue-400 focus:border-blue-500 focus:ring-2 focus:ring-blue-200' + /> + + + dispatch(setParam({ key: 'status', value: val as CurriculumStatus }))} + options={statusOptions} + onOpen={() => {}} + /> + + {/* Create action */} + +
+ +
+
+ {curriculumData.data.items.map((curriculum) => ( + router.push(`/${locale}/organization/curriculum/${curriculum.id}`)} + > +
+

{curriculum.title}

+

{curriculum.description}

+
+ + {t('list.viewDetails')} > + +
+
+ ))} +
+ + {curriculumData?.data?.totalPages > 1 && ( + + )} +
+
+ ) +} From 1dc330524aa7ac77d3693648fe233e23e344c233 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Tue, 2 Dec 2025 01:17:15 +0700 Subject: [PATCH 25/63] feat: Add curriculum management features for organizations, including API integration and UI enhancements --- messages/en/organization/en_organization.json | 16 +- messages/vi/organization/vi_organization.json | 16 +- .../organization/api/organizationApi.ts | 15 +- .../organization/types/organization.type.ts | 28 ++ .../list/OrganizationCurriculumList.tsx | 254 ++++++++---------- 5 files changed, 190 insertions(+), 139 deletions(-) diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index 6f47e3b09..1a09f9874 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -190,6 +190,20 @@ "name": "Name", "studentCount": "Student Count" } + }, + "curriculum": { + "curriculum": "Curriculum", + "title": "Organization Curriculum List", + "noData": "No curriculum found for this organization.", + "noResultsForFilter": "No results found for the selected filter.", + "courseCount": "{count} courses", + "startDate": "Start Date", + "endDate": "End Date", + "courses": "Courses", + "filterByStatus": "Filter by Status", + "showing": "Showing", + "results": "results", + "all": "All" } } -} +} \ No newline at end of file diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index 1e348b017..e219e42ca 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -189,6 +189,20 @@ "name": "Tên", "studentCount": "Số học sinh" } + }, + "curriculum": { + "curriculum": "Khung chương trình", + "title": "Danh sách khung chương trình", + "noData": "Không tìm thấy chương trình giảng dạy nào cho tổ chức này.", + "noResultsForFilter": "Không tìm thấy kết quả nào cho bộ lọc đã chọn.", + "courseCount": "{count} khóa học", + "startDate": "Ngày bắt đầu", + "endDate": "Ngày kết thúc", + "courses": "Khóa học", + "filterByStatus": "Lọc theo trạng thái", + "showing": "Hiển thị", + "results": "kết quả", + "all": "Tất cả" } } -} +} \ No newline at end of file diff --git a/src/features/organization/api/organizationApi.ts b/src/features/organization/api/organizationApi.ts index 871181cce..5b1c3c1cc 100644 --- a/src/features/organization/api/organizationApi.ts +++ b/src/features/organization/api/organizationApi.ts @@ -1,5 +1,6 @@ import { Organization, + OrganizationCurriculum, OrganizationQueryParams, OrganizationSliceParams, OrganizationType @@ -19,6 +20,15 @@ export const organizationApi = createCrudApi({ query: () => '/organization-types', providesTags: ['Organization'] + }), + getCurriculumsByOrganizationId: build.query< + ApiSuccessResponse<{ curriculums: OrganizationCurriculum[] }>, + { organizationId: number } + >({ + query: ({ organizationId }) => ({ + url: `/organizations/${organizationId}/curriculums` + }), + providesTags: ['Organization'] }) }) }) @@ -33,5 +43,8 @@ export const { useDeleteMutation: useDeleteOrganizationMutation, // Org types - useGetAllOrganizationTypesQuery + useGetAllOrganizationTypesQuery, + + // Org Curriculums + useGetCurriculumsByOrganizationIdQuery } = organizationApi diff --git a/src/features/organization/types/organization.type.ts b/src/features/organization/types/organization.type.ts index a48e3f897..a6f143bee 100644 --- a/src/features/organization/types/organization.type.ts +++ b/src/features/organization/types/organization.type.ts @@ -46,3 +46,31 @@ export type OrganizationFormData = { image: File | null imageUrl?: string } + +// organization curriculum + +export type OrganizationCurriculum = { + id: number + title: string + imageUrl: string + courseCount: number + startDate: string + endDate: string + code: string + status: string + courses: OrganizationCurriculumCourse[] +} + +export type OrganizationCurriculumCourse = { + id: number + title: string + code: string + imageUrl: string + description: string + duration: number + status: string + level: string + ageRangeLabel: string + courseOrderIndex: number + lessons: any[] +} diff --git a/src/features/resource/curriculum/components/list/OrganizationCurriculumList.tsx b/src/features/resource/curriculum/components/list/OrganizationCurriculumList.tsx index 395b48280..ea367d7c6 100644 --- a/src/features/resource/curriculum/components/list/OrganizationCurriculumList.tsx +++ b/src/features/resource/curriculum/components/list/OrganizationCurriculumList.tsx @@ -1,181 +1,163 @@ 'use client' -import { SPagination } from '@/components/shared/SPagination' -import { useModal } from '@/providers/ModalProvider' import { useLocale, useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' -import React, { useEffect } from 'react' -import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' +import React, { useState, useMemo } from 'react' import LoadingComponent from '@/components/shared/loading/LoadingComponent' -import { toast } from 'sonner' import SEmpty from '@/components/shared/empty/SEmpty' - -import { CurriculumSliceParams, CurriculumStatus } from '@/features/resource/curriculum/types/curriculum.type' import CardLayout from '@/components/shared/card/CardLayout' -import Link from 'next/link' -import { Plus, Search } from 'lucide-react' -import { useSearchCurriculumQuery } from '@/features/resource/curriculum/api/curriculumApi' -import { - setPageIndex, - setPageSize, - setParam, - setSearchTerm -} from '@/features/resource/curriculum/slice/curriculumSlice' -import { Input } from '@/components/shadcn/input' -import SSelect from '@/components/shared/SSelect' -import { Button } from '@/components/shadcn/button' -import { useStatusTranslation } from '@/utils/index' +import { formatDate, useStatusTranslation } from '@/utils/index' +import { useGetCurriculumsByOrganizationIdQuery } from '@/features/organization/api/organizationApi' +import { Badge } from '@/components/shadcn/badge' +import { getStatusBadgeClass } from '@/utils/badgeColor' +import { BookOpen, Calendar, GraduationCap, Filter } from 'lucide-react' +import { CurriculumStatus } from '@/features/resource/curriculum/types/curriculum.type' export default function OrganizationCurriculumList() { - const t = useTranslations('curriculum') - const tt = useTranslations('toast') - const tc = useTranslations('common') + const t = useTranslations('organization.curriculum') const statusTranslate = useStatusTranslation() const router = useRouter() - const dispatch = useAppDispatch() - const { openModal } = useModal() const locale = useLocale() - const tList = useTranslations('curriculum.list') - - const filters = useAppSelector((state) => state.curriculum) + const [selectedStatus, setSelectedStatus] = useState('ALL') - const statusOptions = Object.entries(CurriculumStatus) - .filter(([key]) => key.toLowerCase() !== 'deleted') - .map(([key, value]) => ({ - label: statusTranslate(key), - value: value - })) + const { data: curriculumData, isLoading } = useGetCurriculumsByOrganizationIdQuery({ organizationId: 3 }) - const queryParams: CurriculumSliceParams = useAppSelector((state) => state.curriculum) - const { data: curriculumData, isLoading } = useSearchCurriculumQuery(queryParams) - const rows = React.useMemo(() => curriculumData?.data.items ?? [], [curriculumData]) + // Filter curriculums based on selected status + const filteredCurriculums = useMemo(() => { + if (!curriculumData?.data?.curriculums) return [] - useEffect(() => { - dispatch(setPageSize(6)) - }, [dispatch]) + if (selectedStatus === 'ALL') { + return curriculumData.data.curriculums + } - const handlePageChange = (newPage: number) => { - dispatch(setPageIndex(newPage)) - } + return curriculumData.data.curriculums.filter((curriculum) => curriculum.status === selectedStatus) + }, [curriculumData, selectedStatus]) if (isLoading) { return ( -
+
) } - if (!curriculumData || curriculumData.data.items.length === 0) { + if (!curriculumData || curriculumData.data.curriculums.length === 0) { return ( -
-

{t('list.title')}

- -
- {/* Search input */} - dispatch(setSearchTerm(e.target.value))} - className='max-w-[400px] flex-1 border-gray-300 bg-white pl-10 hover:border-blue-400 focus:border-blue-500 focus:ring-2 focus:ring-blue-200' - /> - - - dispatch(setParam({ key: 'status', value: val as CurriculumStatus }))} - options={statusOptions} - onOpen={() => {}} - /> - - {/* Create action */} - -
- +
+
) } + const statusOptions: Array = [ + 'ALL', + CurriculumStatus.DRAFT, + CurriculumStatus.PUBLISHED, + CurriculumStatus.ARCHIVED + ] + return (
-

{t('list.title')}

- -
- {/* Search input */} - dispatch(setSearchTerm(e.target.value))} - className='max-w-[400px] flex-1 border-gray-300 bg-white pl-10 hover:border-blue-400 focus:border-blue-500 focus:ring-2 focus:ring-blue-200' - /> - - - dispatch(setParam({ key: 'status', value: val as CurriculumStatus }))} - options={statusOptions} - onOpen={() => {}} - /> - - {/* Create action */} - + {/* Header Section */} +
+
+
+ +
+
+

{t('title')}

+

+ {curriculumData.data.curriculums.length} {t('curriculum')} +

+
+
+
+ + {/* Filter Section */} +
+
+
+ + {t('filterByStatus')}: +
+
+ {statusOptions.map((status) => ( + + ))} +
+
+ + {/* Results Count */} +
+ {t('showing')} {filteredCurriculums.length}{' '} + {t('results')} +
-
-
- {curriculumData.data.items.map((curriculum) => ( + {/* Empty State for Filtered Results */} + {filteredCurriculums.length === 0 ? ( +
+ +
+ ) : ( + /* Curriculum Grid */ +
+ {filteredCurriculums.map((curriculum) => ( router.push(`/${locale}/organization/curriculum/${curriculum.id}`)} + badge={ + + {statusTranslate(curriculum.status)} + + } + action={{curriculum.code}} > -
-

{curriculum.title}

-

{curriculum.description}

-
- - {t('list.viewDetails')} > - +
+ {/* Title */} +

+ {curriculum.title} +

+ +
+ {/* Course Count */} +
+ + + {curriculum.courseCount} {t('courses')} + +
+ + {/* Dates */} +
+ + {t('startDate')}: + {formatDate(curriculum.startDate, { locale })} +
+ +
+ + {t('endDate')}: + {formatDate(curriculum.endDate, { locale })} +
+
))}
- - {curriculumData?.data?.totalPages > 1 && ( - - )} -
+ )}
) } From abcfe01df99e545de4593d1fe1bfa83fd5275ce9 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Tue, 2 Dec 2025 12:03:54 +0700 Subject: [PATCH 26/63] feat: Update UI components and translations for organization context, including course selection and admin roles --- messages/en/classroom/en_classroom.json | 2 +- messages/en/common/en_common.json | 3 +- messages/en/organization/en_organization.json | 4 +- .../[locale]/organization/curriculum/page.tsx | 2 +- .../components/upsert/ClassroomBasicInfo.tsx | 76 +++---------------- .../list/AdminCurriculumCourseList.tsx | 42 +++++----- .../kit/components/list/KitListSection.tsx | 7 +- .../components/list/LearningOutcomeAction.tsx | 40 +++++----- .../components/list/LearningOutcomeTable.tsx | 20 +++-- 9 files changed, 79 insertions(+), 117 deletions(-) diff --git a/messages/en/classroom/en_classroom.json b/messages/en/classroom/en_classroom.json index c2f5c6c77..e07431107 100644 --- a/messages/en/classroom/en_classroom.json +++ b/messages/en/classroom/en_classroom.json @@ -3,7 +3,7 @@ "list": { "header": "Classroom List", "searchPlaceholder": "Search...", - "selectCurriculumPlaceholder": "Select curriculum", + "selectCoursePlaceholder": "Select course", "courses": "courses" }, "update": { diff --git a/messages/en/common/en_common.json b/messages/en/common/en_common.json index 77173d97d..ab0c87616 100644 --- a/messages/en/common/en_common.json +++ b/messages/en/common/en_common.json @@ -241,7 +241,8 @@ "student": "Student", "teacher": "Teacher", "member": "Member", + "organizationadmin": "Organization Admin", "organization_admin": "Organization Admin" } } -} \ No newline at end of file +} diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index 1a09f9874..517575f34 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -157,7 +157,7 @@ }, "license": { "header": "Create Organization Account(s)", - "subHeader": "New accounts will be created for this organization. The account will automatically be assigned a valid license from this subscription.", + "description": "New accounts will be created for this organization. The account will automatically be assigned a valid license from this subscription.", "downloadCSVTemplate": "Download CSV Template", "uploadCSV": "Select a CSV file to upload", "dragAndDrop": "Drag and drop your CSV file here", @@ -206,4 +206,4 @@ "all": "All" } } -} \ No newline at end of file +} diff --git a/src/app/[locale]/organization/curriculum/page.tsx b/src/app/[locale]/organization/curriculum/page.tsx index 26615c433..ac2edbfbf 100644 --- a/src/app/[locale]/organization/curriculum/page.tsx +++ b/src/app/[locale]/organization/curriculum/page.tsx @@ -3,7 +3,7 @@ import React from 'react' export default function OrganizationCurriculumPage() { return ( -
+
) diff --git a/src/features/classroom/components/upsert/ClassroomBasicInfo.tsx b/src/features/classroom/components/upsert/ClassroomBasicInfo.tsx index 8365e6497..10de89bec 100644 --- a/src/features/classroom/components/upsert/ClassroomBasicInfo.tsx +++ b/src/features/classroom/components/upsert/ClassroomBasicInfo.tsx @@ -21,19 +21,6 @@ type ClassroomBasicInfoProps = { maxDate: Date | undefined } -// Generate random class code like Google Meet (xxx-yyyy-zzz) -const generateClassCode = () => { - const chars = 'abcdefghijklmnopqrstuvwxyz' - const randomString = (length: number) => { - let result = '' - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)) - } - return result - } - return `${randomString(3)}-${randomString(4)}-${randomString(3)}` -} - export default function ClassroomBasicInfo({ form, minDate, maxDate }: ClassroomBasicInfoProps) { const tClassroom = useTranslations('classroom.create.step1') @@ -51,7 +38,6 @@ export default function ClassroomBasicInfo({ form, minDate, maxDate }: Classroom ] const [name, setName] = useState('') - const [classCode, setClassCode] = useState('') const [grade, setGrade] = useState('') const [description, setDescription] = useState('') const [durationWeeks, setDurationWeeks] = useState('8') @@ -60,18 +46,6 @@ export default function ClassroomBasicInfo({ form, minDate, maxDate }: Classroom const isCustomDuration = durationWeeks === 'custom' - // Generate initial class code - useEffect(() => { - const formClassCode = form.getFieldValue('classCode') - if (!formClassCode) { - const newCode = generateClassCode() - setClassCode(newCode) - form.setFieldValue('classCode', newCode) - } else { - setClassCode(formClassCode) - } - }, []) - // Sync với form khi mount (cho trường hợp edit) useEffect(() => { const formName = form.getFieldValue('name') @@ -107,10 +81,6 @@ export default function ClassroomBasicInfo({ form, minDate, maxDate }: Classroom form.setFieldValue('name', name) }, [name]) - useEffect(() => { - form.setFieldValue('classCode', classCode) - }, [classCode]) - useEffect(() => { form.setFieldValue('grade', grade) }, [grade]) @@ -135,51 +105,25 @@ export default function ClassroomBasicInfo({ form, minDate, maxDate }: Classroom } }, [endDate]) - const handleRegenerateCode = () => { - const newCode = generateClassCode() - setClassCode(newCode) - form.setFieldValue('classCode', newCode) - } - return (

{tClassroom('basicInfo')}

- {/* Classroom Name */} -
- - setName(e.target.value)} /> -
- {/* Row 1: class code + Grade */}
+ {/* Classroom Name */}
-
@@ -187,7 +131,7 @@ export default function ClassroomBasicInfo({ form, minDate, maxDate }: Classroom {tClassroom('gradeLevel')} * setSearch(e.target.value)} + className='w-80 bg-white py-4.5' + style={{ width: '420px' }} + /> +
+
+ + dispatch(setParam({ key: 'status', value: val as 'upcoming' | 'inprogress' | 'endsoon' | 'completed' })) + } + options={statusOptions} + /> +
+
+ + {/* Table */} + {}} + onRowClick={(val) => { + router.push(`/${locale}/organization/classroom/${val.id}`) + }} + /> +
+ ) } diff --git a/src/features/resource/course/components/detail/organization/OrganizationCourseClassroomCoulum.tsx b/src/features/resource/course/components/detail/organization/OrganizationCourseClassroomCoulum.tsx new file mode 100644 index 000000000..cb1bc3da0 --- /dev/null +++ b/src/features/resource/course/components/detail/organization/OrganizationCourseClassroomCoulum.tsx @@ -0,0 +1,135 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@/components/shadcn/avatar' +import { Badge } from '@/components/shadcn/badge' +import { createActionsColumnFromItems, createSelectColumn } from '@/components/shared/data-table/columns-helpers' +import { useDeleteClassroomMutation } from '@/features/classroom/api/classroomApi' +import { Classroom, ClassroomStatus } from '@/features/classroom/types/classroom.type' +import { getStatusBadgeClass } from '@/utils/badgeColor' +import { formatDate, formatDateV2, useStatusTranslation } from '@/utils/index' +import { ColumnDef } from '@tanstack/react-table' +import { GraduationCap, Users } from 'lucide-react' +import { useLocale, useTranslations } from 'next-intl' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' + +export function useGetOrganizationCourseClassroomColumn(): ColumnDef[] { + const tc = useTranslations('common') + const translateStatus = useStatusTranslation() + + const router = useRouter() + const locale = useLocale() + const [deleteClassroom] = useDeleteClassroomMutation() + + return [ + { + accessorKey: 'classCode', + header: tc('tableHeader.classCode'), + cell: ({ row }) => { + return {row.original.classCode} + } + }, + { + accessorKey: 'id', + header: '', + cell: ({ row }) => {} + }, + + { + accessorKey: 'grade', + header: tc('tableHeader.grade') + }, + + { + accessorKey: 'teacherNameAndEmail', + header: () =>

{tc('tableHeader.teacher')}

, + cell: ({ row }) => { + const teacher = row.original.teacher + return ( +
+ {teacher.name} + {teacher.email} +
+ ) + } + }, + { + accessorKey: 'numberOfStudents', + header: tc('tableHeader.numberOfStudents'), + cell: ({ row }) => { + const numberOfStudents = row.original.numberOfStudents + + // Nếu không có học viên, hiển thị dấu gạch ngang + if (numberOfStudents === 0) { + return ( +
+ 0 +
+ ) + } + + return ( +
+ {/* Hiển thị tối đa 3 avatar mặc định */} + {[...Array(Math.min(3, numberOfStudents))].map((_, index) => ( + + + + S{index + 1} + + + ))} + + {/* Hiển thị +số nếu có hơn 3 học viên */} + {numberOfStudents > 3 && ( +
+ +{numberOfStudents - 3} +
+ )} +
+ ) + } + }, + { + accessorKey: 'status', + header: tc('tableHeader.status'), + cell: ({ row }) => { + const status = row.original.status + return {translateStatus(status)} + } + }, + { + accessorKey: 'startDate', + header: tc('tableHeader.startDate'), + cell: ({ row }) => { + return {formatDate(row.original.startDate, { locale })} + } + }, + { + accessorKey: 'endDate', + header: tc('tableHeader.endDate'), + cell: ({ row }) => { + return {formatDate(row.original.endDate, { locale })} + } + }, + createActionsColumnFromItems([ + { + label: tc('button.view'), + onClick: (classroom) => { + console.log('View details of', classroom) + } + }, + { + label: tc('button.update'), + onClick: (classroom) => { + console.log('Edit class', classroom) + } + }, + { + label: tc('button.delete'), + onClick: ({ original }) => { + deleteClassroom(original.id) + toast.success('Classroom deleted successfully') + } + } + ]) + ] +} diff --git a/src/features/resource/course/components/detail/organization/OrganizationCourseDetail.tsx b/src/features/resource/course/components/detail/organization/OrganizationCourseDetail.tsx index 86ec4659d..b58be56cd 100644 --- a/src/features/resource/course/components/detail/organization/OrganizationCourseDetail.tsx +++ b/src/features/resource/course/components/detail/organization/OrganizationCourseDetail.tsx @@ -1,5 +1,6 @@ 'use client' -import React from 'react' + +import React, { useState } from 'react' import LoadingComponent from '@/components/shared/loading/LoadingComponent' import SEmpty from '@/components/shared/empty/SEmpty' import { BookOpen } from 'lucide-react' @@ -13,6 +14,7 @@ import { useAppSelector } from '@/hooks/redux-hooks' import { useParams } from 'next/navigation' import { useSearchCourseEnrollmentQuery } from '@/features/enrollment/api/courseEnrollmentApi' import OrganizationCourseHeroSection from '@/features/resource/course/components/detail/organization/OrganizationCourseHeroSection' +import OrganizationCourseClassroom from '@/features/resource/course/components/detail/organization/OrganizationCourseClassroom' export default function OrganizationCourseDetail() { const auth = useAppSelector((state) => state.auth) @@ -34,13 +36,17 @@ export default function OrganizationCourseDetail() { error: enrollmentError } = useSearchCourseEnrollmentQuery({ courseId: Number(courseId), studentId }, { skip: !studentId }) + const [activeTab, setActiveTab] = useState<'lesson' | 'classroom'>('lesson') + if (isLoading || outcomeLoading || outcomeFetching || enrollmentLoading) return (
) + if (error) return
Error loading course details.
+ if (!course?.data) return (
@@ -55,17 +61,44 @@ export default function OrganizationCourseDetail() { return (
- +
+
- + +
+ +
+
+ + + +
+ +
+ {activeTab === 'lesson' && } + {activeTab === 'classroom' && } +
+
) } diff --git a/src/features/resource/course/components/detail/organization/OrganizationCourseHeroSection.tsx b/src/features/resource/course/components/detail/organization/OrganizationCourseHeroSection.tsx index dd3f02764..db9492d34 100644 --- a/src/features/resource/course/components/detail/organization/OrganizationCourseHeroSection.tsx +++ b/src/features/resource/course/components/detail/organization/OrganizationCourseHeroSection.tsx @@ -10,8 +10,6 @@ import { useTranslations } from 'next-intl' type OrganizationCourseHeroSectionProps = { course: Course - enrollmentStatus?: string - enrollmentId?: number } type TagGroupProps = { @@ -42,14 +40,14 @@ export default function OrganizationCourseHeroSection({ course }: OrganizationCo
-
+
{t('details.tags.ageRange')}: {course.ageRangeLabel}
-

{course.title}

+

{course.title}

-

{course.description}

+

{course.description}

{/* Category */} diff --git a/src/features/resource/course/components/list/OrganizationCourseList.tsx b/src/features/resource/course/components/list/OrganizationCourseList.tsx index 357e7d0e0..6588c75ed 100644 --- a/src/features/resource/course/components/list/OrganizationCourseList.tsx +++ b/src/features/resource/course/components/list/OrganizationCourseList.tsx @@ -48,9 +48,9 @@ export default function OrganizationCourseList({ curriculumId }: OrganizationCur } >
-

{course.code}

-

{course.title}

-

{course.description}

+

{course.code}

+

{course.title}

+

{course.description}

diff --git a/src/features/resource/learning-outcome/components/list/LearningOutcomeTable.tsx b/src/features/resource/learning-outcome/components/list/LearningOutcomeTable.tsx index e7633b353..4aa1832ff 100644 --- a/src/features/resource/learning-outcome/components/list/LearningOutcomeTable.tsx +++ b/src/features/resource/learning-outcome/components/list/LearningOutcomeTable.tsx @@ -23,7 +23,6 @@ export default function LearningOutcomeTable({ curriculumId }: LearningOutcomeTa const { openModal } = useModal() const { selectedOrganizationId } = useAppSelector((state) => state.selectedOrganization) - const dispatch = useAppDispatch() const queryParams: LearningOutcomeQueryParams = { curriculumId } From 8d8293031c74db4897e0b08172dca7e6dbef1197 Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Tue, 2 Dec 2025 17:50:52 +0700 Subject: [PATCH 30/63] feat: Add group management features including CRUD operations, UI updates, and new translations --- messages/en/common/en_toast.json | 3 +- messages/en/organization/en_organization.json | 3 + messages/vi/common/vi_toast.json | 3 +- messages/vi/organization/vi_organization.json | 3 + .../components/upsert/UpsertClassroom.tsx | 8 +- src/features/group/api/groupApi.ts | 60 ++++++ .../components/list/OrganizationGroupList.tsx | 195 +++++------------- .../components/modal/UpdateGroupModal.tsx | 16 ++ .../group/components/upsert/UpdateGroup.tsx | 5 + src/features/group/types/group.type.ts | 35 ++++ .../lesson/components/list/LessonColumn.tsx | 9 - .../detail/OrganizationSubscriptionDetail.tsx | 16 +- src/libs/redux/apiMiddleware.ts | 4 +- src/libs/redux/rootReducer.ts | 4 +- src/providers/ModalProvider.tsx | 2 + src/types/general.ts | 1 + 16 files changed, 192 insertions(+), 175 deletions(-) create mode 100644 src/features/group/api/groupApi.ts create mode 100644 src/features/group/components/modal/UpdateGroupModal.tsx create mode 100644 src/features/group/components/upsert/UpdateGroup.tsx create mode 100644 src/features/group/types/group.type.ts diff --git a/messages/en/common/en_toast.json b/messages/en/common/en_toast.json index 7cbdb80a9..bb031f4e3 100644 --- a/messages/en/common/en_toast.json +++ b/messages/en/common/en_toast.json @@ -22,7 +22,8 @@ "clearCart": "Cart cleared!", "addToCart": "Item added to cart!", "uploadCSV": "Upload CSV Successfully", - "reorder": "Reordered Successfully" + "reorder": "Reordered Successfully", + "copiedToClipboard": "Copied to clipboard!" }, "errorMessage": "An error occurred. Please try again. ", "errorSpecific": { diff --git a/messages/en/organization/en_organization.json b/messages/en/organization/en_organization.json index 517575f34..c82431d0e 100644 --- a/messages/en/organization/en_organization.json +++ b/messages/en/organization/en_organization.json @@ -172,6 +172,9 @@ "title": "Organization Student Groups", "subTitle": "Manage your organization groups", "noData": "No groups found for this organization.", + "groupCode": "Group Code:", + "groupName": "Group Name:", + "numberOfStudents": "Number of Students: {quantity}", "step1": { "title": "Step 1: Select Students", "description": "Choose students to include in the groups", diff --git a/messages/vi/common/vi_toast.json b/messages/vi/common/vi_toast.json index 371a56d64..132f398ea 100644 --- a/messages/vi/common/vi_toast.json +++ b/messages/vi/common/vi_toast.json @@ -22,7 +22,8 @@ "clearCart": "Đã xóa giỏ hàng!", "addToCart": "Đã thêm sản phẩm vào giỏ hàng!", "uploadCSV": "Đã tải CSV thành công", - "reorder": "Đã sắp xếp lại thành công" + "reorder": "Đã sắp xếp lại thành công", + "copiedToClipboard": "Đã sao chép vào clipboard!" }, "errorMessage": "Đã xảy ra lỗi. Vui lòng thử lại.", "errorSpecific": { diff --git a/messages/vi/organization/vi_organization.json b/messages/vi/organization/vi_organization.json index 29e15840b..22141d11f 100644 --- a/messages/vi/organization/vi_organization.json +++ b/messages/vi/organization/vi_organization.json @@ -171,6 +171,9 @@ "title": "Nhóm học sinh của tổ chức", "subTitle": "Quản lý các nhóm của tổ chức bạn", "noData": "Không tìm thấy nhóm nào cho tổ chức này.", + "groupCode": "Mã nhóm:", + "groupName": "Tên nhóm:", + "numberOfStudents": "Số lượng: {quantity} học sinh", "step1": { "title": "Bước 1: Chọn học sinh cho nhóm", "description": "Chọn học sinh để thêm vào nhóm", diff --git a/src/features/classroom/components/upsert/UpsertClassroom.tsx b/src/features/classroom/components/upsert/UpsertClassroom.tsx index c39ea0f61..9764d46ea 100644 --- a/src/features/classroom/components/upsert/UpsertClassroom.tsx +++ b/src/features/classroom/components/upsert/UpsertClassroom.tsx @@ -29,7 +29,7 @@ type ClassroomFormData = { description?: string classCode: string grade: string - curriculumId: number + courseId: number organizationSubscriptionOrderId: number durationWeeks: string // '4' | '6' | '8' | '10' | 'custom' startDate: string // ISO date string @@ -43,7 +43,7 @@ const defaultClassroomFormData: ClassroomFormData = { description: '', classCode: '', grade: '', - curriculumId: 1, + courseId: 1, organizationSubscriptionOrderId: 1, durationWeeks: '8', startDate: new Date().toISOString(), // default là hôm nay @@ -150,7 +150,7 @@ export default function UpsertClassroom({ classroomId, onSuccess }: UpsertClassr onSubmit: async ({ value }) => { const payload = { ...value, - curriculumId: Number(value.curriculumId), + courseId: Number(value.courseId), organizationSubscriptionOrderId: selectedSubscriptionId!, studentIds: selectedStudentIds } @@ -189,7 +189,7 @@ export default function UpsertClassroom({ classroomId, onSuccess }: UpsertClassr classCode: p.classCode, description: p.description, grade: p.grade, - curriculumId: p.curriculum.id, + courseId: p.course.id, organizationSubscriptionOrderId: p.organizationSubscriptionOrderId, durationWeeks: durationWeeks, startDate: p.startDate, diff --git a/src/features/group/api/groupApi.ts b/src/features/group/api/groupApi.ts new file mode 100644 index 000000000..69ac4ad2e --- /dev/null +++ b/src/features/group/api/groupApi.ts @@ -0,0 +1,60 @@ +import { Group, GroupQueryParams } from '@/features/group/types/group.type' +import { createCrudApi } from '@/libs/redux/baseApi' +import { ApiSuccessResponse, PaginatedResult } from '@/types/baseModel' + +export const groupApi = createCrudApi({ + reducerPath: 'groupApi', + tagTypes: ['Group'], + baseUrl: '/groups' +}).injectEndpoints({ + endpoints: (builder) => ({ + searchGroupByOrganizationId: builder.query< + ApiSuccessResponse>, + { organizationId: number; params: GroupQueryParams } + >({ + query: ({ organizationId, params }) => ({ + url: `/organizations/${organizationId}/groups`, + method: 'GET', + params + }), + providesTags: ['Group'] + }), + + addStudentToGroup: builder.mutation< + ApiSuccessResponse<{ isSuccess: boolean }>, + { groupId: number; studentIds: string[] } + >({ + query: ({ groupId, studentIds }) => ({ + url: `/groups/${groupId}/students`, + method: 'POST', + body: studentIds + }), + invalidatesTags: ['Group'] + }), + + removeStudentFromGroup: builder.mutation< + ApiSuccessResponse<{ isSuccess: boolean }>, + { groupId: number; studentIds: string[] } + >({ + query: ({ groupId, studentIds }) => ({ + url: `/groups/${groupId}/students`, + method: 'DELETE', + body: studentIds + }), + invalidatesTags: ['Group'] + }) + }) +}) + +export const { + useGetAllQuery: useGetAllGroupsQuery, + useSearchQuery: useSearchGroupsQuery, + useGetByIdQuery: useGetGroupByIdQuery, + + useUpdateMutation: useUpdateGroupMutation, + useDeleteMutation: useDeleteGroupMutation, + + useSearchGroupByOrganizationIdQuery, + useAddStudentToGroupMutation, + useRemoveStudentFromGroupMutation +} = groupApi diff --git a/src/features/group/components/list/OrganizationGroupList.tsx b/src/features/group/components/list/OrganizationGroupList.tsx index 148b1a946..95391122c 100644 --- a/src/features/group/components/list/OrganizationGroupList.tsx +++ b/src/features/group/components/list/OrganizationGroupList.tsx @@ -1,123 +1,42 @@ 'use client' import { useTranslations } from 'next-intl' import { Card, CardContent } from '@/components/shadcn/card' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/shadcn/avatar' import { Badge } from '@/components/shadcn/badge' -import { Users, MoreHorizontal } from 'lucide-react' +import { Users, MoreHorizontal, Copy, Trash2, Pencil } from 'lucide-react' import { Button } from '@/components/shadcn/button' import { useModal } from '@/providers/ModalProvider' - -// ===== Mock Data ===== -const mockGroups = [ - { - id: 1, - name: 'Advanced Mathematics', - code: 'MATH301', - students: [ - { - id: 1, - name: 'John Doe', - avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=32&h=32&fit=crop&crop=face' - }, - { - id: 2, - name: 'Jane Smith', - avatar: 'https://images.unsplash.com/photo-1494790108755-2616b332c647?w=32&h=32&fit=crop&crop=face' - }, - { - id: 3, - name: 'Mike Johnson', - avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=32&h=32&fit=crop&crop=face' - } - ], - totalStudents: 25 - }, - { - id: 2, - name: 'Computer Science Fundamentals', - code: 'CS101', - students: [ - { - id: 4, - name: 'Sarah Wilson', - avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=32&h=32&fit=crop&crop=face' - }, - { - id: 5, - name: 'Tom Brown', - avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=32&h=32&fit=crop&crop=face' - } - ], - totalStudents: 18 - }, - { - id: 3, - name: 'Physics Laboratory', - code: 'PHY201', - students: [ - { - id: 6, - name: 'Emma Davis', - avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=32&h=32&fit=crop&crop=face' - }, - { - id: 7, - name: 'Chris Lee', - avatar: 'https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=32&h=32&fit=crop&crop=face' - }, - { - id: 8, - name: 'Alex Kim', - avatar: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=32&h=32&fit=crop&crop=face' - } - ], - totalStudents: 32 - }, - { - id: 4, - name: 'Biology Research Group', - code: 'BIO301', - students: [ - { - id: 9, - name: 'Lisa Wang', - avatar: 'https://images.unsplash.com/photo-1544725176-7c40e5a71c5e?w=32&h=32&fit=crop&crop=face' - } - ], - totalStudents: 12 - }, - { - id: 5, - name: 'Engineering Design Team', - code: 'ENG205', - students: [ - { - id: 10, - name: 'David Chen', - avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=32&h=32&fit=crop&crop=face' - }, - { - id: 11, - name: 'Rachel Green', - avatar: 'https://images.unsplash.com/photo-1494790108755-2616b332c647?w=32&h=32&fit=crop&crop=face' - }, - { - id: 12, - name: 'Mark Taylor', - avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=32&h=32&fit=crop&crop=face' - } - ], - totalStudents: 45 - } -] +import { useDeleteGroupMutation, useSearchGroupByOrganizationIdQuery } from '@/features/group/api/groupApi' +import { useAppSelector } from '@/hooks/redux-hooks' +import { toast } from 'sonner' +import { Group } from '@/features/group/types/group.type' export default function OrganizationGroupList() { - const to = useTranslations('organization.group') - const tc = useTranslations('common') const { openModal } = useModal() + const tc = useTranslations('common') + const tt = useTranslations('toast') + const to = useTranslations('organization.group') + + const { selectedOrganizationId } = useAppSelector((state) => state.selectedOrganization) - const handleCreateGroup = () => { - openModal('upsertGroup') + const { data } = useSearchGroupByOrganizationIdQuery( + { organizationId: selectedOrganizationId!, params: {} }, + { skip: !selectedOrganizationId } + ) + const [deleteGroup] = useDeleteGroupMutation() + + const handleCopyGroupCode = (code: string) => { + navigator.clipboard.writeText(code) + toast.success(tt('successMessage.copiedToClipboard')) + } + + const handleDeleteGroup = async (group: Group) => { + openModal('confirm', { + message: tt('confirmMessage.delete', { title: group.name }), + onConfirm: async () => { + await deleteGroup(group.id).unwrap() + toast.success(tt('successMessage.delete')) + } + }) } return ( @@ -127,11 +46,10 @@ export default function OrganizationGroupList() {

{to('title')}

{to('subTitle')}

-
- {mockGroups.map((group) => ( + {data?.data.items.map((group) => (
@@ -141,44 +59,35 @@ export default function OrganizationGroupList() {
-
+
-

{group.name}

- +

+ {to('groupName')} {group.name} +

+
+ + {group.studentCount} +
+
+
- - {/* AVATAR LIST */} -
- {group.students.slice(0, 3).map((s, i) => ( - - - - {s.name - .split(' ') - .map((n) => n[0]) - .join('')} - - - ))} - {group.totalStudents > 3 && ( -
- +{group.totalStudents - 3} -
- )} -
+
{/* MENU BTN */} - +
+ + + +
diff --git a/src/features/group/components/modal/UpdateGroupModal.tsx b/src/features/group/components/modal/UpdateGroupModal.tsx new file mode 100644 index 000000000..4c8503949 --- /dev/null +++ b/src/features/group/components/modal/UpdateGroupModal.tsx @@ -0,0 +1,16 @@ +import { Dialog, DialogContent, DialogTitle } from '@/components/shadcn/dialog' +import UpdateGroup from '@/features/group/components/upsert/UpdateGroup' +import { useModal } from '@/providers/ModalProvider' +import React from 'react' + +export default function UpdateGroupModal() { + const { closeModal } = useModal() + return ( + + + Update Group + + + + ) +} diff --git a/src/features/group/components/upsert/UpdateGroup.tsx b/src/features/group/components/upsert/UpdateGroup.tsx new file mode 100644 index 000000000..f4a24d3e4 --- /dev/null +++ b/src/features/group/components/upsert/UpdateGroup.tsx @@ -0,0 +1,5 @@ +import React from 'react' + +export default function UpdateGroup() { + return
UpdateGroup
+} diff --git a/src/features/group/types/group.type.ts b/src/features/group/types/group.type.ts new file mode 100644 index 000000000..9a14c03b3 --- /dev/null +++ b/src/features/group/types/group.type.ts @@ -0,0 +1,35 @@ +import { SearchPaginatedRequestParams } from '@/types/baseModel' + +export enum GroupStatus { + ACTIVE = 'Active', + ARCHIEVE = 'Archieve' +} + +export type Group = { + id: number + organizationId: number + name: string + code: string + status: GroupStatus + studentCount: number + createdByUserId: string + createdAt: string + updatedAt: string + students: GroupDetailStudent[] +} + +export type GroupDetailStudent = { + organizationUserId: string + userId: string + email: string + userName: string + fullName: string + subscriptionOrderId: number + joinedAt: string + isActive: boolean +} + +export type GroupQueryParams = { + includeArchived?: boolean + activeOnly?: boolean +} & SearchPaginatedRequestParams diff --git a/src/features/resource/lesson/components/list/LessonColumn.tsx b/src/features/resource/lesson/components/list/LessonColumn.tsx index 4b765a83e..91807b590 100644 --- a/src/features/resource/lesson/components/list/LessonColumn.tsx +++ b/src/features/resource/lesson/components/list/LessonColumn.tsx @@ -142,15 +142,6 @@ export function useGetLessonColumn(): ColumnDef[] { ) } }, - { - accessorKey: 'createdByUserName', - header: () =>
{tc('tableHeader.createdBy')}
, - cell: ({ row }) => { - const value = row.getValue('createdByUserName') - const display = value?.trim() ? value : 'STEMify Staff' - return
{display}
- } - }, { accessorKey: 'createdDate', header: () =>
{tc('tableHeader.createdDate')}
, diff --git a/src/features/subscription/components/detail/OrganizationSubscriptionDetail.tsx b/src/features/subscription/components/detail/OrganizationSubscriptionDetail.tsx index 88dd2251f..1e4fc8b29 100644 --- a/src/features/subscription/components/detail/OrganizationSubscriptionDetail.tsx +++ b/src/features/subscription/components/detail/OrganizationSubscriptionDetail.tsx @@ -3,28 +3,14 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/shadcn/card' import { Badge } from '@/components/shadcn/badge' import { Button } from '@/components/shadcn/button' -import { Input } from '@/components/shadcn/input' import { Progress } from '@/components/shadcn/progress' -import { - CheckCircle, - Users, - GraduationCap, - BookOpen, - TrendingUp, - TrendingDown, - Search, - UserPlus, - Calendar, - CreditCard -} from 'lucide-react' +import { Users, GraduationCap, BookOpen, Calendar, CreditCard } from 'lucide-react' import { useModal } from '@/providers/ModalProvider' import { useGetSubscriptionByIdQuery } from '@/features/subscription/api/subscriptionApi' import { useParams } from 'next/navigation' import LoadingComponent from '@/components/shared/loading/LoadingComponent' import { formatDate } from '@/utils/index' import SEmpty from '@/components/shared/empty/SEmpty' -import { useEffect } from 'react' -import { SCard } from '@/components/shared/card/SCard' import CardLayout from '@/components/shared/card/CardLayout' import LicenseAssignmentList from '@/features/license-assignment/components/list/licenseAssignmentList' import { getStatusBadgeClass } from '@/utils/badgeColor' diff --git a/src/libs/redux/apiMiddleware.ts b/src/libs/redux/apiMiddleware.ts index 445ef67ec..aa13bac7b 100644 --- a/src/libs/redux/apiMiddleware.ts +++ b/src/libs/redux/apiMiddleware.ts @@ -34,6 +34,7 @@ import { classroomApi } from '@/features/classroom/api/classroomApi' import { orgDashboardApi } from '@/features/dashboard/api/OrgDashboardApi' import { studentAssignmentApi } from '@/features/assignment/api/studentAssignmentApi' import { assignmentApi } from '@/features/assignment/api/assignmentApi' +import { groupApi } from '@/features/group/api/groupApi' export const apiMiddlewares: Middleware[] = [ courseApi.middleware, @@ -70,7 +71,8 @@ export const apiMiddlewares: Middleware[] = [ classroomApi.middleware, studentAssignmentApi.middleware, assignmentApi.middleware, - orgDashboardApi.middleware + orgDashboardApi.middleware, + groupApi.middleware // Add your custom middlewares here // Example: loggerMiddleware, errorHandlingMiddleware, etc. ] diff --git a/src/libs/redux/rootReducer.ts b/src/libs/redux/rootReducer.ts index 390935dc9..7375293b5 100644 --- a/src/libs/redux/rootReducer.ts +++ b/src/libs/redux/rootReducer.ts @@ -72,6 +72,7 @@ import { assignmentApi } from '@/features/assignment/api/assignmentApi' import selectedOrganizationSlice from '@/features/subscription/slice/selectedOrganizationSlice' import { studentAssignmentSelectedSlice } from '@/features/assignment/slice/studentAssignmentSlice' import { enrollmentSlice } from '@/features/enrollment/slice/enrollmentSlice' +import { groupApi } from '@/features/group/api/groupApi' export const rootReducer = combineReducers({ // Add your reducers here @@ -150,5 +151,6 @@ export const rootReducer = combineReducers({ [classroomApi.reducerPath]: classroomApi.reducer, [orgDashboardApi.reducerPath]: orgDashboardApi.reducer, [studentAssignmentApi.reducerPath]: studentAssignmentApi.reducer, - [assignmentApi.reducerPath]: assignmentApi.reducer + [assignmentApi.reducerPath]: assignmentApi.reducer, + [groupApi.reducerPath]: groupApi.reducer }) diff --git a/src/providers/ModalProvider.tsx b/src/providers/ModalProvider.tsx index 3bf381c0c..a2cd24a60 100644 --- a/src/providers/ModalProvider.tsx +++ b/src/providers/ModalProvider.tsx @@ -44,6 +44,7 @@ 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' const ModalContext = createContext({ openModal: () => {}, closeModal: () => {}, @@ -99,6 +100,7 @@ export const ModalProvider = ({ children }: { children: React.ReactNode }) => { {modalType === 'upsertEmulator' && } {modalType === 'createAssignmentInfo' && } {modalType === 'upsertGroup' && } + {modalType === 'updateGroup' && } {/* detail */} {modalType === 'lessonDetail' && } diff --git a/src/types/general.ts b/src/types/general.ts index 92ebb2b68..0b25be01f 100644 --- a/src/types/general.ts +++ b/src/types/general.ts @@ -41,6 +41,7 @@ export type ModalType = | 'upsertEmulator' | 'createAssignmentInfo' | 'upsertGroup' + | 'updateGroup' // detail | 'lessonDetail' From fee54e9ea4f60c7901b8a47be6508115b264ae3d Mon Sep 17 00:00:00 2001 From: meewaldor Date: Tue, 2 Dec 2025 17:54:20 +0700 Subject: [PATCH 31/63] feat: Refactor course and lesson status management by removing unused statuses and improving status handling logic --- .../components/list/SystemOrganizationList.tsx | 10 +++++++++- .../course/components/list/CourseColum.tsx | 9 +++------ .../resource/course/types/course.type.ts | 11 ++++------- .../lesson/components/list/LessonColumn.tsx | 17 +---------------- .../resource/lesson/types/lesson.type.ts | 5 +---- 5 files changed, 18 insertions(+), 34 deletions(-) diff --git a/src/features/organization/components/list/SystemOrganizationList.tsx b/src/features/organization/components/list/SystemOrganizationList.tsx index 3de3bd260..f7912b895 100644 --- a/src/features/organization/components/list/SystemOrganizationList.tsx +++ b/src/features/organization/components/list/SystemOrganizationList.tsx @@ -3,6 +3,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 SSelect from '@/components/shared/SSelect' import { useSearchOrganizationsQuery } from '@/features/organization/api/organizationApi' import { useGetOrganizationColumn } from '@/features/organization/components/list/SystemOrganizationColumn' @@ -27,7 +28,7 @@ export default function SystemOrganizationList() { const queryParams = useAppSelector((state) => state.organization) const columns = useGetOrganizationColumn() - const { data } = useSearchOrganizationsQuery(queryParams) + const { data, isLoading } = useSearchOrganizationsQuery(queryParams) const organizationStatusOptions = [ { label: tc('status.all'), value: 'all' }, @@ -42,6 +43,13 @@ export default function SystemOrganizationList() { const handlePageChange = (page: number) => { dispatch(setPageIndex(page)) } + if (isLoading) { + return ( +
+ +
+ ) + } return (
diff --git a/src/features/resource/course/components/list/CourseColum.tsx b/src/features/resource/course/components/list/CourseColum.tsx index 01d33bd96..4e8d00018 100644 --- a/src/features/resource/course/components/list/CourseColum.tsx +++ b/src/features/resource/course/components/list/CourseColum.tsx @@ -80,11 +80,8 @@ export function useGetCourseColumn({ isPopup }: { isPopup?: boolean }): ColumnDe const statusFlow: Record = { [CourseStatus.DRAFT]: [CourseStatus.DRAFT, CourseStatus.PUBLISHED], [CourseStatus.PUBLISHED]: [CourseStatus.PUBLISHED], - [CourseStatus.PENDING]: [], - [CourseStatus.REJECTED]: [], [CourseStatus.DELETED]: [], - [CourseStatus.ARCHIVED]: [], - [CourseStatus.APPROVED]: [] + [CourseStatus.ARCHIVED]: [] } const handleStatusChange = (courseId: number, newStatus: string) => { @@ -193,7 +190,7 @@ export function useGetCourseColumn({ isPopup }: { isPopup?: boolean }): ColumnDe { label: tc('button.delete'), danger: true, - hidden: () => curriculumId !== undefined, + hidden: ({ original }) => curriculumId !== undefined || original.status !== CourseStatus.DRAFT, onClick: async ({ original }) => { // Open the confirmation modal for deletion openModal('confirm', { @@ -204,7 +201,7 @@ export function useGetCourseColumn({ isPopup }: { isPopup?: boolean }): ColumnDe }, { label: tc('button.archive'), - hidden: () => curriculumId !== undefined, + hidden: ({ original }) => curriculumId !== undefined || original.status !== CourseStatus.PUBLISHED, onClick: async ({ original }) => { // Open the confirmation modal for deletion openModal('confirm', { diff --git a/src/features/resource/course/types/course.type.ts b/src/features/resource/course/types/course.type.ts index aa50a9497..f45cdaacb 100644 --- a/src/features/resource/course/types/course.type.ts +++ b/src/features/resource/course/types/course.type.ts @@ -37,13 +37,10 @@ export type Course = { } export enum CourseStatus { - DRAFT = 'DRAFT', - PUBLISHED = 'PUBLISHED', - ARCHIVED = 'ARCHIVED', - DELETED = 'DELETED', - PENDING = 'PENDING', - REJECTED = 'REJECTED', - APPROVED = 'APPROVED' + DRAFT = 'Draft', + PUBLISHED = 'Published', + ARCHIVED = 'Archived', + DELETED = 'Deleted' } export enum CourseLevel { diff --git a/src/features/resource/lesson/components/list/LessonColumn.tsx b/src/features/resource/lesson/components/list/LessonColumn.tsx index 4b765a83e..617cea7f8 100644 --- a/src/features/resource/lesson/components/list/LessonColumn.tsx +++ b/src/features/resource/lesson/components/list/LessonColumn.tsx @@ -59,11 +59,8 @@ export function useGetLessonColumn(): ColumnDef[] { const statusFlow: Record = { [LessonStatus.DRAFT]: [LessonStatus.DRAFT, LessonStatus.PUBLISHED], [LessonStatus.PUBLISHED]: [LessonStatus.PUBLISHED], - [LessonStatus.PENDING]: [], - [LessonStatus.REJECTED]: [], [LessonStatus.DELETED]: [], - [LessonStatus.ARCHIVED]: [], - [LessonStatus.APPROVED]: [] + [LessonStatus.ARCHIVED]: [] } const handleStatusChange = (lessonId: number, newStatus: string) => { @@ -177,18 +174,6 @@ export function useGetLessonColumn(): ColumnDef[] { onConfirm: () => handleDelete(original.id) }) } - }, - { - separatorBefore: true, - label: tc('button.approve'), - hidden: ({ original }) => original.status !== LessonStatus.PENDING && original.status !== LessonStatus.DRAFT, - onClick: ({ original }) => handleStatusUpdate(original.id, original.title, LessonStatus.PUBLISHED) - }, - { - label: tc('button.reject'), - danger: true, - hidden: ({ original }) => original.status !== LessonStatus.PENDING && original.status !== LessonStatus.DRAFT, - onClick: ({ original }) => handleStatusUpdate(original.id, original.title, LessonStatus.REJECTED) } ]) ] diff --git a/src/features/resource/lesson/types/lesson.type.ts b/src/features/resource/lesson/types/lesson.type.ts index 0acb262e3..821934de3 100644 --- a/src/features/resource/lesson/types/lesson.type.ts +++ b/src/features/resource/lesson/types/lesson.type.ts @@ -28,10 +28,7 @@ export enum LessonStatus { DRAFT = 'Draft', PUBLISHED = 'Published', ARCHIVED = 'Archived', - DELETED = 'Deleted', - PENDING = 'Pending', - REJECTED = 'Rejected', - APPROVED = 'Approved' + DELETED = 'Deleted' } // query params From 255afe6ac89e7029bfd3047ecae31acc41528c9c Mon Sep 17 00:00:00 2001 From: ttd11204 Date: Tue, 2 Dec 2025 17:57:45 +0700 Subject: [PATCH 32/63] feat: Update classroom and curriculum components with improved routing and layout adjustments --- .../classroom/components/list/table/ClassroomTable.tsx | 6 ------ .../detail/organization/OrganizationCourseClassroom.tsx | 7 +++++-- .../components/list/OrganizationCurriculumList.tsx | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/features/classroom/components/list/table/ClassroomTable.tsx b/src/features/classroom/components/list/table/ClassroomTable.tsx index 9d47655de..a9f0f9dbf 100644 --- a/src/features/classroom/components/list/table/ClassroomTable.tsx +++ b/src/features/classroom/components/list/table/ClassroomTable.tsx @@ -104,12 +104,6 @@ export default function ClassroomTable() { -
diff --git a/src/features/resource/course/components/detail/organization/OrganizationCourseClassroom.tsx b/src/features/resource/course/components/detail/organization/OrganizationCourseClassroom.tsx index 303b9ff3f..a22795f20 100644 --- a/src/features/resource/course/components/detail/organization/OrganizationCourseClassroom.tsx +++ b/src/features/resource/course/components/detail/organization/OrganizationCourseClassroom.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/shadcn/button' import { DataTable } from '@/components/shared/data-table/data-table' import { useGetClassroomColumn } from '@/features/classroom/components/list/table/ClassroomColumn' import { useSearchClassroomsQuery } from '@/features/classroom/api/classroomApi' -import { useRouter } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { useLocale, useTranslations } from 'next-intl' import { Input } from '@/components/shadcn/input' import SSelect from '@/components/shared/SSelect' @@ -20,6 +20,8 @@ export default function OrganizationCourseClassroom() { const router = useRouter() const locale = useLocale() + const { courseId } = useParams() + const tc = useTranslations('common') const tClassroom = useTranslations('classroom') @@ -59,7 +61,7 @@ export default function OrganizationCourseClassroom() { @@ -98,6 +100,7 @@ export default function OrganizationCourseClassroom() { pagingParams={queryParams} handlePageChange={() => {}} onRowClick={(val) => { + console.log(val) router.push(`/${locale}/organization/classroom/${val.id}`) }} /> diff --git a/src/features/resource/curriculum/components/list/OrganizationCurriculumList.tsx b/src/features/resource/curriculum/components/list/OrganizationCurriculumList.tsx index 30fef1dfe..b9ddbbccb 100644 --- a/src/features/resource/curriculum/components/list/OrganizationCurriculumList.tsx +++ b/src/features/resource/curriculum/components/list/OrganizationCurriculumList.tsx @@ -116,7 +116,7 @@ export default function OrganizationCurriculumList() {
) : ( /* Curriculum Grid */ -
+
{filteredCurriculums.map((curriculum) => ( Date: Tue, 2 Dec 2025 21:17:41 +0700 Subject: [PATCH 33/63] feat: Implement Create Classroom functionality with group management and API integration --- messages/en/classroom/en_classroom.json | 40 +-- messages/vi/classroom/vi_classroom.json | 41 +-- .../organization/classroom/create/page.tsx | 4 +- src/features/classroom/api/classroomApi.ts | 17 +- .../upsert/ClassroomStepIndicator.tsx | 3 +- .../components/upsert/CreateClassroom.tsx | 297 ++++++++++++++++++ .../components/upsert/UpsertClassroom.tsx | 286 ----------------- .../upsert/UpsertClassroomModal.tsx | 4 +- .../classroom/types/classroom.type.ts | 18 ++ .../group/components/list/GroupTable.tsx | 150 +++++++++ .../slice/organizationSpecialSlice.ts | 21 ++ .../OrganizationCourseClassroom.tsx | 5 +- src/libs/redux/rootReducer.ts | 2 + src/libs/redux/store.ts | 2 +- 14 files changed, 532 insertions(+), 358 deletions(-) create mode 100644 src/features/classroom/components/upsert/CreateClassroom.tsx delete mode 100644 src/features/classroom/components/upsert/UpsertClassroom.tsx create mode 100644 src/features/group/components/list/GroupTable.tsx create mode 100644 src/features/organization/slice/organizationSpecialSlice.ts diff --git a/messages/en/classroom/en_classroom.json b/messages/en/classroom/en_classroom.json index e07431107..122f9fef2 100644 --- a/messages/en/classroom/en_classroom.json +++ b/messages/en/classroom/en_classroom.json @@ -71,36 +71,16 @@ "create": { "header": "Create New Classroom", "subheader": "Follow the steps below to set up your classroom", - "step1": { - "title": "Classroom Information", - "subtitle": "Basic classroom details", - "basicInfo": "Basic Information", - "className": "Class Name", - "classCode": "Class Code", - "description": "Description", - "descriptionPlaceholder": "Brief description of this classroom...", - "gradeLevel": "Grade Level", - "selectGrade": "Select Grade", - "duration": "Duration", - "selectDuration": "Select Duration", - "startDate": "Start Date", - "endDate": "End Date", - "weeks": "Weeks", - "custom": "Custom" - }, - "step2": { - "title": "Assignments", - "subtitle": "Assign curriculum, teacher & students", - "curriculumAndTeacher": "Curriculum & Teacher", - "curriculum": "Curriculum", - "chooseCurriculum": "Choose Curriculum", - "teacher": "Teacher", - "chooseTeacher": "Choose Teacher", - "students": "Students", - "selected": "Selected", - "selectStudents": "Select Students for the Classroom", - "searchStudent": "Search students by name or email" - } + "basicInfo": "Basic Information", + "description": "Description", + "descriptionPlaceholder": "Brief description of this classroom...", + "duration": "Duration", + "selectDuration": "Select Duration", + "startDate": "Start Date", + "endDate": "End Date", + "weeks": "Weeks", + "custom": "Custom", + "groupList": "Group List" } } } diff --git a/messages/vi/classroom/vi_classroom.json b/messages/vi/classroom/vi_classroom.json index b5ca3ecb8..06b677d1d 100644 --- a/messages/vi/classroom/vi_classroom.json +++ b/messages/vi/classroom/vi_classroom.json @@ -70,37 +70,16 @@ "create": { "header": "Tạo lớp học mới", "subheader": "Làm theo các bước dưới đây để thiết lập lớp học của bạn", - "step1": { - "title": "Thông tin lớp học", - "subtitle": "Chi tiết cơ bản về lớp học", - "basicInfo": "Thông tin cơ bản", - "className": "Tên lớp học", - "classCode": "Mã lớp học", - "description": "Mô tả", - "descriptionPlaceholder": "Mô tả ngắn gọn về lớp học này...", - "gradeLevel": "Cấp lớp", - "grade": "Lớp", - "selectGrade": "Chọn cấp lớp", - "duration": "Thời lượng", - "selectDuration": "Chọn thời lượng", - "startDate": "Ngày bắt đầu", - "endDate": "Ngày kết thúc", - "weeks": "Tuần", - "custom": "Tùy chỉnh" - }, - "step2": { - "title": "Bài tập", - "subtitle": "Chỉ định chương trình học, giáo viên & học sinh", - "curriculumAndTeacher": "Chương trình học & Giáo viên", - "curriculum": "Chương trình học", - "chooseCurriculum": "Chọn chương trình học", - "teacher": "Giáo viên", - "chooseTeacher": "Chọn giáo viên", - "students": "Học sinh", - "selected": "Đã chọn", - "selectStudents": "Chọn học sinh cho lớp học", - "searchStudent": "Tìm kiếm học sinh theo tên hoặc email" - } + "basicInfo": "Thông tin cơ bản", + "duration": "Thời lượng", + "selectDuration": "Chọn thời lượng", + "startDate": "Ngày bắt đầu", + "endDate": "Ngày kết thúc", + "weeks": "Tuần", + "custom": "Tùy chỉnh", + "description": "Mô tả", + "descriptionPlaceholder": "Mô tả ngắn gọn về lớp học này...", + "groupList": "Danh sách nhóm" } } } diff --git a/src/app/[locale]/organization/classroom/create/page.tsx b/src/app/[locale]/organization/classroom/create/page.tsx index 40d8e8408..f4093ee84 100644 --- a/src/app/[locale]/organization/classroom/create/page.tsx +++ b/src/app/[locale]/organization/classroom/create/page.tsx @@ -1,10 +1,10 @@ -import UpsertClassroom from '@/features/classroom/components/upsert/UpsertClassroom' +import CreateClassroom from '@/features/classroom/components/upsert/CreateClassroom' import React from 'react' export default function createClassroomPage() { return (
- +
) } diff --git a/src/features/classroom/api/classroomApi.ts b/src/features/classroom/api/classroomApi.ts index 62a868d95..287169baa 100644 --- a/src/features/classroom/api/classroomApi.ts +++ b/src/features/classroom/api/classroomApi.ts @@ -3,6 +3,7 @@ import { ClassroomSchedule, ClassroomSliceParams, ClassroomStatisticData, + CreateClassroom, StudentProgressData, StudentProgressParams } from '@/features/classroom/types/classroom.type' @@ -65,10 +66,18 @@ export const classroomApi = createCrudApi({ url: `/classrooms/${classroomId}/schedule` }) }), - getClassroomStatistics: builder.query, {classroomId: number}>({ - query: ({classroomId}) => ({ + getClassroomStatistics: builder.query, { classroomId: number }>({ + query: ({ classroomId }) => ({ url: `/classrooms/${classroomId}/statistic` }) + }), + createClassroom: builder.mutation, Partial>({ + query: (body) => ({ + url: `/classrooms`, + method: 'POST', + body + }), + invalidatesTags: ['Classroom'] }) }) }) @@ -79,7 +88,9 @@ export const { useGetByIdQuery: useGetClassroomByIdQuery, useUpdateMutation: useUpdateClassroomMutation, useDeleteMutation: useDeleteClassroomMutation, - useCreateMutation: useCreateClassroomMutation, + // useCreateMutation: useCreateClassroomMutation, + + useCreateClassroomMutation, useGetClassroomScheduleQuery, diff --git a/src/features/classroom/components/upsert/ClassroomStepIndicator.tsx b/src/features/classroom/components/upsert/ClassroomStepIndicator.tsx index fc6bb77b3..af007783f 100644 --- a/src/features/classroom/components/upsert/ClassroomStepIndicator.tsx +++ b/src/features/classroom/components/upsert/ClassroomStepIndicator.tsx @@ -6,10 +6,9 @@ import { useTranslations } from 'next-intl' type ClassroomStepIndicatorProps = { currentStep: number - isEditing: boolean } -export default function ClassroomStepIndicator({ currentStep, isEditing }: ClassroomStepIndicatorProps) { +export default function ClassroomStepIndicator({ currentStep }: ClassroomStepIndicatorProps) { const tClassroom = useTranslations('classroom') const STEPS = [ diff --git a/src/features/classroom/components/upsert/CreateClassroom.tsx b/src/features/classroom/components/upsert/CreateClassroom.tsx new file mode 100644 index 000000000..ac7783157 --- /dev/null +++ b/src/features/classroom/components/upsert/CreateClassroom.tsx @@ -0,0 +1,297 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { toast } from 'sonner' +import { useAppForm } from '@/components/shared/form/items' +import { useModal } from '@/providers/ModalProvider' +import { useCreateClassroomMutation } from '@/features/classroom/api/classroomApi' +import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks' +import { useRouter } from 'next/navigation' +import { useLocale, useTranslations } from 'next-intl' +import { setPageIndex } from '@/features/user/slice/userSlice' +import { Label } from '@/components/shadcn/label' +import { Textarea } from '@/components/shadcn/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/shadcn/select' +import { Calendar } from '@/components/shadcn/calendar' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/shadcn/popover' +import { Button } from '@/components/shadcn/button' +import { CalendarIcon } from 'lucide-react' +import { format } from 'date-fns' +import { cn } from '@/utils/shadcn/utils' +import BackButton from '@/components/shared/button/BackButton' +import GroupTable from '@/features/group/components/list/GroupTable' + +type ClassroomFormData = { + grade: string + description?: string + courseId: number + organizationSubscriptionOrderId: number + durationWeeks: string + startDate: string + endDate: string + studentGroups: { + groupCode: string + groupName: string + teacherId: string + studentIds: string[] + }[] +} + +const defaultClassroomFormData: ClassroomFormData = { + grade: '', + description: '', + courseId: 1, + organizationSubscriptionOrderId: 1, + durationWeeks: '8', + startDate: new Date().toISOString(), + endDate: new Date(new Date().setDate(new Date().getDate() + 56)).toISOString(), + studentGroups: [] +} + +export default function CreateClassroom() { + const tc = useTranslations('common') + const tt = useTranslations('toast') + const tClassroom = useTranslations('classroom.create') + + const { closeModal } = useModal() + const dispatch = useAppDispatch() + const router = useRouter() + const locale = useLocale() + + const [selectedGroups, setSelectedGroups] = useState< + { + groupCode: string + groupName: string + teacherId: string | null + studentIds: string[] + }[] + >([]) + + const [selectedStudentIds, setSelectedStudentIds] = useState([]) + const [minDate, setMinDate] = useState(undefined) + const [maxDate, setMaxDate] = useState(undefined) + + // Form states + const [description, setDescription] = useState('') + const [durationWeeks, setDurationWeeks] = useState('8') + const [startDate, setStartDateState] = useState(new Date()) + const [endDate, setEndDate] = useState(new Date(new Date().setDate(new Date().getDate() + 56))) + + const selectedSubscriptionId = useAppSelector((state) => state.selectedOrganization.selectedSubscriptionOrderId) + + const DURATION_OPTIONS = [ + { label: `4 ${tClassroom('weeks')}`, value: '4' }, + { label: `6 ${tClassroom('weeks')}`, value: '6' }, + { label: `8 ${tClassroom('weeks')}`, value: '8' }, + { label: `10 ${tClassroom('weeks')}`, value: '10' }, + { label: `${tClassroom('custom')}`, value: 'custom' } + ] + + const isCustomDuration = durationWeeks === 'custom' + + const [createClassroom, { isLoading: isCreating }] = useCreateClassroomMutation() + + const form = useAppForm({ + defaultValues: defaultClassroomFormData, + onSubmit: async ({ value }) => { + const payload = { + ...value, + grade: 'Grade 1-3', + courseId: Number(value.courseId), + organizationSubscriptionOrderId: selectedSubscriptionId!, + studentGroups: selectedGroups.map((group) => ({ + ...group, + teacherId: group.teacherId || '' + })) + } + + const result = await createClassroom(payload).unwrap() + toast.success(tt('successMessage.create', { title: result.data.name })) + + router.push(`/${locale}/organization/classroom`) + closeModal() + } + }) + + const handlePageChange = (newPage: number) => { + dispatch(setPageIndex(newPage)) + } + + // Auto-calculate end date + useEffect(() => { + if (durationWeeks !== 'custom' && startDate) { + const weeks = parseInt(durationWeeks) + if (!isNaN(weeks)) { + const end = new Date(startDate) + end.setDate(end.getDate() + weeks * 7) + setEndDate(end) + form.setFieldValue('endDate', end.toISOString()) + } + } + }, [durationWeeks, startDate]) + + // Sync search + + useEffect(() => { + form.setFieldValue('description', description) + }, [description]) + useEffect(() => { + form.setFieldValue('durationWeeks', durationWeeks) + }, [durationWeeks]) + useEffect(() => { + if (startDate) form.setFieldValue('startDate', startDate.toISOString()) + }, [startDate]) + useEffect(() => { + if (endDate) form.setFieldValue('endDate', endDate.toISOString()) + }, [endDate]) + + return ( +
+ {/* Header */} +
+
+
+ +
+

{tClassroom('header')}

+

{tClassroom('subheader')}

+
+
+
+
+ + {/* Form Content */} + +
{ + e.preventDefault() + form.handleSubmit() + }} + className='flex-1 overflow-y-auto px-6 py-6' + > +
+ setSelectedGroups(groups)} /> + + {/* Basic Information Section */} +
+

{tClassroom('basicInfo')}

+
+ {/* Description */} +
+ +