diff --git a/src/actions/educationPortal/atomActions.js b/src/actions/educationPortal/atomActions.js new file mode 100644 index 0000000000..87b86703a4 --- /dev/null +++ b/src/actions/educationPortal/atomActions.js @@ -0,0 +1,113 @@ +import axios from 'axios'; +import { toast } from 'react-toastify'; +import { ENDPOINTS } from '~/utils/URL'; +import { + FETCH_AVAILABLE_ATOMS_START, + FETCH_AVAILABLE_ATOMS_SUCCESS, + FETCH_AVAILABLE_ATOMS_ERROR, + ASSIGN_ATOMS_START, + ASSIGN_ATOMS_SUCCESS, + ASSIGN_ATOMS_ERROR, + SELECT_ATOM, + DESELECT_ATOM, + CLEAR_SELECTIONS, + SET_NOTE, + CLEAR_FORM, + SHOW_MODAL, + HIDE_MODAL, +} from '~/constants/educationPortal/atom'; + +// Fetch available atoms +export const fetchAvailableAtoms = () => { + return async dispatch => { + dispatch({ type: FETCH_AVAILABLE_ATOMS_START }); + try { + const response = await axios.get(`${ENDPOINTS.APIEndpoint()}/atoms`); + dispatch({ + type: FETCH_AVAILABLE_ATOMS_SUCCESS, + payload: response.data, + }); + } catch (error) { + dispatch({ + type: FETCH_AVAILABLE_ATOMS_ERROR, + payload: error.message, + }); + toast.error('Failed to load atoms. Please try again.'); + } + }; +}; + +// Assign atoms to a student +export const assignAtoms = (studentId, atoms, note) => { + return async dispatch => { + dispatch({ type: ASSIGN_ATOMS_START }); + try { + const payload = { + studentId, + atomTypes: atoms || [], + note: note || '', + }; + + const response = await axios.post(ENDPOINTS.EDUCATOR_ASSIGN_ATOMS(), payload); + + dispatch({ + type: ASSIGN_ATOMS_SUCCESS, + payload: response.data, + }); + + toast.success('Atoms have been assigned', { + position: 'top-right', + autoClose: 3000, + }); + + return response.data; + } catch (error) { + dispatch({ + type: ASSIGN_ATOMS_ERROR, + payload: error.message, + }); + + toast.error('Failed to assign atoms. Please try again.', { + position: 'top-right', + autoClose: 5000, + }); + + throw error; + } + }; +}; + +// Selection actions +export const selectAtom = atomId => ({ + type: SELECT_ATOM, + payload: atomId, +}); + +export const deselectAtom = atomId => ({ + type: DESELECT_ATOM, + payload: atomId, +}); + +export const clearSelections = () => ({ + type: CLEAR_SELECTIONS, +}); + +// Form actions +export const setNote = note => ({ + type: SET_NOTE, + payload: note, +}); + +export const clearForm = () => ({ + type: CLEAR_FORM, +}); + +// Modal actions +export const showModal = (studentId, studentName) => ({ + type: SHOW_MODAL, + payload: { studentId, studentName }, +}); + +export const hideModal = () => ({ + type: HIDE_MODAL, +}); diff --git a/src/components/EductionPortal/AssignAtomModal/AssignAtomModal.jsx b/src/components/EductionPortal/AssignAtomModal/AssignAtomModal.jsx new file mode 100644 index 0000000000..b73dfe2c4d --- /dev/null +++ b/src/components/EductionPortal/AssignAtomModal/AssignAtomModal.jsx @@ -0,0 +1,424 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Button, + FormGroup, + Label, + Input, + FormFeedback, + Dropdown, + Table, + CustomInput, +} from 'reactstrap'; +import axios from 'axios'; +import { ENDPOINTS } from '~/utils/URL'; +import styles from './AssignAtomModal.module.css'; +import { + fetchAvailableAtoms, + assignAtoms, + selectAtom, + deselectAtom, + setNote, + hideModal, + clearForm, +} from '~/actions/educationPortal/atomActions'; + +const AssignAtomModal = ({ + // Redux state + isModalOpen, + studentId, + studentName, + availableAtoms, + selectedAtoms, + note, + isLoadingAtoms, + isSubmitting, + submitError, + darkMode, + + // Redux actions + fetchAvailableAtoms, + assignAtoms, + selectAtom, + deselectAtom, + setNote, + hideModal, + clearForm, +}) => { + const [localNote, setLocalNote] = useState(''); + const [validationError, setValidationError] = useState(''); + + // Student search state + const [searchText, setSearchText] = useState(''); + const [allUsers, setAllUsers] = useState([]); + const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false); + const [isInputFocus, setIsInputFocus] = useState(false); + const [selectedStudent, setSelectedStudent] = useState(null); + const [isLoadingUsers, setIsLoadingUsers] = useState(false); + + const userSearchRef = useRef(); + + // Sync local note with Redux state + useEffect(() => { + setLocalNote(note); + }, [note]); + + // Load atoms when modal opens + useEffect(() => { + if (isModalOpen && availableAtoms.length === 0) { + fetchAvailableAtoms(); + } + }, [isModalOpen, availableAtoms.length, fetchAvailableAtoms]); + + // Load users when modal opens + useEffect(() => { + if (isModalOpen && allUsers.length === 0) { + fetchAllUsers(); + } + }, [isModalOpen, allUsers.length]); + + // Set selected student when studentId changes + useEffect(() => { + if (studentId && studentName) { + setSelectedStudent({ _id: studentId, name: studentName }); + setSearchText(studentName); + } + }, [studentId, studentName]); + + // Fetch all users for search + const fetchAllUsers = async () => { + setIsLoadingUsers(true); + try { + const response = await axios.get(`${ENDPOINTS.APIEndpoint()}/userprofile`); + setAllUsers(response.data); + } catch (error) { + // Handle error silently or show toast + // console.error('Failed to fetch users:', error); + } finally { + setIsLoadingUsers(false); + } + }; + + const handleAtomToggle = useCallback( + atomId => { + if (selectedAtoms.includes(atomId)) { + deselectAtom(atomId); + } else { + selectAtom(atomId); + } + }, + [selectedAtoms, selectAtom, deselectAtom], + ); + + const handleNoteChange = useCallback( + e => { + const value = e.target.value; + if (value.length <= 500) { + setLocalNote(value); + setNote(value); + } + }, + [setNote], + ); + + // Student selection handlers + const handleStudentSelect = user => { + setSelectedStudent(user); + setSearchText(`${user.firstName} ${user.lastName}`); + setIsUserDropdownOpen(false); + setIsInputFocus(false); + }; + + const handleSearchChange = value => { + setSearchText(value); + setIsUserDropdownOpen(true); + }; + + const handleSubmit = useCallback(async () => { + // Validation + if (selectedAtoms.length === 0) { + setValidationError('Please select at least one atom'); + return; + } + + if (!selectedStudent) { + setValidationError('Please select a student'); + return; + } + + setValidationError(''); + + try { + await assignAtoms(selectedStudent._id, selectedAtoms, localNote); + hideModal(); + } catch (error) { + // Error is handled by the action + // console.error('Assignment failed:', error); + } + }, [selectedAtoms, localNote, selectedStudent, assignAtoms, hideModal]); + + const handleCancel = useCallback(() => { + if (selectedAtoms.length > 0 || localNote.trim() || selectedStudent) { + // eslint-disable-next-line no-alert + if (window.confirm('You have unsaved changes. Are you sure you want to cancel?')) { + clearForm(); + setSelectedStudent(null); + setSearchText(''); + hideModal(); + } + } else { + hideModal(); + } + }, [selectedAtoms.length, localNote, selectedStudent, clearForm, hideModal]); + + // Filter users based on search text + const filteredUsers = allUsers.filter(user => { + if (!searchText.trim()) return false; + const fullName = `${user.firstName} ${user.lastName}`.toLowerCase(); + const searchLower = searchText.toLowerCase(); + return ( + (user.firstName.toLowerCase().includes(searchLower) || + user.lastName.toLowerCase().includes(searchLower) || + fullName.includes(searchLower)) && + user.isActive + ); + }); + + const handleClose = useCallback(() => { + handleCancel(); + }, [handleCancel]); + + const getCharacterCountClass = () => { + const count = localNote.length; + if (count > 450) return styles.error; + if (count > 400) return styles.warning; + return ''; + }; + + const isSubmitDisabled = isSubmitting || selectedAtoms.length === 0 || !selectedStudent; + + return ( + + +

Assign Atoms

+
+ + + {/* Student Selection */} + + +
+ setIsUserDropdownOpen(!isUserDropdownOpen)} + style={{ width: '100%', marginRight: '5px' }} + > + { + setIsInputFocus(true); + setIsUserDropdownOpen(true); + }} + onChange={e => handleSearchChange(e.target.value)} + placeholder="Search for a student..." + className={darkMode ? 'bg-darkmode-liblack text-light border-0' : ''} + autoComplete="off" + name="student-search" + /> + {isInputFocus || (searchText !== '' && allUsers && allUsers.length > 0) ? ( +
+ {isLoadingUsers ? ( +
Loading users...
+ ) : filteredUsers.length > 0 ? ( + filteredUsers.map(user => ( +
handleStudentSelect(user)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + handleStudentSelect(user); + } + }} + > + {user.firstName} {user.lastName} +
+ )) + ) : ( +
No users found
+ )} +
+ ) : null} +
+
+
+ + {/* Associated Atoms */} + + +
+ {isLoadingAtoms ? ( +
Loading atoms...
+ ) : ( +
+ {availableAtoms.map(atom => ( +
+ handleAtomToggle(atom._id)} + /> +
+ ))} +
+ )} +
+ + {/* Selected Atoms Summary */} + {selectedAtoms.length > 0 && ( +
+ Selected Atoms ({selectedAtoms.length}): +
+ {selectedAtoms.map(atomId => { + const atom = availableAtoms.find(a => a._id === atomId); + return ( +
+ {atom?.name || atom?.title || atom?.atomName || 'Unknown Atom'} + +
+ ); + })} +
+
+ )} +
+ + {/* Note Field */} + + +