diff --git a/src/actions/intermediateTasks.js b/src/actions/intermediateTasks.js new file mode 100644 index 0000000000..8521a9f9b0 --- /dev/null +++ b/src/actions/intermediateTasks.js @@ -0,0 +1,165 @@ +import { toast } from 'react-toastify'; +import { ENDPOINTS } from '~/utils/URL'; +import httpService from '../services/httpService'; +import { updateStudentTask } from './studentTasks'; + +/** + * Action types for intermediate tasks + */ +export const FETCH_INTERMEDIATE_TASKS_START = 'FETCH_INTERMEDIATE_TASKS_START'; +export const FETCH_INTERMEDIATE_TASKS_SUCCESS = 'FETCH_INTERMEDIATE_TASKS_SUCCESS'; +export const FETCH_INTERMEDIATE_TASKS_ERROR = 'FETCH_INTERMEDIATE_TASKS_ERROR'; +export const CREATE_INTERMEDIATE_TASK_SUCCESS = 'CREATE_INTERMEDIATE_TASK_SUCCESS'; +export const UPDATE_INTERMEDIATE_TASK_SUCCESS = 'UPDATE_INTERMEDIATE_TASK_SUCCESS'; +export const DELETE_INTERMEDIATE_TASK_SUCCESS = 'DELETE_INTERMEDIATE_TASK_SUCCESS'; +export const MARK_INTERMEDIATE_TASK_DONE = 'MARK_INTERMEDIATE_TASK_DONE'; + +/** + * Fetch intermediate tasks for a parent task + */ +export const fetchIntermediateTasks = (taskId) => { + return async (dispatch) => { + try { + const response = await httpService.get(ENDPOINTS.INTERMEDIATE_TASKS_BY_PARENT(taskId)); + return response.data; + } catch (error) { + console.error('Error fetching intermediate tasks:', error); + toast.error('Failed to fetch sub-tasks'); + throw error; + } + }; +}; + +/** + * Calculate total expected hours from intermediate tasks + */ +const calculateTotalExpectedHours = (intermediateTasks) => { + return intermediateTasks.reduce((total, task) => { + return total + (task.expected_hours || 0); + }, 0); +}; + +/** + * Update parent task's expected hours based on intermediate tasks + */ +const updateParentTaskExpectedHours = async (dispatch, getState, parentTaskId) => { + try { + // Fetch all intermediate tasks for this parent + const intermediateTasks = await dispatch(fetchIntermediateTasks(parentTaskId)); + + // Calculate total expected hours + const totalExpectedHours = calculateTotalExpectedHours(intermediateTasks); + + // Get the parent task from state + const state = getState(); + const parentTask = state.studentTasks.taskItems.find(t => t.id === parentTaskId); + + if (parentTask) { + // Update the parent task with new expected hours + dispatch(updateStudentTask(parentTaskId, { + ...parentTask, + suggested_total_hours: totalExpectedHours + })); + } + } catch (error) { + console.error('Error updating parent task expected hours:', error); + } +}; + +/** + * Create a new intermediate task + */ +export const createIntermediateTask = (taskData) => { + return async (dispatch, getState) => { + try { + const response = await httpService.post(ENDPOINTS.INTERMEDIATE_TASKS(), taskData); + toast.success('Sub-task created successfully'); + + // Update parent task expected hours + if (taskData.parentTaskId) { + await updateParentTaskExpectedHours(dispatch, getState, taskData.parentTaskId); + } + + return response.data; + } catch (error) { + console.error('Error creating intermediate task:', error); + const errorMessage = error.response?.data?.error || error.message || 'Failed to create sub-task'; + toast.error(`Error: ${errorMessage}`); + throw error; + } + }; +}; + +/** + * Update an intermediate task + */ +export const updateIntermediateTask = (id, taskData) => { + return async (dispatch, getState) => { + try { + const response = await httpService.put(ENDPOINTS.INTERMEDIATE_TASK_BY_ID(id), taskData); + toast.success('Sub-task updated successfully'); + + // Update parent task expected hours + if (taskData.parentTaskId) { + await updateParentTaskExpectedHours(dispatch, getState, taskData.parentTaskId); + } + + return response.data; + } catch (error) { + console.error('Error updating intermediate task:', error); + const errorMessage = error.response?.data?.error || error.message || 'Failed to update sub-task'; + toast.error(`Error: ${errorMessage}`); + throw error; + } + }; +}; + +/** + * Delete an intermediate task + */ +export const deleteIntermediateTask = (id, parentTaskId = null) => { + return async (dispatch, getState) => { + try { + await httpService.delete(ENDPOINTS.INTERMEDIATE_TASK_BY_ID(id)); + toast.success('Sub-task deleted successfully'); + + // Update parent task expected hours + if (parentTaskId) { + await updateParentTaskExpectedHours(dispatch, getState, parentTaskId); + } + + return true; + } catch (error) { + console.error('Error deleting intermediate task:', error); + const errorMessage = error.response?.data?.error || error.message || 'Failed to delete sub-task'; + toast.error(`Error: ${errorMessage}`); + throw error; + } + }; +}; + +/** + * Mark an intermediate task as done (for students) + */ +export const markIntermediateTaskAsDone = (id, parentTaskId) => { + return async (dispatch) => { + try { + // First, fetch the current task data + const currentTask = await httpService.get(ENDPOINTS.INTERMEDIATE_TASK_BY_ID(id)); + + // Update with the completed status while preserving all required fields + const response = await httpService.put(ENDPOINTS.INTERMEDIATE_TASK_BY_ID(id), { + ...currentTask.data, + status: 'completed' + }); + toast.success('Sub-task marked as done'); + return response.data; + } catch (error) { + console.error('Error marking intermediate task as done:', error); + const errorMessage = error.response?.data?.error || error.message || 'Failed to mark sub-task as done'; + toast.error(`Error: ${errorMessage}`); + throw error; + } + }; +}; + diff --git a/src/components/EductionPortal/IntermediateTasks/IntermediateTaskForm.jsx b/src/components/EductionPortal/IntermediateTasks/IntermediateTaskForm.jsx new file mode 100644 index 0000000000..e9510b3226 --- /dev/null +++ b/src/components/EductionPortal/IntermediateTasks/IntermediateTaskForm.jsx @@ -0,0 +1,170 @@ +import React, { useState, useEffect } from 'react'; +import { + Button, + Form, + FormGroup, + Label, + Input, + Modal, + ModalHeader, + ModalBody, + ModalFooter, +} from 'reactstrap'; +import styles from './IntermediateTaskList.module.css'; + +const IntermediateTaskForm = ({ task, onSubmit, onCancel }) => { + const [formData, setFormData] = useState({ + title: '', + description: '', + expectedHours: '', + dueDate: '', + status: 'pending', + }); + + useEffect(() => { + if (task) { + setFormData({ + title: task.title || '', + description: task.description || '', + expectedHours: task.expectedHours || task.expected_hours || '', + dueDate: + task.dueDate || task.due_date + ? new Date(task.dueDate || task.due_date).toISOString().split('T')[0] + : '', + status: task.status || 'pending', + }); + } + }, [task]); + + const handleChange = e => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value, + })); + }; + + const handleSubmit = e => { + e.preventDefault(); + + // Validation + if (!formData.title.trim()) { + alert('Title is required'); + return; + } + + if (formData.expectedHours && (isNaN(formData.expectedHours) || formData.expectedHours < 0)) { + alert('Expected hours must be a positive number'); + return; + } + + // Convert to backend format + const submitData = { + title: formData.title, + description: formData.description, + expectedHours: formData.expectedHours ? parseFloat(formData.expectedHours) : 0, + status: formData.status, + }; + + // Only set logged_hours to 0 when creating a new task (not editing) + if (!task) { + submitData.loggedHours = 0; + } + + // Only include dueDate if it's set + if (formData.dueDate) { + submitData.dueDate = new Date(formData.dueDate).toISOString(); + } + + onSubmit(submitData); + }; + + return ( + + + {task ? 'Edit Intermediate Task' : 'Add Intermediate Task'} + +
+ + + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + + {task && } + + +
+ + + + +
+
+ ); +}; + +export default IntermediateTaskForm; diff --git a/src/components/EductionPortal/IntermediateTasks/IntermediateTaskList.jsx b/src/components/EductionPortal/IntermediateTasks/IntermediateTaskList.jsx new file mode 100644 index 0000000000..32c2d08232 --- /dev/null +++ b/src/components/EductionPortal/IntermediateTasks/IntermediateTaskList.jsx @@ -0,0 +1,376 @@ +import React, { useState, useEffect } from 'react'; +import { Container, Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; +import { useSelector, useDispatch } from 'react-redux'; +import { toast } from 'react-toastify'; +import styles from './IntermediateTaskList.module.css'; +import IntermediateTaskForm from './IntermediateTaskForm'; +import { + fetchIntermediateTasks, + createIntermediateTask, + updateIntermediateTask, + deleteIntermediateTask, + markIntermediateTaskAsDone, +} from '~/actions/intermediateTasks'; +import { fetchStudentTasks } from '~/actions/studentTasks'; + +const IntermediateTaskList = () => { + const dispatch = useDispatch(); + const authUser = useSelector(state => state.auth.user); + const { taskItems: parentTasks } = useSelector(state => state.studentTasks); + + const [expandedTasks, setExpandedTasks] = useState({}); + const [intermediateTasks, setIntermediateTasks] = useState({}); + const [loadingTasks, setLoadingTasks] = useState({}); + const [showForm, setShowForm] = useState(false); + const [editingTask, setEditingTask] = useState(null); + const [selectedParentTaskId, setSelectedParentTaskId] = useState(null); + const [isEducator, setIsEducator] = useState(false); + const [deleteModal, setDeleteModal] = useState({ isOpen: false, task: null, parentTaskId: null }); + + // Check if user is educator (you may need to adjust this based on your role system) + useEffect(() => { + // Check user role - adjust based on your role system + const userRole = authUser?.role; + setIsEducator(userRole === 'Administrator' || userRole === 'Owner' || userRole === 'Manager'); + }, [authUser]); + + // Fetch parent tasks on mount + useEffect(() => { + dispatch(fetchStudentTasks()); + }, [dispatch]); + + // Toggle expand/collapse for a task + const toggleTask = async taskId => { + const isExpanded = expandedTasks[taskId]; + + if (!isExpanded && !intermediateTasks[taskId]) { + // Fetch intermediate tasks when expanding + setLoadingTasks(prev => ({ ...prev, [taskId]: true })); + try { + const tasks = await dispatch(fetchIntermediateTasks(taskId)); + setIntermediateTasks(prev => ({ ...prev, [taskId]: tasks || [] })); + } catch (error) { + console.error('Error fetching intermediate tasks:', error); + } finally { + setLoadingTasks(prev => ({ ...prev, [taskId]: false })); + } + } + + setExpandedTasks(prev => ({ + ...prev, + [taskId]: !isExpanded, + })); + }; + + // Handle add intermediate task + const handleAddTask = parentTaskId => { + setSelectedParentTaskId(parentTaskId); + setEditingTask(null); + setShowForm(true); + }; + + // Handle edit intermediate task + const handleEditTask = (task, parentTaskId) => { + setSelectedParentTaskId(parentTaskId); + // Ensure task has id field for consistency + const taskWithId = { ...task, id: task.id || task._id }; + setEditingTask(taskWithId); + setShowForm(true); + }; + + // Handle delete intermediate task - open confirmation modal + const handleDeleteTask = (task, parentTaskId) => { + setDeleteModal({ isOpen: true, task, parentTaskId }); + }; + + // Confirm deletion + const confirmDelete = async () => { + const { task, parentTaskId } = deleteModal; + try { + const taskId = task.id || task._id; + await dispatch(deleteIntermediateTask(taskId, parentTaskId)); + // Refresh intermediate tasks for the parent + const tasks = await dispatch(fetchIntermediateTasks(parentTaskId)); + setIntermediateTasks(prev => ({ ...prev, [parentTaskId]: tasks || [] })); + setDeleteModal({ isOpen: false, task: null, parentTaskId: null }); + } catch (error) { + // Error is handled in the action + setDeleteModal({ isOpen: false, task: null, parentTaskId: null }); + } + }; + + // Cancel deletion + const cancelDelete = () => { + setDeleteModal({ isOpen: false, task: null, parentTaskId: null }); + }; + + // Handle mark as done (for students) + const handleMarkAsDone = async (task, parentTaskId) => { + try { + const taskId = task.id || task._id; + await dispatch(markIntermediateTaskAsDone(taskId)); + // Refresh intermediate tasks for the parent + const tasks = await dispatch(fetchIntermediateTasks(parentTaskId)); + setIntermediateTasks(prev => ({ ...prev, [parentTaskId]: tasks || [] })); + } catch (error) { + // Error is handled in the action + } + }; + + // Handle form submit + const handleFormSubmit = async formData => { + try { + if (editingTask) { + const taskId = editingTask.id || editingTask._id; + await dispatch( + updateIntermediateTask(taskId, { + ...formData, + parentTaskId: selectedParentTaskId, + }), + ); + } else { + await dispatch( + createIntermediateTask({ + ...formData, + parentTaskId: selectedParentTaskId, + }), + ); + } + + // Refresh intermediate tasks for the parent + const tasks = await dispatch(fetchIntermediateTasks(selectedParentTaskId)); + setIntermediateTasks(prev => ({ ...prev, [selectedParentTaskId]: tasks || [] })); + + setShowForm(false); + setEditingTask(null); + setSelectedParentTaskId(null); + } catch (error) { + // Error is handled in the action + } + }; + + // Calculate progress for a parent task based on intermediate tasks + const calculateProgress = parentTaskId => { + const tasks = intermediateTasks[parentTaskId] || []; + if (tasks.length === 0) return 0; + const completedCount = tasks.filter(t => t.status === 'completed').length; + return Math.round((completedCount / tasks.length) * 100); + }; + + return ( +
+ +
+

Intermediate Tasks

+

Manage sub-tasks for main tasks

+
+ + {showForm && ( + { + setShowForm(false); + setEditingTask(null); + setSelectedParentTaskId(null); + }} + /> + )} + +
+ {!parentTasks || parentTasks.length === 0 ? ( +
+

No tasks found. Tasks will appear here once assigned.

+
+ ) : ( + parentTasks.map(parentTask => { + const isExpanded = expandedTasks[parentTask.id]; + const subTasks = intermediateTasks[parentTask.id] || []; + const isLoading = loadingTasks[parentTask.id]; + const progress = calculateProgress(parentTask.id); + + return ( +
+
+
+

+ {parentTask.course_name || parentTask.title} +

+ {parentTask.subtitle && ( +

{parentTask.subtitle}

+ )} +
+ + Status: {parentTask.status || 'assigned'} + + {subTasks.length > 0 && ( + + Progress: {progress}% ( + {subTasks.filter(t => t.status === 'completed').length}/ + {subTasks.length} completed) + + )} +
+
+
+ {isEducator && ( + + )} + +
+
+ + {isExpanded && ( +
+ {isLoading ? ( +
+
+

Loading sub-tasks...

+
+ ) : subTasks.length === 0 ? ( +
+

+ No sub-tasks found.{' '} + {isEducator && 'Click "Add Intermediate Task" to create one.'} +

+
+ ) : ( +
+ {subTasks.map(subTask => ( +
+
+

{subTask.title}

+ {subTask.description && ( +

{subTask.description}

+ )} +
+ + Expected: {subTask.expected_hours || 0}h + + {subTask.due_date && ( + + Due: {new Date(subTask.due_date).toLocaleDateString()} + + )} + + {subTask.status || 'pending'} + +
+
+
+ {isEducator && ( + <> + + + + )} + {!isEducator && subTask.status !== 'completed' && ( + + )} + {!isEducator && subTask.status === 'completed' && ( + Completed + )} +
+
+ ))} +
+ )} +
+ )} +
+ ); + }) + )} +
+ + {/* Delete Confirmation Modal */} + + Confirm Deletion + +

Are you sure you want to delete this sub-task?

+ {deleteModal.task && ( +

+ {deleteModal.task.title} +

+ )} +

This action cannot be undone.

+
+ + + + +
+
+
+ ); +}; + +export default IntermediateTaskList; diff --git a/src/components/EductionPortal/IntermediateTasks/IntermediateTaskList.module.css b/src/components/EductionPortal/IntermediateTasks/IntermediateTaskList.module.css new file mode 100644 index 0000000000..02de19b1c1 --- /dev/null +++ b/src/components/EductionPortal/IntermediateTasks/IntermediateTaskList.module.css @@ -0,0 +1,366 @@ +.dashboard { + min-height: 100vh; + background-color: #f8f9fa; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; + padding: 2rem 0; +} + +.mainContainer { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.header { + text-align: center; + margin-bottom: 3rem; +} + +.title { + font-size: 2.5rem; + font-weight: 700; + color: #1a1a1a; + margin-bottom: 0.5rem; + line-height: 1.2; +} + +.subtitle { + font-size: 1.125rem; + color: #6b7280; + margin: 0; + font-weight: 400; +} + +.tasksSection { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.taskCard { + background-color: #ffffff; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border: 1px solid #e5e7eb; + transition: all 0.2s ease; +} + +.taskCard:hover { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.taskHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.taskHeaderContent { + flex: 1; +} + +.taskTitle { + font-size: 1.25rem; + font-weight: 600; + color: #1a1a1a; + margin: 0 0 0.5rem 0; + line-height: 1.3; +} + +.taskSubtitle { + font-size: 0.875rem; + color: #6b7280; + margin: 0 0 0.75rem 0; + line-height: 1.4; +} + +.taskMeta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + font-size: 0.875rem; + color: #6b7280; +} + +.taskStatus { + font-weight: 500; + color: #374151; +} + +.progressInfo { + color: #3b82f6; + font-weight: 500; +} + +.taskActions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.addButton { + white-space: nowrap; +} + +.expandButton { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid #d1d5db; + background-color: #ffffff; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + color: #6b7280; +} + +.expandButton:hover { + background-color: #f9fafb; + border-color: #9ca3af; + color: #374151; +} + +.expandButton svg { + transition: transform 0.2s ease; +} + +.expandButton.expanded svg { + transform: rotate(180deg); +} + +.subTasksContainer { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid #e5e7eb; +} + +.loadingState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + color: #6b7280; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid #e5e7eb; + border-top: 4px solid #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.emptySubTasks { + text-align: center; + padding: 2rem; + color: #6b7280; +} + +.subTasksList { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.subTaskItem { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 1rem; + background-color: #f9fafb; + border-radius: 8px; + border: 1px solid #e5e7eb; + transition: all 0.2s ease; +} + +.subTaskItem:hover { + background-color: #f3f4f6; + border-color: #d1d5db; +} + +.subTaskContent { + flex: 1; +} + +.subTaskTitle { + font-size: 1rem; + font-weight: 600; + color: #1a1a1a; + margin: 0 0 0.5rem 0; +} + +.subTaskDescription { + font-size: 0.875rem; + color: #6b7280; + margin: 0 0 0.75rem 0; + line-height: 1.5; +} + +.subTaskMeta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + font-size: 0.875rem; + color: #6b7280; +} + +.subTaskHours { + font-weight: 500; + color: #374151; +} + +.subTaskDueDate { + color: #6b7280; +} + +.subTaskStatus { + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.statuspending { + background-color: #e5e7eb; + color: #374151; +} + +.statusin_progress { + background-color: #dbeafe; + color: #1e40af; +} + +.statuscompleted { + background-color: #dcfce7; + color: #166534; +} + +.subTaskActions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.editButton, +.deleteButton { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid #d1d5db; + background-color: #ffffff; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + color: #6b7280; +} + +.editButton:hover { + background-color: #eff6ff; + border-color: #3b82f6; + color: #3b82f6; +} + +.deleteButton:hover { + background-color: #fef2f2; + border-color: #ef4444; + color: #ef4444; +} + +.markDoneButton { + padding: 0.5rem 1rem; + border: none; + background-color: #3b82f6; + color: #ffffff; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.markDoneButton:hover { + background-color: #2563eb; +} + +.completedBadge { + padding: 0.5rem 1rem; + background-color: #dcfce7; + color: #166534; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; +} + +.emptyState { + text-align: center; + padding: 4rem 2rem; + color: #6b7280; + background-color: #ffffff; + border-radius: 12px; + border: 1px solid #e5e7eb; +} + +/* Form Modal Styles */ +.formModal { + z-index: 1050; +} + +.formRow { + display: flex; + gap: 1rem; +} + +.formGroupHalf { + flex: 1; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .mainContainer { + padding: 0 0.5rem; + } + + .title { + font-size: 2rem; + } + + .taskHeader { + flex-direction: column; + } + + .taskActions { + width: 100%; + justify-content: space-between; + } + + .subTaskItem { + flex-direction: column; + } + + .subTaskActions { + width: 100%; + justify-content: flex-end; + } + + .formRow { + flex-direction: column; + } +} + diff --git a/src/components/EductionPortal/IntermediateTasks/index.js b/src/components/EductionPortal/IntermediateTasks/index.js new file mode 100644 index 0000000000..476d30e8ff --- /dev/null +++ b/src/components/EductionPortal/IntermediateTasks/index.js @@ -0,0 +1,2 @@ +export { default } from './IntermediateTaskList'; +export { default as IntermediateTaskForm } from './IntermediateTaskForm'; diff --git a/src/components/EductionPortal/StudentDashboard/IntermediateTasksList.jsx b/src/components/EductionPortal/StudentDashboard/IntermediateTasksList.jsx new file mode 100644 index 0000000000..29d923f853 --- /dev/null +++ b/src/components/EductionPortal/StudentDashboard/IntermediateTasksList.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { canMarkIntermediateTaskAsDone, getMarkIntermediateAsDoneTooltip } from './taskUtils'; + +const IntermediateTasksList = ({ intermediateTasks, styles, onMarkIntermediateAsDone }) => { + if (intermediateTasks.length === 0) { + return

No sub-tasks available

; + } + + return intermediateTasks.map(subTask => { + const subTaskProgress = subTask.status === 'completed' ? 100 : 0; + const canMarkIntermediateDone = canMarkIntermediateTaskAsDone(subTask); + const intermediateTooltip = getMarkIntermediateAsDoneTooltip(subTask); + + return ( +
+
+

{subTask.title}

+ {subTask.description && ( +

{subTask.description}

+ )} + {/* Progress Bar for Sub-task */} +
+
+
+
+ {subTaskProgress}% +
+
+ + {subTask.logged_hours || 0} / {subTask.expected_hours || 0}h + + {subTask.due_date && ( + + Due: {new Date(subTask.due_date).toLocaleDateString()} + + )} + + {subTask.status || 'pending'} + +
+
+ {subTask.status !== 'completed' && ( + + )} +
+ ); + }); +}; + +export default IntermediateTasksList; diff --git a/src/components/EductionPortal/StudentDashboard/MarkAsDoneButton.jsx b/src/components/EductionPortal/StudentDashboard/MarkAsDoneButton.jsx new file mode 100644 index 0000000000..5e95c72269 --- /dev/null +++ b/src/components/EductionPortal/StudentDashboard/MarkAsDoneButton.jsx @@ -0,0 +1,62 @@ +import React from 'react'; + +const MarkAsDoneButton = ({ + task, + intermediateTasks, + canMarkDone, + markAsDoneTooltip, + onMarkAsDone, + styles, + iconSize = '16', +}) => { + const handleMarkAsDone = () => { + if (canMarkDone) { + onMarkAsDone(task.id); + } + }; + + // Show completed only if task is completed AND (no subtasks OR all subtasks are completed) + if ( + task.is_completed && + (intermediateTasks.length === 0 || intermediateTasks.every(t => t.status === 'completed')) + ) { + return ( + + ); + } + + return ( + + ); +}; + +export default MarkAsDoneButton; diff --git a/src/components/EductionPortal/StudentDashboard/StudentDashboard.jsx b/src/components/EductionPortal/StudentDashboard/StudentDashboard.jsx index 52e43d900f..c4c3a14b2a 100644 --- a/src/components/EductionPortal/StudentDashboard/StudentDashboard.jsx +++ b/src/components/EductionPortal/StudentDashboard/StudentDashboard.jsx @@ -8,6 +8,7 @@ import TaskListView from './TaskListView'; import NavigationBar from './NavigationBar'; import SummaryCards from './SummaryCards'; import { fetchStudentTasks, markStudentTaskAsDone } from '~/actions/studentTasks'; +import { fetchIntermediateTasks, markIntermediateTaskAsDone } from '~/actions/intermediateTasks'; const StudentDashboard = () => { const [viewMode, setViewMode] = useState('card'); // 'card' or 'list' @@ -17,6 +18,8 @@ const StudentDashboard = () => { activeCourses: 0, logEntries: 0, }); + const [intermediateTasks, setIntermediateTasks] = useState({}); + const [expandedTasks, setExpandedTasks] = useState({}); const dispatch = useDispatch(); const authUser = useSelector(state => state.auth.user); @@ -27,6 +30,31 @@ const StudentDashboard = () => { dispatch(fetchStudentTasks()); }, [dispatch]); + // Fetch intermediate tasks for all parent tasks + useEffect(() => { + const fetchAllIntermediateTasks = async () => { + if (tasks && tasks.length > 0) { + const intermediateTasksData = {}; + + // Fetch intermediate tasks for each parent task + for (const task of tasks) { + try { + const subTasks = await dispatch(fetchIntermediateTasks(task.id)); + if (subTasks && subTasks.length > 0) { + intermediateTasksData[task.id] = subTasks; + } + } catch (error) { + console.error(`Error fetching intermediate tasks for task ${task.id}:`, error); + } + } + + setIntermediateTasks(intermediateTasksData); + } + }; + + fetchAllIntermediateTasks(); + }, [tasks, dispatch]); + // Calculate summary data when tasks change useEffect(() => { if (tasks && tasks.length > 0) { @@ -72,6 +100,29 @@ const StudentDashboard = () => { dispatch(markStudentTaskAsDone(taskId)); }; + // Handle mark intermediate task as done + const handleMarkIntermediateAsDone = async (intermediateTaskId, parentTaskId) => { + try { + await dispatch(markIntermediateTaskAsDone(intermediateTaskId)); + // Refresh intermediate tasks for this parent + const tasks = await dispatch(fetchIntermediateTasks(parentTaskId)); + setIntermediateTasks(prev => ({ ...prev, [parentTaskId]: tasks || [] })); + } catch (error) { + // Error is handled in the action + } + }; + + // Toggle expand/collapse intermediate tasks + const toggleIntermediateTasks = async taskId => { + const isExpanded = expandedTasks[taskId]; + + // Just toggle the expanded state (tasks are already loaded) + setExpandedTasks(prev => ({ + ...prev, + [taskId]: !isExpanded, + })); + }; + // Toggle view mode const toggleViewMode = () => { setViewMode(prev => (prev === 'card' ? 'list' : 'card')); @@ -161,9 +212,23 @@ const StudentDashboard = () => { {/* Task Views */} {viewMode === 'card' ? ( - + ) : ( - + )}
diff --git a/src/components/EductionPortal/StudentDashboard/TaskCard.jsx b/src/components/EductionPortal/StudentDashboard/TaskCard.jsx index 9e359be1df..e2b90b1ff4 100644 --- a/src/components/EductionPortal/StudentDashboard/TaskCard.jsx +++ b/src/components/EductionPortal/StudentDashboard/TaskCard.jsx @@ -1,8 +1,17 @@ import React from 'react'; import styles from './TaskCard.module.css'; import { useTaskLogic } from './useTaskLogic'; +import MarkAsDoneButton from './MarkAsDoneButton'; +import IntermediateTasksList from './IntermediateTasksList'; -const TaskCard = ({ task, onMarkAsDone }) => { +const TaskCard = ({ + task, + onMarkAsDone, + intermediateTasks = [], + isExpanded = false, + onToggleIntermediateTasks, + onMarkIntermediateAsDone, +}) => { const { progressPercentage, canMarkDone, @@ -10,11 +19,17 @@ const TaskCard = ({ task, onMarkAsDone }) => { markAsDoneTooltip, formattedTimeAndDate, progressText, - } = useTaskLogic(task, styles); + } = useTaskLogic(task, styles, intermediateTasks); - const handleMarkAsDone = () => { - if (canMarkDone) { - onMarkAsDone(task.id); + const handleToggleIntermediateTasks = () => { + if (onToggleIntermediateTasks) { + onToggleIntermediateTasks(task.id); + } + }; + + const handleMarkIntermediateAsDone = intermediateTaskId => { + if (onMarkIntermediateAsDone) { + onMarkIntermediateAsDone(intermediateTaskId, task.id); } }; @@ -75,26 +90,23 @@ const TaskCard = ({ task, onMarkAsDone }) => { - {task.is_completed ? ( - - ) : ( + + + + {/* Intermediate Tasks Section */} + {onToggleIntermediateTasks && ( +
- )} -
+ + {isExpanded && ( +
+ +
+ )} + + )} ); diff --git a/src/components/EductionPortal/StudentDashboard/TaskCard.module.css b/src/components/EductionPortal/StudentDashboard/TaskCard.module.css index ea08706134..2b1a43b061 100644 --- a/src/components/EductionPortal/StudentDashboard/TaskCard.module.css +++ b/src/components/EductionPortal/StudentDashboard/TaskCard.module.css @@ -180,19 +180,205 @@ cursor: default; } +/* Intermediate Tasks Styles */ +.intermediateTasksSection { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #e5e7eb; +} + +.toggleIntermediateButton { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: transparent; + border: none; + color: #3b82f6; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + width: 100%; + justify-content: center; +} + +.toggleIntermediateButton:hover { + color: #2563eb; + background-color: #eff6ff; + border-radius: 6px; +} + +.expandedIcon { + transform: rotate(180deg); +} + +.intermediateTasksList { + margin-top: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.noIntermediateTasks { + text-align: center; + color: #9ca3af; + font-size: 0.875rem; + padding: 1rem; + margin: 0; +} + +.intermediateTaskItem { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 0.75rem; + background-color: #f9fafb; + border-radius: 8px; + border: 1px solid #e5e7eb; + gap: 0.75rem; + transition: all 0.2s ease; +} + +.intermediateTaskItem:hover { + background-color: #f3f4f6; +} + +.intermediateTaskContent { + flex: 1; +} + +.intermediateTaskTitle { + font-size: 0.9rem; + font-weight: 600; + color: #1f2937; + margin: 0 0 0.25rem 0; +} + +.intermediateTaskDescription { + font-size: 0.8rem; + color: #6b7280; + margin: 0 0 0.5rem 0; + line-height: 1.4; +} + +.subTaskProgressSection { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.subTaskProgressBar { + flex: 1; + height: 6px; + background-color: #e5e7eb; + border-radius: 3px; + overflow: hidden; +} + +.subTaskProgressFill { + height: 100%; + background-color: #10b981; + border-radius: 3px; + transition: width 0.3s ease; +} + +.subTaskProgressText { + font-size: 0.75rem; + font-weight: 600; + color: #6b7280; + min-width: 35px; + text-align: right; +} + +.intermediateTaskMeta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + font-size: 0.75rem; + color: #6b7280; +} + +.intermediateTaskHours { + font-weight: 500; +} + +.intermediateTaskStatus { + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-weight: 500; + text-transform: capitalize; + background-color: #e5e7eb; + color: #374151; +} + +.intermediateTaskStatus.statuscompleted { + background-color: #dcfce7; + color: #166534; +} + +.intermediateTaskStatus.statuspending { + background-color: #fef3c7; + color: #92400e; +} + +.markIntermediateDoneButton { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid #d1d5db; + background-color: #ffffff; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + color: #10b981; + flex-shrink: 0; +} + +.markIntermediateDoneButton:hover:not(.disabled) { + background-color: #10b981; + border-color: #10b981; + color: #ffffff; +} + +.markIntermediateDoneButton.disabled { + background-color: #f3f4f6; + border-color: #e5e7eb; + color: #d1d5db; + cursor: not-allowed; + opacity: 0.6; +} + +.markIntermediateDoneButton.disabled:hover { + background-color: #f3f4f6; + border-color: #e5e7eb; + color: #d1d5db; +} + /* Responsive Design */ @media (max-width: 768px) { .taskCard { padding: 1.25rem; min-height: 260px; } - + .actionButtons { flex-direction: column; gap: 0.5rem; } - + .clockButton { width: 100%; } + + .intermediateTaskItem { + flex-direction: column; + } + + .markIntermediateDoneButton { + width: 100%; + } } diff --git a/src/components/EductionPortal/StudentDashboard/TaskCardView.jsx b/src/components/EductionPortal/StudentDashboard/TaskCardView.jsx index 57b2f4ee70..6638d63fe4 100644 --- a/src/components/EductionPortal/StudentDashboard/TaskCardView.jsx +++ b/src/components/EductionPortal/StudentDashboard/TaskCardView.jsx @@ -2,7 +2,14 @@ import React from 'react'; import styles from './TaskCardView.module.css'; import TaskCard from './TaskCard'; -const TaskCardView = ({ tasks, onMarkAsDone }) => { +const TaskCardView = ({ + tasks, + onMarkAsDone, + intermediateTasks, + expandedTasks, + onToggleIntermediateTasks, + onMarkIntermediateAsDone, +}) => { if (!tasks || tasks.length === 0) { return (
@@ -14,7 +21,15 @@ const TaskCardView = ({ tasks, onMarkAsDone }) => { return (
{tasks.map(task => ( - + ))}
); diff --git a/src/components/EductionPortal/StudentDashboard/TaskListItem.jsx b/src/components/EductionPortal/StudentDashboard/TaskListItem.jsx index 90769725b6..4347ab6e5b 100644 --- a/src/components/EductionPortal/StudentDashboard/TaskListItem.jsx +++ b/src/components/EductionPortal/StudentDashboard/TaskListItem.jsx @@ -1,8 +1,17 @@ import React from 'react'; import styles from './TaskListItem.module.css'; import { useTaskLogic } from './useTaskLogic'; +import MarkAsDoneButton from './MarkAsDoneButton'; +import IntermediateTasksList from './IntermediateTasksList'; -const TaskListItem = ({ task, onMarkAsDone }) => { +const TaskListItem = ({ + task, + onMarkAsDone, + intermediateTasks = [], + isExpanded = false, + onToggleIntermediateTasks, + onMarkIntermediateAsDone, +}) => { const { progressPercentage, canMarkDone, @@ -10,11 +19,17 @@ const TaskListItem = ({ task, onMarkAsDone }) => { markAsDoneTooltip, formattedTimeAndDate, progressText, - } = useTaskLogic(task, styles); + } = useTaskLogic(task, styles, intermediateTasks); - const handleMarkAsDone = () => { - if (canMarkDone) { - onMarkAsDone(task.id); + const handleToggleIntermediateTasks = () => { + if (onToggleIntermediateTasks) { + onToggleIntermediateTasks(task.id); + } + }; + + const handleMarkIntermediateAsDone = intermediateTaskId => { + if (onMarkIntermediateAsDone) { + onMarkIntermediateAsDone(intermediateTaskId, task.id); } }; @@ -65,25 +80,22 @@ const TaskListItem = ({ task, onMarkAsDone }) => { - {task.is_completed ? ( - - ) : ( + + + {/* Toggle Intermediate Tasks */} + {onToggleIntermediateTasks && ( )}
+ + {/* Intermediate Tasks */} + {isExpanded && onToggleIntermediateTasks && ( +
+
+ +
+
+ )} ); }; diff --git a/src/components/EductionPortal/StudentDashboard/TaskListItem.module.css b/src/components/EductionPortal/StudentDashboard/TaskListItem.module.css index c28a7ba3ab..b51b12d4a3 100644 --- a/src/components/EductionPortal/StudentDashboard/TaskListItem.module.css +++ b/src/components/EductionPortal/StudentDashboard/TaskListItem.module.css @@ -5,7 +5,8 @@ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); border: 1px solid #e5e7eb; display: flex; - align-items: center; + flex-wrap: wrap; + align-items: flex-start; gap: 1.5rem; transition: all 0.2s ease; } @@ -170,6 +171,181 @@ cursor: default; } +.toggleIntermediateButton { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid #d1d5db; + background-color: #ffffff; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s ease; + color: #3b82f6; +} + +.toggleIntermediateButton:hover { + background-color: #eff6ff; + border-color: #3b82f6; +} + +.expandedIcon { + transform: rotate(180deg); +} + +/* Intermediate Tasks Wrapper */ +.intermediateTasksWrapper { + width: 100%; + margin-top: 0.5rem; + padding-top: 1rem; + border-top: 1px solid #e5e7eb; +} + +.intermediateTasksList { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.noIntermediateTasks { + text-align: center; + color: #9ca3af; + font-size: 0.875rem; + padding: 1rem; + margin: 0; +} + +.intermediateTaskItem { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 0.75rem; + background-color: #f9fafb; + border-radius: 8px; + border: 1px solid #e5e7eb; + gap: 0.75rem; + transition: all 0.2s ease; +} + +.intermediateTaskItem:hover { + background-color: #f3f4f6; +} + +.intermediateTaskContent { + flex: 1; +} + +.intermediateTaskTitle { + font-size: 0.9rem; + font-weight: 600; + color: #1f2937; + margin: 0 0 0.25rem 0; +} + +.intermediateTaskDescription { + font-size: 0.8rem; + color: #6b7280; + margin: 0 0 0.5rem 0; + line-height: 1.4; +} + +.subTaskProgressSection { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.subTaskProgressBar { + flex: 1; + height: 6px; + background-color: #e5e7eb; + border-radius: 3px; + overflow: hidden; +} + +.subTaskProgressFill { + height: 100%; + background-color: #10b981; + border-radius: 3px; + transition: width 0.3s ease; +} + +.subTaskProgressText { + font-size: 0.75rem; + font-weight: 600; + color: #6b7280; + min-width: 35px; + text-align: right; +} + +.intermediateTaskMeta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + font-size: 0.75rem; + color: #6b7280; +} + +.intermediateTaskHours { + font-weight: 500; +} + +.intermediateTaskStatus { + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-weight: 500; + text-transform: capitalize; + background-color: #e5e7eb; + color: #374151; +} + +.intermediateTaskStatus.statuscompleted { + background-color: #dcfce7; + color: #166534; +} + +.intermediateTaskStatus.statuspending { + background-color: #fef3c7; + color: #92400e; +} + +.markIntermediateDoneButton { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid #d1d5db; + background-color: #ffffff; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + color: #10b981; + flex-shrink: 0; +} + +.markIntermediateDoneButton:hover:not(.disabled) { + background-color: #10b981; + border-color: #10b981; + color: #ffffff; +} + +.markIntermediateDoneButton.disabled { + background-color: #f3f4f6; + border-color: #e5e7eb; + color: #d1d5db; + cursor: not-allowed; + opacity: 0.6; +} + +.markIntermediateDoneButton.disabled:hover { + background-color: #f3f4f6; + border-color: #e5e7eb; + color: #d1d5db; +} + /* Responsive Design */ @media (max-width: 768px) { .taskListItem { @@ -177,9 +353,17 @@ align-items: flex-start; gap: 1rem; } - + .actionIcons { flex-direction: row; align-self: flex-end; } + + .intermediateTaskItem { + flex-direction: column; + } + + .markIntermediateDoneButton { + width: 100%; + } } diff --git a/src/components/EductionPortal/StudentDashboard/TaskListView.jsx b/src/components/EductionPortal/StudentDashboard/TaskListView.jsx index 7dddb5ad93..a0de5d30a8 100644 --- a/src/components/EductionPortal/StudentDashboard/TaskListView.jsx +++ b/src/components/EductionPortal/StudentDashboard/TaskListView.jsx @@ -2,7 +2,14 @@ import React from 'react'; import styles from './TaskListView.module.css'; import TaskListItem from './TaskListItem'; -const TaskListView = ({ tasks, onMarkAsDone }) => { +const TaskListView = ({ + tasks, + onMarkAsDone, + intermediateTasks, + expandedTasks, + onToggleIntermediateTasks, + onMarkIntermediateAsDone, +}) => { if (!tasks || tasks.length === 0) { return (
@@ -14,7 +21,15 @@ const TaskListView = ({ tasks, onMarkAsDone }) => { return (
{tasks.map(task => ( - + ))}
); diff --git a/src/components/EductionPortal/StudentDashboard/taskUtils.js b/src/components/EductionPortal/StudentDashboard/taskUtils.js index 85e55374e0..f848846cad 100644 --- a/src/components/EductionPortal/StudentDashboard/taskUtils.js +++ b/src/components/EductionPortal/StudentDashboard/taskUtils.js @@ -3,11 +3,21 @@ */ /** - * Calculate progress percentage for a task + * Calculate progress percentage for a task based on intermediate tasks * @param {Object} task - The task object + * @param {Array} intermediateTasks - Array of intermediate tasks (optional) * @returns {number} Progress percentage (0-100) */ -export const calculateProgressPercentage = task => { +export const calculateProgressPercentage = (task, intermediateTasks = []) => { + // If there are intermediate tasks, calculate progress based on number of completed tasks + if (intermediateTasks && intermediateTasks.length > 0) { + const totalTasks = intermediateTasks.length; + const completedTasks = intermediateTasks.filter(t => t.status === 'completed').length; + + return totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; + } + + // Otherwise, use logged hours vs suggested hours return task.suggested_total_hours > 0 ? Math.round((task.logged_hours / task.suggested_total_hours) * 100) : 0; @@ -16,18 +26,22 @@ export const calculateProgressPercentage = task => { /** * Determine if a task can be marked as done * @param {Object} task - The task object + * @param {Array} intermediateTasks - Array of intermediate tasks (optional) * @returns {boolean} Whether the task can be marked as done */ -export const canMarkTaskAsDone = task => { +export const canMarkTaskAsDone = (task, intermediateTasks = []) => { if (task.is_completed) return false; - // Only read tasks can be marked as complete manually - // Must have logged hours >= suggested hours + // If there are intermediate tasks, ONLY check if all sub-tasks are completed + if (intermediateTasks && intermediateTasks.length > 0) { + return intermediateTasks.every(t => t.status === 'completed'); + } + + // Otherwise, use the original logic for tasks without sub-tasks if (task.task_type === 'read') { return task.logged_hours >= task.suggested_total_hours; } - // For other task types, cannot be marked done manually return false; }; @@ -35,10 +49,19 @@ export const canMarkTaskAsDone = task => { * Get status badge information for a task * @param {Object} task - The task object * @param {Object} styles - CSS module styles object + * @param {Array} intermediateTasks - Array of intermediate tasks (optional) * @returns {Object} Status badge info with text and className */ -export const getTaskStatusBadge = (task, styles) => { - if (task.status === 'completed' || task.status === 'graded') { +export const getTaskStatusBadge = (task, styles, intermediateTasks = []) => { + // Check if all sub-tasks are completed + if (intermediateTasks && intermediateTasks.length > 0) { + const allSubTasksCompleted = intermediateTasks.every(t => t.status === 'completed'); + // Only show as completed if task is marked completed AND all subtasks are done + if (allSubTasksCompleted && (task.status === 'completed' || task.status === 'graded')) { + return { text: 'Completed', className: styles.completedBadge }; + } + } else if (task.status === 'completed' || task.status === 'graded') { + // No subtasks, check task status only return { text: 'Completed', className: styles.completedBadge }; } @@ -64,13 +87,30 @@ export const getTaskStatusBadge = (task, styles) => { /** * Get tooltip text for mark as done button * @param {Object} task - The task object + * @param {Array} intermediateTasks - Array of intermediate tasks (optional) * @returns {string} Tooltip text */ -export const getMarkAsDoneTooltip = task => { +export const getMarkAsDoneTooltip = (task, intermediateTasks = []) => { if (task.is_completed) { return 'Task is already completed'; } + // If there are intermediate tasks, ONLY consider sub-task completion + if (intermediateTasks && intermediateTasks.length > 0) { + const completedCount = intermediateTasks.filter(t => t.status === 'completed').length; + const totalCount = intermediateTasks.length; + + if (completedCount === totalCount) { + return 'Mark as Done - All sub-tasks completed'; + } + + const remaining = totalCount - completedCount; + return `Cannot mark as done: Complete ${remaining} more sub-task${ + remaining !== 1 ? 's' : '' + } (${completedCount}/${totalCount} done)`; + } + + // Otherwise, use original logic for tasks without sub-tasks if (task.task_type !== 'read') { return `Cannot mark as done: Only read tasks can be completed manually (Current type: ${task.task_type})`; } @@ -112,9 +152,16 @@ export const formatDate = dateString => { /** * Get formatted time and date string for display * @param {Object} task - The task object + * @param {Array} intermediateTasks - Array of intermediate tasks (optional) * @returns {string} Formatted time and date string */ -export const getFormattedTimeAndDate = task => { +export const getFormattedTimeAndDate = (task, intermediateTasks = []) => { + // If there are intermediate tasks, just show the date (no hours) + if (intermediateTasks && intermediateTasks.length > 0) { + return formatDate(task.last_logged_date || task.created_at); + } + + // Otherwise, show logged hours return `${formatTime(task.logged_hours || 0)} • ${formatDate( task.last_logged_date || task.created_at, )}`; @@ -123,11 +170,58 @@ export const getFormattedTimeAndDate = task => { /** * Get progress text for display * @param {Object} task - The task object + * @param {Array} intermediateTasks - Array of intermediate tasks (optional) * @returns {string} Progress text */ -export const getProgressText = task => { - const progressPercentage = calculateProgressPercentage(task); +export const getProgressText = (task, intermediateTasks = []) => { + const progressPercentage = calculateProgressPercentage(task, intermediateTasks); + + // If there are intermediate tasks, show number of completed tasks + if (intermediateTasks && intermediateTasks.length > 0) { + const totalTasks = intermediateTasks.length; + const completedTasks = intermediateTasks.filter(t => t.status === 'completed').length; + + return `Progress: ${completedTasks} / ${totalTasks} tasks (${progressPercentage}%)`; + } + + // Otherwise, show logged hours return `Progress: ${task.logged_hours || 0} / ${ task.suggested_total_hours } hrs (${progressPercentage}%)`; }; + +/** + * Determine if an intermediate task can be marked as done + * @param {Object} intermediateTask - The intermediate task object + * @returns {boolean} Whether the intermediate task can be marked as done + */ +export const canMarkIntermediateTaskAsDone = intermediateTask => { + // Already completed + if (intermediateTask.status === 'completed') return false; + + // Check if logged hours meet or exceed expected hours + const loggedHours = intermediateTask.logged_hours || 0; + const expectedHours = intermediateTask.expected_hours || 0; + + return loggedHours >= expectedHours; +}; + +/** + * Get tooltip text for mark intermediate task as done button + * @param {Object} intermediateTask - The intermediate task object + * @returns {string} Tooltip text + */ +export const getMarkIntermediateAsDoneTooltip = intermediateTask => { + if (intermediateTask.status === 'completed') { + return 'Sub-task is already completed'; + } + + const loggedHours = intermediateTask.logged_hours || 0; + const expectedHours = intermediateTask.expected_hours || 0; + + if (loggedHours >= expectedHours) { + return 'Mark as Done - Hour requirement met'; + } + + return `Cannot mark as done: Insufficient hours logged (${loggedHours}/${expectedHours} hrs required)`; +}; diff --git a/src/components/EductionPortal/StudentDashboard/useTaskLogic.js b/src/components/EductionPortal/StudentDashboard/useTaskLogic.js index 5481acccce..715ab21b58 100644 --- a/src/components/EductionPortal/StudentDashboard/useTaskLogic.js +++ b/src/components/EductionPortal/StudentDashboard/useTaskLogic.js @@ -12,22 +12,25 @@ import { * Custom hook for task-related logic * @param {Object} task - The task object * @param {Object} styles - CSS module styles object + * @param {Array} intermediateTasks - Array of intermediate tasks (optional) * @returns {Object} Task logic and computed values */ -export const useTaskLogic = (task, styles) => { - const progressPercentage = useMemo(() => calculateProgressPercentage(task), [ +export const useTaskLogic = (task, styles, intermediateTasks = []) => { + const progressPercentage = useMemo(() => calculateProgressPercentage(task, intermediateTasks), [ task.logged_hours, task.suggested_total_hours, + intermediateTasks, ]); - const canMarkDone = useMemo(() => canMarkTaskAsDone(task), [ + const canMarkDone = useMemo(() => canMarkTaskAsDone(task, intermediateTasks), [ task.is_completed, task.task_type, task.logged_hours, task.suggested_total_hours, + intermediateTasks, ]); - const statusBadge = useMemo(() => getTaskStatusBadge(task, styles), [ + const statusBadge = useMemo(() => getTaskStatusBadge(task, styles, intermediateTasks), [ task.status, task.has_upload, task.task_type, @@ -35,24 +38,28 @@ export const useTaskLogic = (task, styles) => { task.suggested_total_hours, task.has_comments, task.feedback, + intermediateTasks, ]); - const markAsDoneTooltip = useMemo(() => getMarkAsDoneTooltip(task), [ + const markAsDoneTooltip = useMemo(() => getMarkAsDoneTooltip(task, intermediateTasks), [ task.is_completed, task.task_type, task.logged_hours, task.suggested_total_hours, + intermediateTasks, ]); - const formattedTimeAndDate = useMemo(() => getFormattedTimeAndDate(task), [ + const formattedTimeAndDate = useMemo(() => getFormattedTimeAndDate(task, intermediateTasks), [ task.logged_hours, task.last_logged_date, task.created_at, + intermediateTasks, ]); - const progressText = useMemo(() => getProgressText(task), [ + const progressText = useMemo(() => getProgressText(task, intermediateTasks), [ task.logged_hours, task.suggested_total_hours, + intermediateTasks, ]); return { diff --git a/src/routes.jsx b/src/routes.jsx index 39f880053a..dcf45a77ea 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -174,6 +174,7 @@ import ProtectedRoute from './components/common/ProtectedRoute'; import { UserRole } from './utils/enums'; import WriteTaskUpload from './components/EductionPortal/Tasks/WriteTaskUpload'; +import IntermediateTaskList from './components/EductionPortal/IntermediateTasks/IntermediateTaskList'; // Social Architecture @@ -777,6 +778,11 @@ export default ( + `${APIEndpoint}/tasknotification/${taskNotificationId}`, - -POPULARITY: (range, roles, start, end) => { - let url = `${APIEndpoint}/popularity?`; - if (range) url += `range=${range}&`; - if (roles && roles.length > 0) { - url += `roles=${encodeURIComponent(JSON.stringify(roles))}&`; - } - if (start) url += `start=${encodeURIComponent(start)}&`; - if (end) url += `end=${encodeURIComponent(end)}&`; - return url.slice(0, -1); -}, -POPULARITY_ROLES: `${APIEndpoint}/popularity/roles`, + + POPULARITY: (range, roles, start, end) => { + let url = `${APIEndpoint}/popularity?`; + if (range) url += `range=${range}&`; + if (roles && roles.length > 0) { + url += `roles=${encodeURIComponent(JSON.stringify(roles))}&`; + } + if (start) url += `start=${encodeURIComponent(start)}&`; + if (end) url += `end=${encodeURIComponent(end)}&`; + return url.slice(0, -1); + }, + POPULARITY_ROLES: `${APIEndpoint}/popularity/roles`, // titles endpoints @@ -183,6 +183,11 @@ POPULARITY_ROLES: `${APIEndpoint}/popularity/roles`, STUDENT_TASKS: () => `${APIEndpoint}/student/tasks`, STUDENT_TASK_MARK_DONE: taskId => `${APIEndpoint}/student/tasks/${taskId}/mark-done`, + // Intermediate Tasks (Education Portal) + INTERMEDIATE_TASKS: () => `${APIEndpoint}/educator/intermediate-tasks`, + INTERMEDIATE_TASK_BY_ID: id => `${APIEndpoint}/educator/intermediate-tasks/${id}`, + INTERMEDIATE_TASKS_BY_PARENT: taskId => `${APIEndpoint}/educator/tasks/${taskId}/intermediate`, + TIMER_SERVICE: new URL('/timer-service', APIEndpoint.replace('http', 'ws')).toString(), TIMEZONE_LOCATION: location => `${APIEndpoint}/timezone/${location}`,