From ebbb8462ff649c6abeef983cf75e8b97ddc728e5 Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Tue, 2 Dec 2025 23:52:50 -0800 Subject: [PATCH 1/2] Confirm educator task submissions UI with late submission tagging --- .../TaskSubmissions/SubmissionCard.jsx | 175 +++++++ .../TaskSubmissions/SubmissionCard.module.css | 343 ++++++++++++++ .../TaskSubmissions/TaskSubmissionsPage.jsx | 249 ++++++++++ .../TaskSubmissionsPage.module.css | 431 ++++++++++++++++++ .../Educators/TaskSubmissions/index.js | 1 + src/routes.jsx | 6 + 6 files changed, 1205 insertions(+) create mode 100644 src/components/EductionPortal/Educators/TaskSubmissions/SubmissionCard.jsx create mode 100644 src/components/EductionPortal/Educators/TaskSubmissions/SubmissionCard.module.css create mode 100644 src/components/EductionPortal/Educators/TaskSubmissions/TaskSubmissionsPage.jsx create mode 100644 src/components/EductionPortal/Educators/TaskSubmissions/TaskSubmissionsPage.module.css create mode 100644 src/components/EductionPortal/Educators/TaskSubmissions/index.js diff --git a/src/components/EductionPortal/Educators/TaskSubmissions/SubmissionCard.jsx b/src/components/EductionPortal/Educators/TaskSubmissions/SubmissionCard.jsx new file mode 100644 index 0000000000..23670a0cea --- /dev/null +++ b/src/components/EductionPortal/Educators/TaskSubmissions/SubmissionCard.jsx @@ -0,0 +1,175 @@ +import React, { useState, useMemo } from 'react'; +import { FiInfo, FiCheck } from 'react-icons/fi'; +import styles from './SubmissionCard.module.css'; + +const getInitials = (name = '') => { + const parts = name.trim().split(' '); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); + } + return parts[0] ? parts[0].substring(0, 2).toUpperCase() : '?'; +}; + +const getAvatarColor = name => { + const colors = [ + { bg: '#8B5CF6', shadow: 'rgba(139, 92, 246, 0.3)' }, + { bg: '#3B82F6', shadow: 'rgba(59, 130, 246, 0.3)' }, + { bg: '#EC4899', shadow: 'rgba(236, 72, 153, 0.3)' }, + { bg: '#10B981', shadow: 'rgba(16, 185, 129, 0.3)' }, + { bg: '#F59E0B', shadow: 'rgba(245, 158, 11, 0.3)' }, + { bg: '#EF4444', shadow: 'rgba(239, 68, 68, 0.3)' }, + ]; + const index = (name?.charCodeAt(0) || 0) % colors.length; + return colors[index]; +}; + +const getTaskTypeLabel = type => { + const labels = { + read: 'Read Task', + write: 'Write Task', + quiz: 'Quiz Task', + practice: 'Practice Task', + project: 'Project Task', + }; + return labels[type] || 'Task'; +}; + +const SubmissionCard = ({ submission }) => { + const [showTooltip, setShowTooltip] = useState(false); + const { studentName, taskType, status, submittedAt, dueAt, grade } = submission; + + const statusDetails = useMemo(() => { + const isLate = submittedAt && dueAt && new Date(submittedAt) > new Date(dueAt); + + if (status === 'Graded') { + return { + showBadge: true, + badgeText: 'Graded', + badgeClass: styles.gradedBadge, + cardClass: styles.gradedCard, + icon: , + }; + } + + if (isLate) { + return { + showLateSection: true, + cardClass: styles.lateCard, + showInfoIcon: true, + tooltipContent: ( + <> + Late Submission + + Submitted{' '} + {new Date(submittedAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })}{' '} + at{' '} + {new Date(submittedAt).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + })} + + + ), + }; + } + + if (status === 'Pending Review') { + return { + showInfoIcon: true, + tooltipContent: Pending Review, + }; + } + + return {}; + }, [status, submittedAt, dueAt]); + + const avatarColor = getAvatarColor(studentName); + + return ( +
+
+
+
+ {getInitials(studentName)} +
+
+ + {studentName} + + {getTaskTypeLabel(taskType)} +
+
+ +
+ {statusDetails.showBadge && ( +
+ {statusDetails.icon} + {statusDetails.badgeText} +
+ )} + + {statusDetails.showInfoIcon && ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > +
+ +
+ + {showTooltip && ( +
+
{statusDetails.tooltipContent}
+
+
+ )} +
+ )} +
+
+ +
+ + {status === 'Graded' && grade && grade !== 'pending' && ( +
+ Grade + {grade} +
+ )} + + {statusDetails.showLateSection && ( +
+ LATE SUBMISSION + + Submitted{' '} + {new Date(submittedAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })}{' '} + •{' '} + {new Date(submittedAt).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + })} + +
+ )} +
+ ); +}; + +export default SubmissionCard; diff --git a/src/components/EductionPortal/Educators/TaskSubmissions/SubmissionCard.module.css b/src/components/EductionPortal/Educators/TaskSubmissions/SubmissionCard.module.css new file mode 100644 index 0000000000..df2365aefb --- /dev/null +++ b/src/components/EductionPortal/Educators/TaskSubmissions/SubmissionCard.module.css @@ -0,0 +1,343 @@ +.card { + background: white; + border: 2px solid #E5E7EB; + border-radius: 12px; + padding: 18px; + position: relative; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + height: 150px; + display: flex; + flex-direction: column; + overflow: visible; +} + +.card:hover { + border-color: #D1D5DB; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); + transform: translateY(-3px); +} + +.lateCard { + border-color: #EF4444; + border-width: 2px; + background: linear-gradient(to bottom, white 0%, #FEF2F2 100%); +} + +.lateCard:hover { + border-color: #DC2626; +} + +.gradedCard { + background: linear-gradient(to bottom, white 0%, #F0FDF4 100%); + border-color: #BBF7D0; +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 8px; +} + +.studentInfo { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.avatar { + width: 44px; + height: 44px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; + font-size: 0.9375rem; + flex-shrink: 0; + letter-spacing: 0.5px; + transition: transform 0.2s; +} + +.card:hover .avatar { + transform: scale(1.05); +} + +.studentDetails { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; +} + +.studentName { + font-size: 15px; + font-weight: 600; + color: #111827; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.taskType { + font-size: 12px; + color: #6B7280; + font-weight: 500; + line-height: 1; +} + +.statusArea { + display: flex; + align-items: flex-start; + flex-shrink: 0; + position: relative; +} + +.gradedBadge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 12px; + background: linear-gradient(135deg, #10B981 0%, #059669 100%); + color: white; + border-radius: 8px; + font-size: 12px; + font-weight: 700; + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.25); + letter-spacing: 0.3px; + text-transform: uppercase; +} + +.infoIconWrapper { + position: relative; + z-index: 10; +} + +.infoIcon { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, #374151 0%, #1F2937 100%); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); +} + +.infoIcon:hover { + transform: scale(1.1); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.2); +} + +.tooltip { + position: absolute; + top: calc(100% + 12px); + right: 0; + z-index: 50; + animation: tooltipFadeIn 0.2s ease-out; +} + +@keyframes tooltipFadeIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.tooltipContent { + background: #FFFFFF; + color: #1F2937; + padding: 12px 14px; + border-radius: 8px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05); + min-width: 200px; + max-width: 280px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.tooltipTitle { + font-size: 13px; + font-weight: 700; + color: #DC2626; + letter-spacing: 0.3px; + display: block; +} + +.tooltipText { + font-size: 13px; + line-height: 1.5; + color: #4B5563; + font-weight: 400; +} + +.tooltipArrow { + position: absolute; + bottom: 100%; + right: 8px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 8px solid #FFFFFF; + filter: drop-shadow(0 -2px 2px rgba(0, 0, 0, 0.05)); +} + +.spacer { + flex: 1; +} + +.gradeSection { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: linear-gradient(135deg, #ECFDF5 0%, #D1FAE5 100%); + border-radius: 8px; + border: 1px solid #A7F3D0; + margin-top: auto; +} + +.gradeLabel { + font-size: 14px; + font-weight: 600; + color: #065F46; + letter-spacing: 0.3px; + text-transform: uppercase; +} + +.gradeValue { + font-size: 24px; + font-weight: 800; + color: #059669; + letter-spacing: 0.5px; +} + +.lateSection { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 14px; + background: linear-gradient(135deg, #FEF2F2 0%, #FEE2E2 100%); + border: 1.5px solid #FECACA; + border-radius: 8px; + margin-top: auto; +} + +.lateLabel { + font-size: 11px; + font-weight: 800; + color: #DC2626; + letter-spacing: 0.8px; + text-transform: uppercase; + line-height: 1; +} + +.lateDate { + font-size: 13px; + color: #991B1B; + font-weight: 500; + line-height: 1.4; +} + +@media (max-width: 1024px) { + .card { + height: 145px; + padding: 16px; + } + + .avatar { + width: 40px; + height: 40px; + font-size: 14px; + } + + .studentName { + font-size: 14px; + } + + .taskType { + font-size: 11px; + } +} + +@media (max-width: 768px) { + .card { + height: auto; + min-height: 140px; + padding: 14px; + } + + .avatar { + width: 38px; + height: 38px; + font-size: 13px; + } + + .studentName { + font-size: 13px; + } + + .taskType { + font-size: 11px; + } + + .gradedBadge { + font-size: 11px; + padding: 5px 10px; + } + + .infoIcon { + width: 28px; + height: 28px; + } + + .tooltip { + right: -10px; + } + + .tooltipContent { + min-width: 180px; + padding: 10px 12px; + } + + .gradeSection { + padding: 8px 12px; + } + + .gradeValue { + font-size: 20px; + } +} + +@media print { + .card { + break-inside: avoid; + box-shadow: none; + border: 1px solid #D1D5DB; + } + + .card:hover { + transform: none; + box-shadow: none; + } + + .tooltip { + display: none; + } +} diff --git a/src/components/EductionPortal/Educators/TaskSubmissions/TaskSubmissionsPage.jsx b/src/components/EductionPortal/Educators/TaskSubmissions/TaskSubmissionsPage.jsx new file mode 100644 index 0000000000..fd46f12ed2 --- /dev/null +++ b/src/components/EductionPortal/Educators/TaskSubmissions/TaskSubmissionsPage.jsx @@ -0,0 +1,249 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import axios from 'axios'; +import SubmissionCard from './SubmissionCard'; +import styles from './TaskSubmissionsPage.module.css'; +import { FiChevronDown, FiChevronUp, FiChevronLeft, FiChevronRight } from 'react-icons/fi'; + +const TaskSubmissionsPage = () => { + const [submissions, setSubmissions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeClassId, setActiveClassId] = useState(''); + const [filterStatus, setFilterStatus] = useState('all'); + const [expandedTasks, setExpandedTasks] = useState({}); + + useEffect(() => { + const fetchSubmissions = async () => { + try { + setLoading(true); + const res = await axios.get( + `${process.env.REACT_APP_APIENDPOINT}/educationportal/educator/task-submissions`, + ); + const fetchedSubmissions = res.data || []; + setSubmissions(fetchedSubmissions); + + if (fetchedSubmissions.length > 0) { + const uniqueClassIds = [ + ...new Set(fetchedSubmissions.map(sub => sub.lessonPlanId)), + ].filter(Boolean); + if (uniqueClassIds.length > 0) { + setActiveClassId(uniqueClassIds[0]); + } + + const firstClassTasks = fetchedSubmissions.filter( + sub => sub.lessonPlanId === uniqueClassIds[0], + ); + if (firstClassTasks.length > 0) { + setExpandedTasks({ [firstClassTasks[0].taskName]: true }); + } + } + } catch (err) { + setError('Failed to load submissions. Please try again.'); + } finally { + setLoading(false); + } + }; + fetchSubmissions(); + }, []); + + const groupedData = useMemo(() => { + const data = {}; + submissions.forEach(sub => { + if (!sub.lessonPlanId || !sub.taskName) return; + + const classId = sub.lessonPlanId; + const className = sub.lessonPlanTitle || `Class ${classId.slice(-6)}`; + + if (!data[classId]) { + data[classId] = { className, tasks: {} }; + } + if (!data[classId].tasks[sub.taskName]) { + data[classId].tasks[sub.taskName] = []; + } + data[classId].tasks[sub.taskName].push(sub); + }); + return data; + }, [submissions]); + + const activeClassTasks = useMemo(() => { + return activeClassId ? groupedData[activeClassId]?.tasks || {} : {}; + }, [activeClassId, groupedData]); + + const filteredTasks = useMemo(() => { + const filtered = {}; + Object.entries(activeClassTasks).forEach(([taskName, subs]) => { + const filteredSubs = subs.filter(sub => { + if (filterStatus === 'all') return true; + if (filterStatus === 'pending_review' && sub.status === 'Pending Review') return true; + if (filterStatus === 'graded' && sub.status === 'Graded') return true; + return false; + }); + + if (filteredSubs.length > 0) { + filtered[taskName] = filteredSubs; + } + }); + return filtered; + }, [activeClassTasks, filterStatus]); + + const handleExpand = taskName => { + setExpandedTasks(prev => ({ + ...prev, + [taskName]: !prev[taskName], + })); + }; + + const handleKeyPress = (e, taskName) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleExpand(taskName); + } + }; + + const scrollTabs = direction => { + const tabsElement = document.querySelector(`.${styles.tabs}`); + if (tabsElement) { + const scrollAmount = direction === 'left' ? -200 : 200; + tabsElement.scrollBy({ left: scrollAmount, behavior: 'smooth' }); + } + }; + + if (loading) { + return ( +
+
+
+

Loading Submissions...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + return ( +
+
+

Submissions Overview

+
+ + +
+
+ +
+ + +
+ {Object.keys(groupedData).map(classId => ( + + ))} +
+ + +
+ +
+ {Object.keys(filteredTasks).length === 0 ? ( +
+

No submissions match the current filter.

+
+ ) : ( + Object.entries(filteredTasks).map(([taskName, subs]) => ( +
+
handleExpand(taskName)} + role="button" + tabIndex={0} + aria-expanded={!!expandedTasks[taskName]} + onKeyPress={e => handleKeyPress(e, taskName)} + > +
+

{taskName}

+ {subs[0]?.dueAt && ( +

+ Due{' '} + {new Date(subs[0].dueAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + })}{' '} + at{' '} + {new Date(subs[0].dueAt).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + })} +

+ )} +
+
+ + {subs.length} {subs.length === 1 ? 'submission' : 'submissions'} + + + {expandedTasks[taskName] ? : } + +
+
+ {expandedTasks[taskName] && ( +
+ {subs.map(submission => ( + + ))} +
+ )} +
+ )) + )} +
+
+ ); +}; + +export default TaskSubmissionsPage; diff --git a/src/components/EductionPortal/Educators/TaskSubmissions/TaskSubmissionsPage.module.css b/src/components/EductionPortal/Educators/TaskSubmissions/TaskSubmissionsPage.module.css new file mode 100644 index 0000000000..e080b4f26b --- /dev/null +++ b/src/components/EductionPortal/Educators/TaskSubmissions/TaskSubmissionsPage.module.css @@ -0,0 +1,431 @@ +.container { + min-height: 100vh; + background-color: #F8F9FA; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 32px; + background-color: #FFFFFF; + border-bottom: 1px solid #EDEDED; +} + +.title { + font-size: 24px; + font-weight: 600; + color: #1A1A1A; + margin: 0; +} + +.filterWrapper { + position: relative; + width: 220px; +} + +.filterSelect { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #fff; + border: 1px solid #D9D9D9; + border-radius: 8px; + padding: 10px 16px; + padding-right: 40px; + font-size: 14px; + font-weight: 500; + color: #333333; + cursor: pointer; + width: 100%; + height: 42px; + transition: all 0.2s ease; +} + +.filterSelect:hover { + border-color: #a0a0a0; +} + +.filterSelect:focus { + outline: none; + border-color: #4f46e5; + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + +.filterIcon { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: #8C8C8C; + font-size: 18px; +} + +/* Tabs Section */ +.tabsContainer { + position: relative; + display: flex; + align-items: stretch; + background-color: #F3F4F6; + border-bottom: 1px solid #E5E7EB; + overflow: hidden; +} + +.scrollButton { + flex-shrink: 0; + width: 44px; + background: #F9FAFB; + border: none; + border-right: 1px solid #E5E7EB; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #6B7280; + transition: all 0.2s ease; + z-index: 10; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04); +} + +.scrollButton:last-child { + border-right: none; + border-left: 1px solid #E5E7EB; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.04); +} + +.scrollButton:hover { + background: #F3F4F6; + color: #374151; +} + +.scrollButton:active { + background-color: #E5E7EB; +} + +.tabs { + display: flex; + flex: 1; + overflow-x: auto; + overflow-y: hidden; + scroll-behavior: smooth; + scrollbar-width: thin; + scrollbar-color: #D1D5DB #F9FAFB; + gap: 4px; + padding: 8px 12px; + background: #F9FAFB; +} + +.tabs::-webkit-scrollbar { + height: 6px; +} + +.tabs::-webkit-scrollbar-track { + background: #F9FAFB; +} + +.tabs::-webkit-scrollbar-thumb { + background: #D1D5DB; + border-radius: 3px; +} + +.tabs::-webkit-scrollbar-thumb:hover { + background: #9CA3AF; +} + +.tab { + flex-shrink: 0; + padding: 12px 24px; + cursor: pointer; + background-color: white; + border: 1px solid #E5E7EB; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + color: #6B7280; + transition: all 0.2s ease; + white-space: nowrap; + min-width: fit-content; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.tab:hover:not(.activeTab) { + background-color: #F9FAFB; + border-color: #D1D5DB; + color: #374151; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); +} + +.tab:active:not(.activeTab) { + transform: translateY(0); +} + +.activeTab { + background: linear-gradient(135deg, #3B82F6 0%, #2563EB 100%); + color: white; + border-color: #3B82F6; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + transform: translateY(-2px); +} + +.activeTab:hover { + background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%); + box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4); +} + +/* Content Section */ +.content { + padding: 28px 32px; +} + +.taskSection { + background-color: #ffffff; + border: 1px solid #EAEAEA; + border-radius: 12px; + margin-bottom: 24px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); + overflow: hidden; +} + +.sectionHeader { + padding: 18px 24px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + border-bottom: 1px solid #EAEAEA; + transition: background-color 0.2s ease; + background-color: #FAFAFA; +} + +.sectionHeader:hover { + background-color: #F3F4F6; +} + +.sectionHeader:focus { + outline: none; + background-color: #F3F4F6; +} + +.sectionHeader:focus-visible { + outline: 2px solid #3B82F6; + outline-offset: -2px; +} + +.sectionInfo { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.sectionHeader h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #1A1A1A; +} + +.dueDate { + margin: 0; + font-size: 13px; + color: #8C8C8C; + font-weight: 400; +} + +.sectionActions { + display: flex; + align-items: center; + gap: 16px; +} + +.submissionCount { + font-size: 13px; + color: #6B7280; + font-weight: 500; +} + +.expandIcon { + color: #8C8C8C; + font-size: 20px; + display: flex; + align-items: center; + transition: transform 0.2s ease; +} + +.cardsGrid { + padding: 24px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; +} + +/* Loading State */ +.loadingState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 60px 20px; + text-align: center; + font-size: 16px; + color: #555; + background-color: #fff; + border-radius: 12px; + margin: 24px 32px; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid #E5E7EB; + border-top-color: #4f46e5; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loadingState p { + margin: 0; + font-weight: 500; + color: #6B7280; +} + +/* Error State */ +.errorState { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 60px 20px; + text-align: center; + color: #D32F2F; + background-color: #FEF2F2; + border: 1px solid #FEE2E2; + border-radius: 12px; + margin: 24px 32px; +} + +.errorState p { + margin: 0; + font-size: 16px; + font-weight: 500; +} + +.retryButton { + padding: 10px 24px; + background-color: #4f46e5; + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.retryButton:hover { + background-color: #4338ca; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3); +} + +/* No Data State */ +.noData { + text-align: center; + padding: 60px 20px; + color: #757575; + font-size: 16px; + background-color: #FFFFFF; + border: 1px solid #E0E0E0; + border-radius: 12px; +} + +.noData p { + margin: 0; + font-weight: 500; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .cardsGrid { + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; + } + + .tabs { + gap: 6px; + padding: 6px 8px; + } + + .tab { + padding: 10px 20px; + font-size: 13px; + } + + .scrollButton { + width: 40px; + } +} + +@media (max-width: 768px) { + .header { + flex-direction: column; + align-items: flex-start; + gap: 16px; + padding: 20px; + } + + .filterWrapper { + width: 100%; + } + + .tabs { + gap: 4px; + padding: 6px; + } + + .tab { + padding: 10px 16px; + font-size: 13px; + min-width: 140px; + } + + .scrollButton { + width: 36px; + } + + .content { + padding: 20px 16px; + } + + .sectionHeader { + padding: 16px; + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .sectionActions { + width: 100%; + justify-content: space-between; + } + + .cardsGrid { + grid-template-columns: 1fr; + padding: 16px; + } +} + +@media (min-width: 1024px) { + .tabs::-webkit-scrollbar { + height: 8px; + } +} diff --git a/src/components/EductionPortal/Educators/TaskSubmissions/index.js b/src/components/EductionPortal/Educators/TaskSubmissions/index.js new file mode 100644 index 0000000000..14743b3d2e --- /dev/null +++ b/src/components/EductionPortal/Educators/TaskSubmissions/index.js @@ -0,0 +1 @@ +export { default } from './TaskSubmissionsPage'; diff --git a/src/routes.jsx b/src/routes.jsx index 73a489e9ec..b59ff27b4d 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -163,6 +163,7 @@ import CommunityCalendar from './components/CommunityPortal/Calendar/CommunityCa import EPProtectedRoute from './components/common/EPDashboard/EPProtectedRoute'; import EPLogin from './components/EductionPortal/Login'; import EPDashboard from './components/EductionPortal'; +import TaskSubmissions from './components/EductionPortal/Educators/TaskSubmissions'; import PRReviewTeamAnalytics from './components/HGNPRDashboard/PRReviewTeamAnalytics'; import PRDashboardOverview from './components/HGNPRDashboard/PRDashboardOverview'; @@ -760,6 +761,11 @@ export default ( + Date: Wed, 22 Apr 2026 01:43:05 -0700 Subject: [PATCH 2/2] fix(education-portal): fix lag, sub-task toast spam, and mobile responsiveness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Suppress 'Failed to fetch sub-tasks' toast on 404 (demo/mock task IDs that don't exist in the DB no longer flood the UI with error toasts) - Add Active / Pending Review / Completed filter tabs so graded and completed tasks are hidden by default, eliminating unnecessary renders - Lazy-load intermediate tasks only for the currently visible (filtered) tasks; cache results to avoid redundant API calls on tab switch - Replace overflow nav bar with a hamburger menu on mobile (≤768px) - Fix card grid min-width and single-column collapse on mobile - Remove fixed min-height from TaskCard; fix action-button layout at small breakpoints (480px) for both card and list views --- src/actions/intermediateTasks.js | 4 + .../StudentDashboard/NavigationBar.jsx | 110 ++++++--- .../StudentDashboard/NavigationBar.module.css | 112 +++++++-- .../StudentDashboard/StudentDashboard.jsx | 193 +++++++++------ .../StudentDashboard.module.css | 223 +++++++++++++++-- .../StudentDashboard/SummaryCards.module.css | 40 ++- .../StudentDashboard/TaskCard.module.css | 231 ++++++++++++++++-- .../StudentDashboard/TaskCardView.module.css | 8 +- .../StudentDashboard/TaskListItem.module.css | 223 +++++++++++++++-- 9 files changed, 971 insertions(+), 173 deletions(-) diff --git a/src/actions/intermediateTasks.js b/src/actions/intermediateTasks.js index 8521a9f9b0..fc00b6634e 100644 --- a/src/actions/intermediateTasks.js +++ b/src/actions/intermediateTasks.js @@ -23,6 +23,10 @@ export const fetchIntermediateTasks = (taskId) => { const response = await httpService.get(ENDPOINTS.INTERMEDIATE_TASKS_BY_PARENT(taskId)); return response.data; } catch (error) { + // 404 means the parent task doesn't exist in the education system (e.g. mock/demo tasks) - not an error + if (error.response?.status === 404) { + return []; + } console.error('Error fetching intermediate tasks:', error); toast.error('Failed to fetch sub-tasks'); throw error; diff --git a/src/components/EductionPortal/StudentDashboard/NavigationBar.jsx b/src/components/EductionPortal/StudentDashboard/NavigationBar.jsx index caa8f0cd47..b9a63ad404 100644 --- a/src/components/EductionPortal/StudentDashboard/NavigationBar.jsx +++ b/src/components/EductionPortal/StudentDashboard/NavigationBar.jsx @@ -3,11 +3,17 @@ import styles from './NavigationBar.module.css'; const NavigationBar = () => { const [activeDropdown, setActiveDropdown] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); const toggleDropdown = dropdown => { setActiveDropdown(activeDropdown === dropdown ? null : dropdown); }; + const toggleMenu = () => { + setMenuOpen(prev => !prev); + setActiveDropdown(null); + }; + const navigationItems = [ { name: 'Home', icon: 'home', hasDropdown: false }, { name: 'Clock', icon: 'clock', hasDropdown: true }, @@ -88,42 +94,80 @@ const NavigationBar = () => { return ( ); diff --git a/src/components/EductionPortal/StudentDashboard/NavigationBar.module.css b/src/components/EductionPortal/StudentDashboard/NavigationBar.module.css index 1007bfac5a..11be2c2288 100644 --- a/src/components/EductionPortal/StudentDashboard/NavigationBar.module.css +++ b/src/components/EductionPortal/StudentDashboard/NavigationBar.module.css @@ -1,7 +1,8 @@ .navigationBar { background-color: #3b82f6; padding: 0.75rem 0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + box-shadow: 0 1px 3px rgb(0 0 0 / 10%); + position: relative; } .navContainer { @@ -10,8 +11,16 @@ padding: 0 1rem; display: flex; align-items: center; - gap: 2rem; - overflow-x: auto; + gap: 0.25rem; +} + +/* Desktop nav items wrapper */ +.navItems { + display: flex; + align-items: center; + gap: 0.25rem; + flex-wrap: nowrap; + flex: 1; } .navItem { @@ -25,7 +34,7 @@ gap: 0.5rem; background: transparent; border: none; - color: #ffffff; + color: #fff; font-size: 0.875rem; font-weight: 500; padding: 0.5rem 0.75rem; @@ -36,7 +45,7 @@ } .navButton:hover { - background-color: rgba(255, 255, 255, 0.1); + background-color: rgb(255 255 255 / 10%); } .navText { @@ -61,9 +70,9 @@ } .dropdownContent { - background-color: #ffffff; + background-color: #fff; border-radius: 8px; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 10%), 0 2px 4px -1px rgb(0 0 0 / 6%); padding: 0.5rem 0; min-width: 200px; border: 1px solid #e5e7eb; @@ -88,18 +97,91 @@ color: #1f2937; } +/* Hamburger button — hidden on desktop */ +.hamburger { + display: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: transparent; + border: none; + border-radius: 6px; + color: #fff; + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.2s ease; +} + +.hamburger:hover { + background-color: rgb(255 255 255 / 10%); +} + /* Responsive Design */ -@media (max-width: 768px) { +@media (width <= 768px) { + .hamburger { + display: flex; + } + .navContainer { - gap: 1rem; - padding: 0 0.5rem; + flex-wrap: wrap; + gap: 0; + padding: 0 0.75rem; + } + + /* Hide desktop nav items on mobile; show only when open */ + .navItems { + display: none; + flex-direction: column; + align-items: flex-start; + width: 100%; + padding: 0.5rem 0; + gap: 0; + } + + .navItems.navItemsOpen { + display: flex; + } + + .navItem { + width: 100%; } - + .navButton { - padding: 0.5rem; + width: 100%; + justify-content: flex-start; + padding: 0.6rem 0.5rem; + border-radius: 4px; } - - .navText { - display: none; + + .dropdown { + position: static; + margin-top: 0; } + + .dropdownContent { + border-radius: 4px; + margin-left: 1.5rem; + min-width: auto; + width: calc(100% - 1.5rem); + } +} + +/* Dark Mode Styles - Using global dark-mode class for consistency across builds */ +:global(.dark-mode) .navigationBar { + background-color: #1e3a5f; +} + +:global(.dark-mode) .dropdownContent { + background-color: #2d2d2d; + border-color: #404040; +} + +:global(.dark-mode) .dropdownItem { + color: #d1d5db; +} + +:global(.dark-mode) .dropdownItem:hover { + background-color: #363636; + color: #fff; } diff --git a/src/components/EductionPortal/StudentDashboard/StudentDashboard.jsx b/src/components/EductionPortal/StudentDashboard/StudentDashboard.jsx index c4c3a14b2a..2edaa84fe0 100644 --- a/src/components/EductionPortal/StudentDashboard/StudentDashboard.jsx +++ b/src/components/EductionPortal/StudentDashboard/StudentDashboard.jsx @@ -1,7 +1,6 @@ -import React, { useState, useEffect } from 'react'; -import { Container, Row, Col, Card, Button } from 'reactstrap'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { Container, Button } from 'reactstrap'; import { useSelector, useDispatch } from 'react-redux'; -import { toast } from 'react-toastify'; import styles from './StudentDashboard.module.css'; import TaskCardView from './TaskCardView'; import TaskListView from './TaskListView'; @@ -10,8 +9,19 @@ import SummaryCards from './SummaryCards'; import { fetchStudentTasks, markStudentTaskAsDone } from '~/actions/studentTasks'; import { fetchIntermediateTasks, markIntermediateTaskAsDone } from '~/actions/intermediateTasks'; +const ACTIVE_STATUSES = ['assigned', 'in_progress']; +const PENDING_STATUSES = ['pending_review', 'submitted']; +const COMPLETED_STATUSES = ['completed', 'graded']; + +const FILTER_TABS = [ + { key: 'active', label: 'Active' }, + { key: 'pending', label: 'Pending Review' }, + { key: 'completed', label: 'Completed' }, +]; + const StudentDashboard = () => { const [viewMode, setViewMode] = useState('card'); // 'card' or 'list' + const [activeFilter, setActiveFilter] = useState('active'); const [summaryData, setSummaryData] = useState({ totalTimeLogged: '0h 0min', thisWeek: '0h 0min', @@ -22,40 +32,66 @@ const StudentDashboard = () => { const [expandedTasks, setExpandedTasks] = useState({}); const dispatch = useDispatch(); - const authUser = useSelector(state => state.auth.user); const { taskItems: tasks, fetching: loading, error } = useSelector(state => state.studentTasks); + // Derived filtered task list — no unnecessary rendering of graded/completed tasks by default + const filteredTasks = useMemo(() => { + if (!tasks) return []; + switch (activeFilter) { + case 'active': + return tasks.filter(t => ACTIVE_STATUSES.includes(t.status)); + case 'pending': + return tasks.filter(t => PENDING_STATUSES.includes(t.status)); + case 'completed': + return tasks.filter(t => COMPLETED_STATUSES.includes(t.status)); + default: + return tasks; + } + }, [tasks, activeFilter]); + + // Tab counts for display + const tabCounts = useMemo(() => { + if (!tasks) return { active: 0, pending: 0, completed: 0 }; + return { + active: tasks.filter(t => ACTIVE_STATUSES.includes(t.status)).length, + pending: tasks.filter(t => PENDING_STATUSES.includes(t.status)).length, + completed: tasks.filter(t => COMPLETED_STATUSES.includes(t.status)).length, + }; + }, [tasks]); + // Fetch tasks from API useEffect(() => { dispatch(fetchStudentTasks()); }, [dispatch]); - // Fetch intermediate tasks for all parent tasks + // Fetch intermediate tasks only for currently visible (filtered) 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); - } - } + const fetchVisibleIntermediateTasks = async () => { + if (!filteredTasks || filteredTasks.length === 0) return; - setIntermediateTasks(intermediateTasksData); + const intermediateTasksData = { ...intermediateTasks }; + let changed = false; + + for (const task of filteredTasks) { + if (intermediateTasksData[task.id] !== undefined) continue; // already fetched + try { + const subTasks = await dispatch(fetchIntermediateTasks(task.id)); + intermediateTasksData[task.id] = subTasks || []; + changed = true; + } catch (err) { + intermediateTasksData[task.id] = []; + changed = true; + } } + + if (changed) setIntermediateTasks(intermediateTasksData); }; - fetchAllIntermediateTasks(); - }, [tasks, dispatch]); + fetchVisibleIntermediateTasks(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filteredTasks, dispatch]); - // Calculate summary data when tasks change + // Calculate summary data when tasks change (uses ALL tasks for accurate totals) useEffect(() => { if (tasks && tasks.length > 0) { calculateSummaryData(tasks); @@ -64,17 +100,18 @@ const StudentDashboard = () => { // Calculate summary data from tasks const calculateSummaryData = tasksData => { + const formatTime = hrs => { + const wholeHours = Math.floor(hrs); + const minutes = Math.round((hrs - wholeHours) * 60); + return `${wholeHours}h ${minutes}min`; + }; + const totalHours = tasksData.reduce((sum, task) => sum + (task.logged_hours || 0), 0); const thisWeekHours = tasksData.reduce((sum, task) => { - // Check if task was logged this week (simplified logic) const taskDate = new Date(task.last_logged_date || task.created_at); const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 7); - - if (taskDate >= weekAgo) { - return sum + (task.logged_hours || 0); - } - return sum; + return taskDate >= weekAgo ? sum + (task.logged_hours || 0) : sum; }, 0); const activeCourses = new Set(tasksData.map(task => task.course_id || task.course_name)).size; @@ -88,50 +125,37 @@ const StudentDashboard = () => { }); }; - // Format time in hours and minutes - const formatTime = hours => { - const wholeHours = Math.floor(hours); - const minutes = Math.round((hours - wholeHours) * 60); - return `${wholeHours}h ${minutes}min`; - }; - // Handle mark as done - const handleMarkAsDone = async taskId => { - dispatch(markStudentTaskAsDone(taskId)); - }; + const handleMarkAsDone = useCallback( + taskId => { + dispatch(markStudentTaskAsDone(taskId)); + }, + [dispatch], + ); // 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 - } - }; + const handleMarkIntermediateAsDone = useCallback( + async (intermediateTaskId, parentTaskId) => { + try { + await dispatch(markIntermediateTaskAsDone(intermediateTaskId)); + const subTasks = await dispatch(fetchIntermediateTasks(parentTaskId)); + setIntermediateTasks(prev => ({ ...prev, [parentTaskId]: subTasks || [] })); + } catch (_err) { + // Error is handled in the action + } + }, + [dispatch], + ); // 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')); - }; + const toggleIntermediateTasks = useCallback(taskId => { + setExpandedTasks(prev => ({ ...prev, [taskId]: !prev[taskId] })); + }, []); if (loading) { return (
-
+

Loading your dashboard...

); @@ -148,6 +172,12 @@ const StudentDashboard = () => { ); } + const emptyMessages = { + active: 'No active tasks right now.', + pending: 'No tasks pending review.', + completed: 'No completed tasks yet.', + }; + return (
@@ -162,10 +192,29 @@ const StudentDashboard = () => { {/* Summary Cards */} - {/* Recent Time Logs Section */} + {/* Tasks Section */}
+ {/* Section header row */}
-

Recent Time Logs

+ {/* Filter tabs */} +
+ {FILTER_TABS.map(tab => ( + + ))} +
+ + {/* View toggle */}
{/* Task Views */} - {viewMode === 'card' ? ( + {filteredTasks.length === 0 ? ( +
+

{emptyMessages[activeFilter]}

+
+ ) : viewMode === 'card' ? ( { /> ) : (