From ea64f8403e5b060c67c7c46cacbb1b3bda620da3 Mon Sep 17 00:00:00 2001 From: Yifei Fang Date: Mon, 19 May 2025 20:24:22 -0500 Subject: [PATCH 1/4] on demand and video --- backend/models/courseModel.ts | 21 +- .../AdminCoursePreview/AdminCoursePreview.tsx | 4 + .../editCoursePageSideBar.tsx | 74 ++----- frontend/src/components/SaveCourseButtons.tsx | 1 + .../pages/Admin/WorkshopCreation/InPerson.tsx | 99 +++++++-- .../pages/Admin/WorkshopCreation/OnDemand.tsx | 73 ++++--- .../WorkshopCreation/WorkshopCreation.tsx | 204 +++++++++++++++--- frontend/src/pages/Dashboard/dashboard.tsx | 5 +- .../courseDetailPage/courseDetailsPage.tsx | 171 +++++++-------- frontend/src/routes/RoutesAndLayouts.tsx | 23 +- frontend/src/store/useCourseEditStore.ts | 3 +- 11 files changed, 440 insertions(+), 238 deletions(-) diff --git a/backend/models/courseModel.ts b/backend/models/courseModel.ts index c9708b5..8aafc95 100644 --- a/backend/models/courseModel.ts +++ b/backend/models/courseModel.ts @@ -25,8 +25,12 @@ export interface ICourse extends Document { regStart: Date; regEnd: Date; //Virtual Training - Live Meeting, In-Person Training, Virtual Training - On Demand, Virtual Training - Live Webinar - productType: string; - productInfo: string + productType: + | "Virtual Training - Live Meeting" + | "In-Person Training" + | "Virtual Training - On Demand" + | "Virtual Training - Live Webinar"; + productInfo: string; shortUrl: string; draft: boolean; } @@ -78,8 +82,17 @@ const CourseSchema: Schema = new Schema( ], regStart: { type: Date, required: false }, regEnd: { type: Date, required: false }, - productType: { type: String, required: false }, - productInfo: {type:String, required: false}, + productType: { + type: String, + required: false, + enum: [ + "Virtual Training - Live Meeting", + "In-Person Training", + "Virtual Training - On Demand", + "Virtual Training - Live Webinar", + ], + }, + productInfo: { type: String, required: false }, shortUrl: { type: String, required: false }, draft: { type: Boolean, required: true, default: true }, }, diff --git a/frontend/src/components/AdminCoursePreview/AdminCoursePreview.tsx b/frontend/src/components/AdminCoursePreview/AdminCoursePreview.tsx index dfdd353..6b530c8 100644 --- a/frontend/src/components/AdminCoursePreview/AdminCoursePreview.tsx +++ b/frontend/src/components/AdminCoursePreview/AdminCoursePreview.tsx @@ -8,6 +8,7 @@ import { ShieldCheck, Star, Trash2, + LayoutDashboard, } from "lucide-react"; import AdminCourseDeleteModal from "./AdminCourseDeleteModal"; import { Link } from "react-router-dom"; @@ -180,6 +181,9 @@ function AdminCoursePreview({ + + + + {/* */} ); } diff --git a/frontend/src/pages/Admin/WorkshopCreation/WorkshopCreation.tsx b/frontend/src/pages/Admin/WorkshopCreation/WorkshopCreation.tsx index f2802c2..e52e784 100644 --- a/frontend/src/pages/Admin/WorkshopCreation/WorkshopCreation.tsx +++ b/frontend/src/pages/Admin/WorkshopCreation/WorkshopCreation.tsx @@ -9,11 +9,12 @@ import NewMeeting from "./ModalComponents/NewMeeting"; import ExistingWebinarList from "./ModalComponents/ExistingWebinarList"; import NewWebinar from "./ModalComponents/NewWebinar"; import SaveCourseButton from "../../../components/SaveCourseButtons"; -import apiClient from "../../../services/apiClient"; -import { getCleanCourseData } from "../../../store/useCourseEditStore"; +import { + getCleanCourseData, + useCourseEditStore, +} from "../../../store/useCourseEditStore"; export default function WorkshopCreation() { - const [webinarData, setWebinarData] = useState({ serviceType: "webinar", meetingURL: "string", @@ -27,7 +28,7 @@ export default function WorkshopCreation() { const [inPersonData, setInPersonData] = useState({ serviceType: "in-person", startTime: null, - duration: 0, + duration: "", location: "", }); @@ -36,7 +37,9 @@ export default function WorkshopCreation() { embeddingLink: "", }); - const [openModal, setOpenModal] = useState<"NewWebinar" | "ExistingWebinar" | "NewMeeting" | "ExistingMeeting" | null>(null); + const [openModal, setOpenModal] = useState< + "NewWebinar" | "ExistingWebinar" | "NewMeeting" | "ExistingMeeting" | null + >(null); const handleChange = (e: any) => { const { name, value } = e.target; @@ -44,23 +47,18 @@ export default function WorkshopCreation() { }; const course = getCleanCourseData(); - console.log(course) function getInitialType() { - if(course.productType === "Virtual Training - On Demand"){ - return "on demand" - } - else if(course.productType === "Virtual Training - Live Meeting"){ - return "meeting" - } - else if(course.productType === "Virtual Training - Live Webinar"){ - return "webinar" - } - else if(course.productType === "In-Person Training"){ - return "in-person" - } - else{ - return "meeting" + if (course.productType === "Virtual Training - On Demand") { + return "on demand"; + } else if (course.productType === "Virtual Training - Live Meeting") { + return "meeting"; + } else if (course.productType === "Virtual Training - Live Webinar") { + return "webinar"; + } else if (course.productType === "In-Person Training") { + return "in-person"; + } else { + return "meeting"; } } @@ -76,11 +74,156 @@ export default function WorkshopCreation() { minTime: 0, }); + useEffect(() => { + const mappedType = (() => { + switch (course.productType) { + case "Virtual Training - On Demand": + return "on-demand"; + case "Virtual Training - Live Meeting": + return "meeting"; + case "Virtual Training - Live Webinar": + return "webinar"; + case "In-Person Training": + return "in-person"; + default: + return "meeting"; + } + })(); + + // Only update type if it's not already set (prevents override if user changes it) + if (!formData.type) { + setFormData((prev) => ({ + ...prev, + type: mappedType, + })); + } + }, [course.productType]); + const handleSubmit = (event: any) => { event.preventDefault(); console.log("Form submitted:", formData); }; + const setField = useCourseEditStore((state) => state.setField); + + const [hasHydratedFormData, setHasHydratedFormData] = useState(false); + + useEffect(() => { + if (!hasHydratedFormData) { + // Hydrate formData type + const mappedType = (() => { + switch (course.productType) { + case "Virtual Training - On Demand": + return "on-demand"; + case "Virtual Training - Live Meeting": + return "meeting"; + case "Virtual Training - Live Webinar": + return "webinar"; + case "In-Person Training": + return "in-person"; + default: + return "meeting"; + } + })(); + + setFormData((prev) => ({ + ...prev, + type: mappedType, + })); + + // Hydrate type-specific data + if ( + mappedType === "in-person" && + typeof course.productInfo === "string" + ) { + try { + const parsed = JSON.parse(course.productInfo); + setInPersonData({ + serviceType: "in-person", + startTime: parsed.startTime || "", + duration: + parsed.duration !== undefined ? parsed.duration.toString() : "", + location: parsed.location || "", + }); + } catch (e) { + console.error("Failed to parse in-person data", e); + } + } + + if ( + mappedType === "on-demand" && + typeof course.productInfo === "string" + ) { + setOnDemandData({ + serviceType: "on demand", + embeddingLink: course.productInfo || "", + }); + } + + if (mappedType === "meeting") { + setMeetingData({ + serviceType: "meeting", + meetingID: course.productInfo || "", + }); + } + + if (mappedType === "webinar") { + setWebinarData({ + serviceType: "webinar", + meetingURL: course.productInfo || "", + }); + } + + setHasHydratedFormData(true); // ✅ lock hydration + } + }, [hasHydratedFormData, course]); + + // Watch onDemandData + useEffect(() => { + if (formData.type === "on-demand") { + setField("productType", "Virtual Training - On Demand"); + setField("productInfo", onDemandData.embeddingLink || ""); + } + }, [onDemandData, formData.type]); + + // Watch meetingData + useEffect(() => { + if (formData.type === "meeting") { + setField("productType", "Virtual Training - Live Meeting"); + setField("productInfo", meetingData.meetingID || ""); + } + }, [meetingData, formData.type]); + + // Watch webinarData + useEffect(() => { + if (formData.type === "webinar") { + setField("productType", "Virtual Training - Live Webinar"); + setField("productInfo", webinarData.meetingURL || ""); + } + }, [webinarData, formData.type]); + + // Watch inPersonData + useEffect(() => { + console.log("formData.type:", JSON.stringify(inPersonData)); + if (formData.type === "in-person") { + setField("productType", "In-Person Training"); + setField( + "productInfo", + JSON.stringify(inPersonData) // duration stays string + ); + } + }, [inPersonData, formData.type]); + + const productType = useCourseEditStore((state) => state.productType); + const productInfo = useCourseEditStore((state) => state.productInfo); + + useEffect(() => { + console.log("🧠 Zustand updated:", { + productType, + productInfo, + }); + }, [productType, productInfo]); + return (
@@ -115,8 +258,7 @@ export default function WorkshopCreation() { value="meeting" checked={formData.type === "meeting"} onChange={handleChange} - required - />{" "} + /> Meeting
@@ -163,7 +306,7 @@ export default function WorkshopCreation() { inPersonData={inPersonData} setInPersonData={setInPersonData} /> - ): formData.type === "meeting" ? ( + ) : formData.type === "meeting" ? ( )} - + {/* New Meeting Modal */} @@ -229,4 +375,4 @@ export default function WorkshopCreation() { ); -} \ No newline at end of file +} diff --git a/frontend/src/pages/Dashboard/dashboard.tsx b/frontend/src/pages/Dashboard/dashboard.tsx index 9687bfd..c972668 100644 --- a/frontend/src/pages/Dashboard/dashboard.tsx +++ b/frontend/src/pages/Dashboard/dashboard.tsx @@ -18,9 +18,10 @@ export default function Dashboard() { const progresses = (await fetchUserProgress()).progresses; console.log(progresses); setIncompleteCourses( - progresses.filter((p: any) => !p.course.isComplete) + progresses + // .filter((p: any) => !p.course.isComplete) ); - setCompletedCourses(progresses.filter((p: any) => p.course.isComplete)); + // setCompletedCourses(progresses.filter((p: any) => p.course.isComplete)); setLoading(false); } catch (error) { console.error(error); diff --git a/frontend/src/pages/courseDetailPage/courseDetailsPage.tsx b/frontend/src/pages/courseDetailPage/courseDetailsPage.tsx index 71efcae..e79d465 100644 --- a/frontend/src/pages/courseDetailPage/courseDetailsPage.tsx +++ b/frontend/src/pages/courseDetailPage/courseDetailsPage.tsx @@ -49,45 +49,6 @@ const CoursePage = ({ setCartItemCount }: CatalogProps) => { const navigate = useNavigate(); const [courseDetailsData, setCourseDetailsData] = useState( null - // { - // _id: "", - // className: "Introduction to Computer Science", - // courseDescription: `When it comes to your child's case closing, are you hearing terms or phrases like, "least drastic alternative", APR, RGAP, intervention, etc. and feeling lost in the acronyms and language? Are you facing an APR and worried about ongoing support or post-permanency legal ramifications? Do you find yourself feeling unsure about how to advocate for your rights or desires in a potential APR? Are you wondering if you have to accept an APR? Has your county told you the requirements to qualify for RGAP (post-APR financial assistance)? If you answered yes to any of these questions, join us as attorney, Tim Eirich, helps us make sense of all things APR and RGAP! - // - // Hours earned: 2.0 - // - // Feedback from this class: - // - // "Incredibly helpful information. This should be required so that all foster parents are informed and not taken advantage of." - // - // "Tim was EXCELLENT and provided insight into complicated legal matters." - // - // "All of Tim's trainings are excellent, and I'm grateful that he partners with Foster Source to equip foster and kinship parents with the knowledge that they need to advocate for themselves and the children in their care."`, - // instructorName: "Dr. Alice Johnson", - // creditNumber: 3, - // discussion: "An interactive discussion about computational thinking.", - // components: ["Lectures", "Labs", "Quizzes"], - // handouts: ["syllabus.pdf", "lecture1.pdf", "assignment1.pdf"], - // ratings: [], - // isLive: false, - // cost: 100, - // categories: ["Technology", "Category", "Misc"], - // thumbnailPath: "", - // instructorDescription: `Sarah has her degree in social work from Metropolitan State University with an emphasis in child and adolescent mental health. - // - // She has worked for Denver Department of Human Services Child Welfare for over 3 years as an ongoing social caseworker and currently holds a senior caseworker position in placement navigation. She has worked as a counselor at a residential treatment program for youth corrections, as a counselor for dual diagnosis adult men at a halfway house, and an independent living specialist for the disabled community/outreach specialist for individuals experiencing homelessness. - // - // Sarah writes: - // - // In my spare time, I spend most of my time with my two teenage daughters. I am a huge advocate for social justice issues which I spend a lot of my time supporting through peaceful protests, education, volunteer work, etc. I love camping, crafting, karaoke, road trip adventures, and dancing in my living room. My favorite place in the entire world is the Mojave Desert.`, - // instructorRole: "Moderator", - // lengthCourse: 2, - // time: new Date("2025-10-15T00:00:00.000Z"), - // isInPerson: true, - // students: [], - // regStart: new Date("2025-10-10T00:00:00.000Z"), - // regEnd: new Date("2025-10-12T00:00:00.000Z"), - // } ); const [starRating, setStarRating] = useState(-1); const [isAdded, setIsAdded] = useState(false); @@ -95,6 +56,8 @@ const CoursePage = ({ setCartItemCount }: CatalogProps) => { const [numStarsRatingPage, setNumStarsRatingpage] = useState(0); const [isAdmin, setIsAdmin] = useState(false); + const productType = courseDetailsData?.productType; + useEffect(() => { const checkAdminStatus = async () => { try { @@ -113,10 +76,9 @@ const CoursePage = ({ setCartItemCount }: CatalogProps) => { }, []); const navigateToCourseEdit = () => { - navigate(`/courses/edit`); // Change to the desired route + navigate(`/courses/edit`); }; - //================ Working axios request ====================== const fetchCourse = async () => { if (!courseId) return; try { @@ -128,11 +90,6 @@ const CoursePage = ({ setCartItemCount }: CatalogProps) => { } }; - // useEffect(() => { - // const id = queryParams.get("courseId"); - // setCourseId(id || ""); - // }, [location.search]); - useEffect(() => { fetchCourse(); checkCourseInCart(); @@ -360,6 +317,7 @@ const CoursePage = ({ setCartItemCount }: CatalogProps) => { isSurveyModalOpen={isSurveyModalOpen} setIsSurveyModalOpen={setIsSurveyModalOpen} productInfo={courseDetailsData.productInfo} + productType={courseDetailsData.productType} /> @@ -421,12 +379,14 @@ const DisplayBar = ({ isSurveyModalOpen, setIsSurveyModalOpen, productInfo, + productType, }: { creditHours: number; time: Date; isSurveyModalOpen: boolean; setIsSurveyModalOpen: any; productInfo: string; + productType: string; }) => { const [currentPage, setCurrentPage] = useState("Webinar"); const [surveyColor, setSurveyColor] = useState("#D9D9D9"); @@ -477,14 +437,9 @@ const DisplayBar = ({ return videoId ? `https://www.youtube.com/embed/${videoId}` : null; }; - const retrieveVideo = async () => { + const retrieveVideo = () => { try { - const response = await apiClient.get(`/videos`, { - params: { - _id: productInfo, - }, - }); - setVideoLink(getYouTubeEmbedUrl(response.data.data[0].videoUrl)); + setVideoLink(getYouTubeEmbedUrl(productInfo)); } catch (error) { console.error(error); } @@ -494,6 +449,10 @@ const DisplayBar = ({ retrieveVideo(); }, [productInfo]); + useEffect(() => { + console.log(productInfo); + }, [productInfo]); + return (
@@ -552,44 +511,76 @@ const DisplayBar = ({
{currentPage === "Workshop" && ( -
- {/*
-
Date: {new Date(time).toLocaleDateString()}
-
- Time:{" "} - {new Date(time).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - hour12: true, - })} +
+ {productType === "Virtual Training - On Demand" ? ( + + ) : productType === "Virtual Training - Live Meeting" ? ( +
+

Meeting ID: {productInfo}

+ + Join Zoom Meeting +
-
Length: {lengthCourse} hours
-
-
- - - - -
*/} - + ) : productType === "Virtual Training - Live Webinar" ? ( +
+

Webinar URL:

+ + Join Webinar + +
+ ) : productType === "In-Person Training" ? ( + (() => { + try { + const parsed = JSON.parse(productInfo); + return ( +
+

+ Location:{" "} + + {parsed.location} + +

+

+ Start Time:{" "} + {new Date(parsed.startTime).toLocaleString()} +

+

Duration: {parsed.duration} minutes

+
+ ); + } catch (e) { + return ( +

+ Invalid in-person course info. +

+ ); + } + })() + ) : ( +

Unknown course type.

+ )}
)} {currentPage === "Survey" && ( diff --git a/frontend/src/routes/RoutesAndLayouts.tsx b/frontend/src/routes/RoutesAndLayouts.tsx index 0c69ee1..3cdabdf 100644 --- a/frontend/src/routes/RoutesAndLayouts.tsx +++ b/frontend/src/routes/RoutesAndLayouts.tsx @@ -9,7 +9,6 @@ import Login from "../pages/UserAuth/Login"; import Register from "../pages/UserAuth/Register"; import ResetPassword from "../pages/UserAuth/resetPassword"; import ResetPasswordForm from "../pages/UserAuth/resetPasswordForm"; -import authService from "../services/authService"; import CoursePage from "../pages/courseDetailPage/courseDetailsPage"; import DiscountPage from "../pages/Admin/DiscountPage/Discount"; import SpeakerPage from "../pages/Admin/SpeakerPage/Speaker"; @@ -21,16 +20,12 @@ import ComponentPage from "../pages/Admin/ComponentPage/Component"; import SurveyPage from "../pages/Admin/SurveyPage/Survey"; import WorkshopCreation from "../pages/Admin/WorkshopCreation/WorkshopCreation"; import RegistrationPage from "../pages/Admin/RegistrationPage/RegistrationPage"; -import AdminPage from "../pages/Admin/AdminPage"; -import path from "path"; import EmailPage from "../pages/Admin/EmailPage/EmailPage"; -import apiClient from "../services/apiClient"; import { AdminSidebar } from "../components/AdminSidebar/AdminSidebar"; import EditCourse from "../pages/Admin/EditCoursePage/editCoursePage"; import EditSideBar from "../components/EditCourseSidebar/editCoursePageSideBar"; import Registrants from "../pages/Admin/RegistrantsPage/Registrants"; import SurveySummary from "../pages/Admin/SurveySummaryPage/SurveySummary"; -import CourseManagerPage from "../pages/Admin/CourseManagerPage/CourseManagerPage"; import UserManagementPage from "../pages/Admin/UserManagementPage/Users"; import UserTypesPage from "../pages/Admin/UserTypesPage/UserTypesPage"; import ProductProgressReport from "../pages/Admin/ProductSummaryPage/ProductProgressReport"; @@ -63,7 +58,7 @@ function RoutesAndLayout({ PrivateRoute: any; AdminRoute: any; }) { - const location = useLocation(); // ✅ safe now + const location = useLocation(); const isAuthRoute = location.pathname.startsWith("/login") || @@ -244,11 +239,7 @@ function RoutesAndLayout({ /> } /> } /> - } /> } /> - } /> - } /> - } /> } /> } /> - } /> } /> + + + + + } + > + } /> + } /> } /> } /> } /> diff --git a/frontend/src/store/useCourseEditStore.ts b/frontend/src/store/useCourseEditStore.ts index c19c760..84c3a70 100644 --- a/frontend/src/store/useCourseEditStore.ts +++ b/frontend/src/store/useCourseEditStore.ts @@ -26,6 +26,7 @@ interface CourseEditState { regStart: Date; regEnd?: Date; productType: string; + productInfo: string; shortUrl?: string; draft: boolean; @@ -76,6 +77,7 @@ const initialState: Omit< regStart: new Date(), regEnd: undefined, productType: "", + productInfo: "", shortUrl: undefined, draft: true, }; @@ -114,7 +116,6 @@ export const useCourseEditStore = create()( name: "course-edit-store", storage: createJSONStorage(() => sessionStorage), - // ✅ Correct usage here onRehydrateStorage: (state) => { return () => { state?.setHydrated(); // this is how Zustand recommends setting hydration flag From 510561685b2eb2bc2307dd2ed13e6b4cfbacc0ad Mon Sep 17 00:00:00 2001 From: Yifei Fang Date: Mon, 19 May 2025 22:31:48 -0500 Subject: [PATCH 2/4] product progress report --- backend/controllers/courseController.ts | 78 + backend/routes/courseRoutes.ts | 5 +- .../AdminCoursePreview/AdminCoursePreview.tsx | 2 +- .../editCoursePageSideBar.tsx | 13 +- .../ProductProgressReport.tsx | 1416 +++++++++-------- frontend/src/pages/UserAuth/Register.tsx | 14 +- frontend/src/routes/RoutesAndLayouts.tsx | 9 +- 7 files changed, 881 insertions(+), 656 deletions(-) diff --git a/backend/controllers/courseController.ts b/backend/controllers/courseController.ts index 4c5adfe..12bd8e8 100644 --- a/backend/controllers/courseController.ts +++ b/backend/controllers/courseController.ts @@ -605,3 +605,81 @@ export const updateUserProgress = async ( }); } }; + +// @desc Batch update user progress in a course +// @route PUT /api/courses/:courseId/progress/batch +// @access Public +export const batchUpdateUserProgress = async ( + req: Request, + res: Response +): Promise => { + try { + const { courseId } = req.params; + const updates = req.body.updates; // Array of updates + + if (!Array.isArray(updates) || updates.length === 0) { + res.status(400).json({ + success: false, + message: "No updates provided.", + }); + return; + } + + const results: any[] = []; + + for (const update of updates) { + const { userId, webinarComplete, surveyComplete, certificateComplete } = + update; + + console.log("Processing update for user:", userId); + + const progress = await Progress.findOne({ + course: courseId, + user: userId, + }); + + if (!progress) { + console.warn(`Progress not found for user ${userId}`); + results.push({ + userId, + success: false, + message: "Progress record not found.", + }); + continue; + } + + const completedComponents = { + webinar: Boolean(webinarComplete), + survey: Boolean(surveyComplete), + certificate: Boolean(certificateComplete), + }; + + progress.completedComponents = completedComponents; + + const allComplete = Object.values(completedComponents).every(Boolean); + progress.isComplete = allComplete; + if (allComplete && !progress.dateCompleted) { + progress.dateCompleted = new Date(); + } + + await progress.save(); + + results.push({ + userId, + success: true, + }); + } + + res.status(200).json({ + success: true, + message: "Batch update complete.", + results, + }); + } catch (error: any) { + console.error("Error in batchUpdateUserProgress:", error); + res.status(500).json({ + success: false, + message: error.message || "Internal server error.", + }); + } +}; diff --git a/backend/routes/courseRoutes.ts b/backend/routes/courseRoutes.ts index 7922f4a..665c79d 100644 --- a/backend/routes/courseRoutes.ts +++ b/backend/routes/courseRoutes.ts @@ -8,6 +8,7 @@ import { getCourseUsers, getCourseProgress, updateUserProgress, + batchUpdateUserProgress, } from "../controllers/courseController"; const router = express.Router(); @@ -34,6 +35,8 @@ router.get("/:courseId/users", getCourseUsers); router.get("/:courseId/progress", getCourseProgress); // PUT update user's progress in a course -router.put("/:courseId/progress/:userId", updateUserProgress); +router.put("/:courseId/progress/single/:userId", updateUserProgress); + +router.put("/:courseId/progress/batch", batchUpdateUserProgress); export default router; diff --git a/frontend/src/components/AdminCoursePreview/AdminCoursePreview.tsx b/frontend/src/components/AdminCoursePreview/AdminCoursePreview.tsx index 6b530c8..a93340c 100644 --- a/frontend/src/components/AdminCoursePreview/AdminCoursePreview.tsx +++ b/frontend/src/components/AdminCoursePreview/AdminCoursePreview.tsx @@ -181,7 +181,7 @@ function AdminCoursePreview({ - + -
- -
-
-
- Webinar - -
- -
- Survey - -
- -
- Certificate - -
-
- -
- - -
-
-
-
- ); +const EditProgressModal: React.FC = ({ + isOpen, + onClose, + progress, + onSave, +}) => { + const [editedProgress, setEditedProgress] = useState(progress); + + // Update local state when progress prop changes + useEffect(() => { + setEditedProgress(progress); + }, [progress]); + + if (!isOpen) return null; + + const handleComponentChange = ( + component: "webinar" | "survey" | "certificate", + checked: boolean + ) => { + console.log("Component change:", component, checked); + const updatedProgress = { + ...editedProgress, + completedComponents: { + ...editedProgress.completedComponents, + [component]: checked, + }, + }; + + // Check if all components are complete + const allComplete = Object.values( + updatedProgress.completedComponents + ).every(Boolean); + updatedProgress.isComplete = allComplete; + if (allComplete && !updatedProgress.dateCompleted) { + updatedProgress.dateCompleted = new Date(); + } + + setEditedProgress(updatedProgress); + }; + + const handleSave = () => { + console.log("Save button clicked"); + console.log("Current edited progress:", editedProgress); + onSave(editedProgress); + }; + + return ( +
+
+
+
+

Edit User Progress

+
+

+ Product: + {progress.course.className} +

+

+ User: + {progress.user.name} +

+
+
+ +
+ +
+
+
+ Workshop + +
+ +
+ Survey + +
+ +
+ Certificate + +
+
+ +
+ + +
+
+
+
+ ); }; -const ProductProgressReport: React.FC = () => { - const [selectedCourse, setSelectedCourse] = useState(null); - const [startDate, setStartDate] = useState(''); - const [endDate, setEndDate] = useState(''); - const [selectedUserType, setSelectedUserType] = useState('All'); - const [excludeFinished, setExcludeFinished] = useState(false); - const [userProgress, setUserProgress] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [currentPage, setCurrentPage] = useState(1); - const [itemsPerPage] = useState(15); - const [allCourses, setAllCourses] = useState([]); - const [editModalOpen, setEditModalOpen] = useState(false); - const [selectedProgress, setSelectedProgress] = useState(null); - - // Load courses from API - useEffect(() => { - const fetchAllCourses = async () => { - try { - const response = await apiClient.get('/courses'); - const courses = response.data.data.map((course: Course) => ({ - value: course._id, - label: course.className, - course: course - })); - setAllCourses(courses); - } catch (error) { - console.error('Error fetching courses:', error); - setAllCourses([]); - } - }; - - fetchAllCourses(); - }, []); - - // Load course options with search - const loadCourseOptions = async (inputValue: string) => { - if (!inputValue) { - return allCourses; - } - - const filteredCourses = allCourses.filter(course => - course.label.toLowerCase().includes(inputValue.toLowerCase()) - ); - return filteredCourses; - }; - - // Fetch progress from API - const fetchProgress = async () => { - if (!selectedCourse?.value) return; - - setIsLoading(true); - try { - // Get enrolled users for the course - const usersResponse = await apiClient.get(`/courses/${selectedCourse.value}/users`); - const enrolledUsers = usersResponse.data.users; - - // Get progress data for the course - const progressResponse = await apiClient.get(`/courses/${selectedCourse.value}/progress`); - const courseProgress = progressResponse.data.progress || []; - - // Combine user data with progress data - const combinedProgress: UserProgress[] = enrolledUsers.map((user: User) => { - const userProgress = courseProgress.find((p: any) => p.user?._id === user._id); - - // Default progress values - const defaultProgress = { - isComplete: false, - completedComponents: { - webinar: false, - survey: false, - certificate: false - }, - dateCompleted: null, - createdAt: new Date() - }; - - // Merge user progress with defaults - const mergedProgress = userProgress ? { - isComplete: userProgress.isComplete || false, - completedComponents: { - webinar: userProgress.completedComponents?.webinar || false, - survey: userProgress.completedComponents?.survey || false, - certificate: userProgress.completedComponents?.certificate || false - }, - dateCompleted: userProgress.dateCompleted || null, - createdAt: userProgress.createdAt || new Date() - } : defaultProgress; - - return { - user, - course: selectedCourse.course, - ...mergedProgress - }; - }); - - let filteredProgress = combinedProgress; - - // Apply filters - if (selectedUserType !== 'All') { - filteredProgress = filteredProgress.filter(p => p.user.role === selectedUserType); - } - - if (startDate) { - const start = new Date(startDate); - filteredProgress = filteredProgress.filter(p => - p.registeredDate && p.registeredDate >= start - ); - } - if (endDate) { - const end = new Date(endDate); - end.setHours(23, 59, 59, 999); - filteredProgress = filteredProgress.filter(p => - p.registeredDate && p.registeredDate <= end - ); - } - - if (excludeFinished) { - filteredProgress = filteredProgress.filter(p => !p.isComplete); - } - - setUserProgress(filteredProgress); - } catch (error) { - console.error('Error fetching data:', error); - setUserProgress([]); - } finally { - setIsLoading(false); - } - }; - - // Fetch progress when filters change - useEffect(() => { - if (selectedCourse) { - fetchProgress(); - } - }, [selectedCourse, startDate, endDate, selectedUserType, excludeFinished]); - - const handleSaveProgress = async (updatedProgress: UserProgress) => { - try { - const response = await apiClient.put( - `/courses/${updatedProgress.course._id}/progress/${updatedProgress.user._id}`, - { - webinarComplete: updatedProgress.completedComponents.webinar, - surveyComplete: updatedProgress.completedComponents.survey, - certificateComplete: updatedProgress.completedComponents.certificate, - isComplete: updatedProgress.isComplete, - dateCompleted: updatedProgress.dateCompleted - } - ); - - if (response.data.success) { - await fetchProgress(); - setEditModalOpen(false); - } else { - console.error('Failed to update progress:', response.data.message); - } - } catch (error) { - console.error('Error in handleSaveProgress:', error); - } - }; - - const handleDownloadCSV = () => { - if (!userProgress.length) return; - - const headers = [ - 'Name', - 'Email', - 'User Type', - 'Registered', - 'Completed', - 'Webinar', - 'Survey', - 'Certificate' - ]; - - const rows = userProgress.map(progress => [ - progress.user.name, - progress.user.email, - progress.user.role, - progress.registeredDate ? format(progress.registeredDate, 'MM/dd/yyyy h:mm a') : 'N/A', - progress.dateCompleted ? format(progress.dateCompleted, 'MM/dd/yyyy h:mm a') : 'N/A', - progress.completedComponents.webinar ? 'Complete' : 'Incomplete', - progress.completedComponents.survey ? 'Complete' : 'Incomplete', - progress.completedComponents.certificate ? 'Complete' : 'Incomplete' - ]); - - const csvContent = [ - headers.join(','), - ...rows.map(row => row.join(',')) - ].join('\n'); - - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const link = document.createElement('a'); - const url = URL.createObjectURL(blob); - link.setAttribute('href', url); - link.setAttribute('download', `${selectedCourse?.label}_progress_report.csv`); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - - const displayedProgress = userProgress - .slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage); - - const totalPages = Math.ceil(userProgress.length / itemsPerPage); - - // Custom styles for react-select - const selectStyles = { - control: (base: any) => ({ - ...base, - minHeight: '42px', - borderRadius: '0.5rem', - borderColor: '#E5E7EB', - boxShadow: 'none', - '&:hover': { - borderColor: '#8757a3' - } - }), - option: (base: any, state: { isSelected: boolean; isFocused: boolean }) => ({ - ...base, - backgroundColor: state.isSelected - ? '#8757a3' - : state.isFocused - ? '#F3F4F6' - : 'white', - color: state.isSelected ? 'white' : '#374151', - '&:active': { - backgroundColor: '#8757a3' - } - }), - input: (base: any) => ({ - ...base, - color: '#374151' - }), - placeholder: (base: any) => ({ - ...base, - color: '#9CA3AF' - }), - singleValue: (base: any) => ({ - ...base, - color: '#374151' - }), - menu: (base: any) => ({ - ...base, - borderRadius: '0.5rem', - boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)' - }) - }; - - const handleEditClick = (progress: UserProgress) => { - setSelectedProgress(progress); - setEditModalOpen(true); - }; - - return ( -
-
-
-
-

Product Progress Report

-
- -
- {/* Course Search */} -
- setSelectedUserType(e.target.value)} - className="border rounded-lg px-3 py-1.5" - > - - - - - - - - - - - - -
- -
- Start Date - setStartDate(e.target.value)} - className="border rounded-lg px-3 py-1.5" - /> -
- -
- End Date - setEndDate(e.target.value)} - className="border rounded-lg px-3 py-1.5" - /> -
- -
- setExcludeFinished(e.target.checked)} - className="rounded border-gray-300" - /> - -
- - -
- - {/* Table Container */} -
- {isLoading ? ( -
-
-
- ) : !selectedCourse ? ( -
- Select a course to view progress report -
- ) : displayedProgress.length === 0 ? ( -
- No progress records found for the selected filters -
- ) : ( -
- - - - - - - - - - - - - - - - {displayedProgress.map((progress) => ( - - - - - - - - - - - - ))} - -
NameEmailTypeRegisteredCompletedWebinarSurveyCertificateEdit
{progress.user.name}{progress.user.email}{progress.user.role} - {progress.registeredDate - ?
- {format(progress.registeredDate, 'MM/dd/yy')} - {format(progress.registeredDate, 'h:mm a')} EST -
- : 'N/A' - } -
- {progress.dateCompleted - ?
- {format(progress.dateCompleted, 'MM/dd/yy')} - {format(progress.dateCompleted, 'h:mm a')} EST -
- : 'N/A' - } -
- {progress.completedComponents.webinar ? ( -
- - {format(new Date(), 'MM/dd/yy')} -
- ) : ( - - )} -
- {progress.completedComponents.survey ? ( -
- - {format(new Date(), 'MM/dd/yy')} -
- ) : ( - - )} -
- {progress.completedComponents.certificate ? ( -
- - {format(new Date(), 'MM/dd/yy')} -
- ) : ( - - )} -
- -
-
- )} -
- - {/* Pagination */} - {displayedProgress.length > 0 && ( -
- - {currentPage} - {Array.from({ length: Math.min(3, totalPages - currentPage) }, (_, i) => ( - - ))} - {currentPage + 3 < totalPages && ...} - {currentPage + 3 < totalPages && ( - - )} - -
- )} -
-
-
- - {/* Add the EditProgressModal component */} - {selectedProgress && ( - setEditModalOpen(false)} - progress={selectedProgress} - onSave={handleSaveProgress} - /> - )} - - ); +interface ProductProgressReportProps { + fixedCourseId: boolean; +} + +const ProductProgressReport: React.FC = ({ + fixedCourseId, +}) => { + const [selectedCourse, setSelectedCourse] = useState( + null + ); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [selectedUserType, setSelectedUserType] = useState("All"); + const [excludeFinished, setExcludeFinished] = useState(false); + const [userProgress, setUserProgress] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage] = useState(15); + const [allCourses, setAllCourses] = useState([]); + const [editModalOpen, setEditModalOpen] = useState(false); + const [selectedProgress, setSelectedProgress] = useState( + null + ); + + const { id: courseId } = useParams(); + + // Load courses from API + useEffect(() => { + const fetchAllCourses = async () => { + try { + const response = await apiClient.get("/courses"); + const courses = response.data.data.map((course: Course) => ({ + value: course._id, + label: course.className, + course: course, + })); + setAllCourses(courses); + } catch (error) { + console.error("Error fetching courses:", error); + setAllCourses([]); + } + }; + if (!fixedCourseId) fetchAllCourses(); + }, []); + + useEffect(() => { + const fetchFixedCourse = async () => { + if (!fixedCourseId) return; + + try { + const response = await apiClient.get(`/courses/${courseId}`); + const course = response.data.data; + setSelectedCourse({ + value: course._id, + label: course.className, + course: course, + }); + } catch (err) { + console.error("Failed to load fixed course", err); + } + }; + + fetchFixedCourse(); + }, [fixedCourseId]); + + // Load course options with search + const loadCourseOptions = async (inputValue: string) => { + if (!inputValue) { + return allCourses; + } + + const filteredCourses = allCourses.filter((course) => + course.label.toLowerCase().includes(inputValue.toLowerCase()) + ); + return filteredCourses; + }; + + // Fetch progress from API + const fetchProgress = async () => { + if (!selectedCourse?.value) return; + + setIsLoading(true); + try { + // Get enrolled users for the course + const usersResponse = await apiClient.get( + `/courses/${selectedCourse.value}/users` + ); + const enrolledUsers = usersResponse.data.users; + + // Get progress data for the course + const progressResponse = await apiClient.get( + `/courses/${selectedCourse.value}/progress` + ); + const courseProgress = progressResponse.data.progress || []; + + // Combine user data with progress data + const combinedProgress: UserProgress[] = enrolledUsers.map( + (user: User) => { + const userProgress = courseProgress.find( + (p: any) => p.user?._id === user._id + ); + + // Default progress values + const defaultProgress = { + isComplete: false, + completedComponents: { + webinar: false, + survey: false, + certificate: false, + }, + dateCompleted: null, + createdAt: new Date(), + }; + + // Merge user progress with defaults + const mergedProgress = userProgress + ? { + isComplete: userProgress.isComplete || false, + completedComponents: { + webinar: userProgress.completedComponents?.webinar || false, + survey: userProgress.completedComponents?.survey || false, + certificate: + userProgress.completedComponents?.certificate || false, + }, + dateCompleted: userProgress.dateCompleted || null, + createdAt: userProgress.createdAt || new Date(), + } + : defaultProgress; + + return { + user, + course: selectedCourse.course, + ...mergedProgress, + }; + } + ); + + let filteredProgress = combinedProgress; + + // Apply filters + if (selectedUserType !== "All") { + filteredProgress = filteredProgress.filter( + (p) => p.user.role === selectedUserType + ); + } + + if (startDate) { + const start = new Date(startDate); + filteredProgress = filteredProgress.filter( + (p) => p.registeredDate && p.registeredDate >= start + ); + } + if (endDate) { + const end = new Date(endDate); + end.setHours(23, 59, 59, 999); + filteredProgress = filteredProgress.filter( + (p) => p.registeredDate && p.registeredDate <= end + ); + } + + if (excludeFinished) { + filteredProgress = filteredProgress.filter((p) => !p.isComplete); + } + + setUserProgress(filteredProgress); + } catch (error) { + console.error("Error fetching data:", error); + setUserProgress([]); + } finally { + setIsLoading(false); + } + }; + + // Fetch progress when filters change + useEffect(() => { + if (selectedCourse) { + fetchProgress(); + } + }, [selectedCourse, startDate, endDate, selectedUserType, excludeFinished]); + + const handleSaveProgress = (updatedProgress: UserProgress) => { + setUserProgress((prevProgress) => + prevProgress.map((p) => + p.user._id === updatedProgress.user._id + ? { ...p, ...updatedProgress } + : p + ) + ); + setEditModalOpen(false); + }; + + const handleSaveAllProgress = async () => { + if (!selectedCourse?.value) return; + + try { + const payload = userProgress.map((p) => ({ + userId: p.user._id, + webinarComplete: p.completedComponents.webinar, + surveyComplete: p.completedComponents.survey, + certificateComplete: p.completedComponents.certificate, + isComplete: p.isComplete, + dateCompleted: p.dateCompleted, + })); + + const response = await apiClient.put( + `/courses/${selectedCourse.value}/progress/batch`, + { updates: payload } + ); + + if (response.data.success) { + alert("All progress saved successfully."); + } else { + console.error("Batch update failed:", response.data.message); + alert("Batch update failed."); + } + } catch (error) { + console.error("Error during batch update:", error); + alert("Error occurred while saving progress."); + } + }; + + const handleDownloadCSV = () => { + if (!userProgress.length) return; + + const headers = [ + "Name", + "Email", + "User Type", + "Registered", + "Completed", + "Webinar", + "Survey", + "Certificate", + ]; + + const rows = userProgress.map((progress) => [ + progress.user.name, + progress.user.email, + progress.user.role, + progress.registeredDate + ? format(progress.registeredDate, "MM/dd/yyyy h:mm a") + : "N/A", + progress.dateCompleted + ? format(progress.dateCompleted, "MM/dd/yyyy h:mm a") + : "N/A", + progress.completedComponents.webinar ? "Complete" : "Incomplete", + progress.completedComponents.survey ? "Complete" : "Incomplete", + progress.completedComponents.certificate ? "Complete" : "Incomplete", + ]); + + const csvContent = [ + headers.join(","), + ...rows.map((row) => row.join(",")), + ].join("\n"); + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + link.setAttribute( + "download", + `${selectedCourse?.label}_progress_report.csv` + ); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const displayedProgress = userProgress.slice( + (currentPage - 1) * itemsPerPage, + currentPage * itemsPerPage + ); + + const totalPages = Math.ceil(userProgress.length / itemsPerPage); + + // Custom styles for react-select + const selectStyles = { + control: (base: any) => ({ + ...base, + minHeight: "42px", + borderRadius: "0.5rem", + borderColor: "#E5E7EB", + boxShadow: "none", + "&:hover": { + borderColor: "#8757a3", + }, + }), + option: ( + base: any, + state: { isSelected: boolean; isFocused: boolean } + ) => ({ + ...base, + backgroundColor: state.isSelected + ? "#8757a3" + : state.isFocused + ? "#F3F4F6" + : "white", + color: state.isSelected ? "white" : "#374151", + "&:active": { + backgroundColor: "#8757a3", + }, + }), + input: (base: any) => ({ + ...base, + color: "#374151", + }), + placeholder: (base: any) => ({ + ...base, + color: "#9CA3AF", + }), + singleValue: (base: any) => ({ + ...base, + color: "#374151", + }), + menu: (base: any) => ({ + ...base, + borderRadius: "0.5rem", + boxShadow: + "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", + }), + }; + + const handleEditClick = (progress: UserProgress) => { + setSelectedProgress(progress); + setEditModalOpen(true); + }; + + const [userTypes, setUserTypes] = useState<{ _id: string; name: string }[]>( + [] + ); + + useEffect(() => { + const fetchUserTypes = async () => { + try { + const response = await apiClient.get("/user-types"); + setUserTypes(response.data.data); + } catch (error) { + console.error("Error fetching user types:", error); + } + }; + fetchUserTypes(); + }, []); + + return ( +
+
+
+
+

Product Progress Report

+
+ +
+ {/* Course Search */} + {!fixedCourseId && ( +
+ { )}
Upload File - + */} {/* Option 2 */}
{/* Option 1 */} -
Upload File - + */} {/* Option 2 */}
-
+ {/*

Individual Pricing

@@ -290,7 +290,7 @@ const Pricing: React.FC = ({ onClose }) => {
-
+
*/}
diff --git a/frontend/src/pages/courseDetailPage/courseDetailsPage.tsx b/frontend/src/pages/courseDetailPage/courseDetailsPage.tsx index 386a41a..d1ac0f1 100644 --- a/frontend/src/pages/courseDetailPage/courseDetailsPage.tsx +++ b/frontend/src/pages/courseDetailPage/courseDetailsPage.tsx @@ -12,6 +12,7 @@ import apiClient from "../../services/apiClient"; import SurveyModal from "./SurveyModal"; import YouTube from "react-youtube"; import { Progress } from "../../shared/types/progress"; +import { ZoomMeeting } from "../../shared/types"; const FaStar = _FaStar as ComponentType<{ size?: number; color?: string }>; const FaStarHalfAlt = _FaStarHalfAlt as ComponentType<{ @@ -196,7 +197,7 @@ const CoursePage = ({ setCartItemCount }: CatalogProps) => {

{courseDetailsData.creditNumber} Credits

-

+ {/*

Live Web Event{" "} {new Date(courseDetailsData.time).toLocaleDateString()} at{" "} {new Date(courseDetailsData.time).toLocaleTimeString( @@ -207,7 +208,7 @@ const CoursePage = ({ setCartItemCount }: CatalogProps) => { hour12: true, } )} -

+

*/}
@@ -445,11 +446,14 @@ const DisplayBar = ({ const [certificateColor, setCertificateColor] = useState("#D9D9D9"); const [videoLink, setVideoLink] = useState(""); const [videoCompleted, setVideoCompleted] = useState(false); + const [meeting, setMeeting] = useState(null); const location = useLocation(); const searchParams = new URLSearchParams(location.search); const courseId = searchParams.get("courseId"); + const user = JSON.parse(localStorage.getItem("user") || "{}"); + const onPlayerReady = (event: any) => { event.target.playVideo(); }; @@ -460,7 +464,6 @@ const DisplayBar = ({ setVideoCompleted(true); try { - const user = JSON.parse(localStorage.getItem("user") || "{}"); const response = await apiClient.put( `/courses/${courseId}/progress/single/${user._id}`, { @@ -509,8 +512,6 @@ const DisplayBar = ({ /* TODO: Needs to be complete once certificate page is out */ const handleAccessCertificate = async () => { - const user = JSON.parse(localStorage.getItem("user") || "{}"); - if (!certificate) { setCertificateCompleted(true); @@ -583,13 +584,88 @@ const DisplayBar = ({ } }; + const retrieveMeeting = async () => { + if ( + productType === "Virtual Training - Live Meeting" || + productType === "Virtual Training - Live Webinar" + ) + try { + const response = await apiClient.get("zoom/meetings"); + const meetings = response.data.meetings; + + const matchingMeeting = meetings.find( + (meeting: any) => String(meeting.id) === String(productInfo) + ); + + if (matchingMeeting) { + setMeeting(matchingMeeting); + } else { + console.warn("No matching meeting found for ID:", productInfo); + } + } catch (error) { + console.error(error); + } + }; + + function formatMeetingTime(dateStr: string, timezone: string) { + try { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + return formatter.format(new Date(dateStr)); + } catch (error) { + console.error("Invalid time zone:", timezone, error); + // Fallback to local time + return new Date(dateStr).toLocaleString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + } + } + useEffect(() => { retrieveVideo(); + retrieveMeeting(); }, [productInfo]); - useEffect(() => { - console.log(productInfo); - }, [productInfo]); + const handleMeetingProgress = async () => { + try { + const now = new Date(); + + const meetingStart = new Date(meeting?.start_time || ""); + const meetingEnd = new Date( + meetingStart.getTime() + (meeting?.duration || 0) * 60000 + ); + const windowStart = new Date(meetingStart.getTime() - 15 * 60000); // 15 minutes before start + + // Check if current time is within allowed window + const isWithinWindow = now >= windowStart && now <= meetingEnd; + + if (isWithinWindow) { + await apiClient.put( + `/courses/${courseId}/progress/single/${user._id}`, + { + webinarComplete: true, + } + ); + console.log("✅ Meeting progress updated"); + } else { + console.warn("⏳ Not within the valid time window to mark progress."); + } + } catch (error) { + console.error("❌ Error updating meeting progress:", error); + } + }; if (hasProgress) return ( @@ -679,23 +755,41 @@ const DisplayBar = ({ ) : productType === "Virtual Training - Live Meeting" ? (

Meeting ID: {productInfo}

+

+ Meeting Date & Time:{" "} + {formatMeetingTime( + meeting?.start_time || "", + meeting?.timezone || "" + )} + , Timezone: {meeting?.timezone} +

Join Zoom Meeting
) : productType === "Virtual Training - Live Webinar" ? (
+

+ Meeting Date & Time:{" "} + {formatMeetingTime( + meeting?.start_time || "", + meeting?.timezone || "" + )} + , Timezone: {meeting?.timezone} +

Webinar URL:

Join Webinar @@ -754,7 +848,7 @@ const DisplayBar = ({ this may take a couple of days to update.)