diff --git a/src/actions/bmdashboard/knowledgeEvolutionActions.js b/src/actions/bmdashboard/knowledgeEvolutionActions.js new file mode 100644 index 0000000000..2e629aca9b --- /dev/null +++ b/src/actions/bmdashboard/knowledgeEvolutionActions.js @@ -0,0 +1,37 @@ +import axios from "axios"; +import { toast } from "react-toastify"; +import { ENDPOINTS } from "~/utils/URL"; +import { + FETCH_KNOWLEDGE_EVOLUTION_DATA_REQUEST, + FETCH_KNOWLEDGE_EVOLUTION_DATA_SUCCESS, + FETCH_KNOWLEDGE_EVOLUTION_DATA_FAILURE, +} from "../../constants/bmdashboard/knowledgeEvolutionConstants"; + + +const fetchKnowledgeEvolutionDataSuccess = (data) => ({ + type: FETCH_KNOWLEDGE_EVOLUTION_DATA_SUCCESS, + payload: data, +}); + +const fetchKnowledgeEvolutionDataFailure = (error) => ({ + type: FETCH_KNOWLEDGE_EVOLUTION_DATA_FAILURE, + payload: error, +}); + +export const fetchKnowledgeEvolutionData = (userId) => { + return async (dispatch) => { + try { + dispatch({ type: FETCH_KNOWLEDGE_EVOLUTION_DATA_REQUEST }); + const url = `${ENDPOINTS.KNOWLEDGE_EVOLUTION}/?studentId=${userId}`; + const res = await axios.get(url); + dispatch(fetchKnowledgeEvolutionDataSuccess(res.data)); + return res.data; + } catch (err) { + const errorPayload = err.response?.data || { message: err.message }; + dispatch(fetchKnowledgeEvolutionDataFailure(errorPayload)); + toast.error(err.response?.data?.error || "Failed to fetch knowledge evolution data"); + return null; + } + }; +}; + diff --git a/src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.jsx b/src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.jsx new file mode 100644 index 0000000000..e346fd64a3 --- /dev/null +++ b/src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.jsx @@ -0,0 +1,323 @@ +import React, { useEffect, useRef, useState } from 'react'; +import * as d3 from 'd3'; +import { Funnel, Search } from 'lucide-react'; +import styles from './KnowledgeEvolution.module.css'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchKnowledgeEvolutionData } from '../../../actions/bmdashboard/knowledgeEvolutionActions'; + +const KnowledgeEvolution = () => { + const svgRef = useRef(); + const dispatch = useDispatch(); + const { data, loading, error } = useSelector(state => state.knowledgeEvolution); + const user = useSelector(state => state.auth.user); + const darkMode = useSelector(state => state.theme.darkMode); + const userId = user ? user.userid : null; + + useEffect(() => { + dispatch(fetchKnowledgeEvolutionData(userId)); + }, [dispatch, userId]); + + const [selectedSubject, setSelectedSubject] = useState(null); + + useEffect(() => { + if (data?.knowledgeEvolution?.length > 0) { + setSelectedSubject(data.knowledgeEvolution[0]._id); + } + }, [data]); + + const allAtoms = data?.knowledgeEvolution?.flatMap(s => s.atoms) || []; + const totalCompleted = allAtoms.filter(a => a.atomStatus === 'completed').length; + const totalInProgress = allAtoms.filter(a => a.atomStatus === 'in_progress').length; + const totalNotStarted = allAtoms.filter(a => a.atomStatus === 'not_started').length; + const savedInterest = 2; + const tooltipRef = useRef(null); + const [tooltipData, setTooltipData] = useState(null); + const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }); + + useEffect(() => { + if (!data || !selectedSubject) return; + + const subjectData = data.knowledgeEvolution.find(s => s._id === selectedSubject); + if (!subjectData) return; + + const courses = subjectData.atoms || []; + + const width = 700; + const height = 500; + const svg = d3.select(svgRef.current); + svg.attr('viewBox', `0 0 ${width} ${height}`); + svg.selectAll('*').remove(); + + const centerX = width / 2; + const centerY = height / 2; + const subjectRadius = 60; + const courseRadius = 45; + const orbitRadius = 180; + + const colorMap = { + completed: '#28a745', + in_progress: '#ffc107', + not_started: '#6c757d', + }; + + const darkerMap = { + completed: '#1e7e34', + in_progress: '#e0a800', + not_started: '#5a6268', + }; + + const subjectNode = { + id: subjectData.subjectName, + type: 'subject', + x: centerX, + y: centerY, + }; + + const courseNodes = courses.map((atom, i) => { + const angle = (2 * Math.PI * i) / (courses.length || 1); + return { + id: atom.atomId, + name: atom.atomName, + status: atom.atomStatus, + type: 'course', + x: centerX + orbitRadius * Math.cos(angle), + y: centerY + orbitRadius * Math.sin(angle), + }; + }); + + const allNodes = [subjectNode, ...courseNodes]; + + const allLinks = courseNodes.map(atom => { + const dx = atom.x - subjectNode.x; + const dy = atom.y - subjectNode.y; + const angle = Math.atan2(dy, dx); + return { + x1: subjectNode.x + subjectRadius * Math.cos(angle), + y1: subjectNode.y + subjectRadius * Math.sin(angle), + x2: atom.x - courseRadius * Math.cos(angle), + y2: atom.y - courseRadius * Math.sin(angle), + status: atom.status, + }; + }); + + svg + .append('g') + .selectAll('line') + .data(allLinks) + .enter() + .append('line') + .attr('x1', d => d.x1) + .attr('y1', d => d.y1) + .attr('x2', d => d.x2) + .attr('y2', d => d.y2) + .attr('stroke', d => colorMap[d.status]) + .attr('stroke-width', 2) + .attr('stroke-dasharray', d => (d.status === 'not_started' ? '6,4' : '0')) + .attr('opacity', 0.95); + + svg + .append('g') + .selectAll('circle') + .data(allNodes) + .enter() + .append('circle') + .attr('cx', d => d.x) + .attr('cy', d => d.y) + .attr('r', d => (d.type === 'subject' ? subjectRadius : courseRadius)) + .attr('fill', d => { + if (d.type === 'subject') return darkMode ? '#2a3b55' : '#ffffff'; + const c = d3.color(colorMap[d.status]); + c.opacity = 0.3; + return c; + }) + .attr('stroke', d => (d.type === 'subject' ? '#8b5a00' : darkerMap[d.status])) + .attr('stroke-width', d => (d.type === 'subject' ? 4 : 3)); + + svg + .append('g') + .selectAll('text') + .data(allNodes) + .enter() + .append('text') + .attr('x', d => d.x) + .attr('y', d => d.y) + .attr('text-anchor', 'middle') + .attr('font-size', d => (d.type === 'subject' ? 18 : 12)) + .attr('fill', darkMode ? '#ffffff' : '#222') + .each(function(d) { + const node = d3.select(this); + const words = (d.type === 'subject' ? d.id : d.name || '').split(' '); + let yOffset = -(words.length - 1) * 6; + words.forEach(word => { + node + .append('tspan') + .attr('x', d.x) + .attr('dy', yOffset) + .text(word); + yOffset = 12; + }); + }); + }, [data, selectedSubject, darkMode]); + + if (loading) return
Loading Knowledge Evolution...
; + if (error) return
Failed to load knowledge evolution data. Please try again later.
; + if (!data) return
No knowledge evolution data available.
; + const handleChartMouseEnter = () => { + const subjectData = data?.knowledgeEvolution?.find(s => s._id === selectedSubject); + if (!subjectData) return; + const atoms = subjectData.atoms || []; + const completed = atoms.filter(a => a.atomStatus === 'completed').length; + const inProgress = atoms.filter(a => a.atomStatus === 'in_progress').length; + const notStarted = atoms.filter(a => a.atomStatus === 'not_started').length; + + setTooltipData({ + subject: subjectData.subjectName, + completed, + inProgress, + notStarted, + }); + if (tooltipRef.current) tooltipRef.current.style.visibility = 'visible'; + }; + + const handleChartMouseLeave = () => { + setTooltipData(null); + if (tooltipRef.current) tooltipRef.current.style.visibility = 'hidden'; + }; + + const handleChartMouseMove = e => { + setTooltipPos({ x: e.clientX, y: e.clientY }); + }; + + const handleChartKeyDown = e => { + if (e.key === 'Enter' || e.key === ' ') { + handleChartMouseEnter(); + } else if (e.key === 'Escape') { + handleChartMouseLeave(); + } + }; + + return ( +
+
+ {/* HEADER */} +
+
Knowledge Evolution
+ + {/* SUMMARY */} +
+
Overall Progress Across All Subjects
+ +
+
+

{totalCompleted}

+

Total Completed

+
+ +
+

{totalInProgress}

+

Total In Progress

+
+ +
+

{totalNotStarted}

+

Total Not Started

+
+ +
+

{savedInterest}

+

Saved Interest

+
+
+
+ + {/* SEARCH + FILTER */} +
+
+ + +
+ + +
+
+ + {/* SUBJECT TABS */} +
+ {data.knowledgeEvolution.map(s => ( + + ))} +
+
+ {tooltipData ? ( + <> +
{tooltipData.subject} Progress
+
+
+ {tooltipData.completed} +
Completed
+
+ +
+ {tooltipData.inProgress} +
In Progress
+
+ +
+ {tooltipData.notStarted} +
Not Started
+
+
+ + ) : null} +
+ + {/* D3 CHART with subject-level hover */} + + + {/* Legend placed below chart */} +
+
+ Completed +
+
+ In Progress +
+
+ Not Started +
+
+
+
+ ); +}; + +export default KnowledgeEvolution; diff --git a/src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.module.css b/src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.module.css new file mode 100644 index 0000000000..f103909cec --- /dev/null +++ b/src/components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution.module.css @@ -0,0 +1,358 @@ +.pageContainer { + padding: 10px; + font-family: Inter, sans-serif; + position: relative; +} + +.pageContainerDarkMode { + background-color: #1b2a41; + color: #fff; +} + +.headerContainer { + margin-bottom: 20px; + font-weight: 800; +} + +.summarySection { + text-align: left; + margin-bottom: 20px; + border: 1px solid #ddd; + border-radius: 10px; + padding: 15px; + background: #fff; +} + +.pageContainerDarkMode .summarySection { + background-color: #2a3b55; + border-color: #444; +} + +.summaryHeading { + font-size: 16px; + margin-bottom: 10px; +} + +.summaryStats { + display: flex; + gap: 25px; +} + +.statBox { + background: #fff; + padding: 12px 20px; + text-align: center; + flex: 1; +} + +.pageContainerDarkMode .statBox { + background-color: #3a4b65; +} + +.statBox h3 { + margin: 0; + font-size: 20px; + font-weight: 700; +} + +.statBox p { + margin: 5px 0 0; + font-size: 13px; + color: #555; +} + +.pageContainerDarkMode .statBox p { + color: #aaa; +} + +.searchFilterContainer { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.searchWrapper { + position: relative; + display: inline-block; +} + +.searchIcon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: #888; +} + +.searchInput { + flex: 0.6; + padding: 8px 12px; + border: 1.3px solid #ccc; + border-radius: 6px; + font-size: 14px; + padding-left: 35px; + height: 38px; +} + +.pageContainerDarkMode .searchInput { + background-color: #3a4b65; + border-color: #555; + color: #eee; +} + +.filterButton { + display: flex; + align-items: center; + gap: 6px; + background-color: #f0f0f0; + border: 1.3px solid #ccc; + border-radius: 6px; + padding: 8px 12px; + cursor: pointer; +} + +.pageContainerDarkMode .filterButton { + background-color: #3a4b65; + border-color: #555; + color: #eee; +} + +.filterButton:hover { + background-color: #e6e6e6; +} + +.chartWrapper { + display: flex; + justify-content: center; + align-items: center; + border: 1.5px solid #ccc; + background: #fafafa; + padding: 15px; + box-shadow: 0 2px 6px rgb(0 0 0 / 10%); + position: relative; + min-height: 420px; + margin-bottom: 12px; + width: 100%; + cursor: default; + font: inherit; + color: inherit; + text-align: left; +} + +.pageContainerDarkMode .chartWrapper { + background-color: #2a3b55; + border-color: #444; +} + +.subjectTabs { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 20px; + border: 2px solid #ddd; + padding-bottom: 5px; + margin-bottom: 12px; +} + +.pageContainerDarkMode .subjectTabs { + border-color: #444; +} + +.tabButton { + background: none; + border: none; + font-size: 15px; + font-weight: 500; + padding: 8px 14px; + cursor: pointer; + color: #555; + position: relative; + overflow-wrap: break-word; + white-space: normal; + max-width: 160px; + text-align: center; +} + +.tabButton:hover { + color: #000; +} + +.pageContainerDarkMode .tabButton { + color: #ccc; +} + +.pageContainerDarkMode .tabButton:hover { + color: #fff; +} + +.activeTab { + color: orange; + font-weight: 600; +} + +.activeTab::after { + content: ''; + position: absolute; + bottom: -6px; + left: 0; + right: 0; + height: 3px; + background-color: orange; +} + +.subjectTooltipTop { + position: fixed; + transform: translateX(-50%); + background: white; + padding: 12px 18px; + border-radius: 10px; + box-shadow: 0 6px 20px rgb(0 0 0 / 12%); + font-size: 13px; + min-width: 320px; + max-width: 720px; + visibility: hidden; + z-index: 60; + text-align: center; + pointer-events: none; +} + +.pageContainerDarkMode .subjectTooltipTop { + background-color: #3a4b65; + color: #eee; + box-shadow: 0 6px 20px rgb(0 0 0 / 40%); + border: 1.5px solid #555; +} + +.tooltipTitle { + font-weight: 600; + margin-bottom: 8px; + font-size: 15px; + color: #222; +} + +.pageContainerDarkMode .tooltipTitle { + color: #eee; +} + +.tooltipCounts { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 0; +} + +.tooltipCount { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 2px; +} + +.completedDot, +.inProgressDot, +.notStartedDot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; +} + +.completedDot { + background: #28a745; +} + +.inProgressDot { + background: #ffc107; +} + +.notStartedDot { + background: #6c757d; +} + +.subjectTooltipBottomLegend { + display: flex; + justify-content: center; + gap: 24px; + align-items: center; + padding: 10px 6px; + background: transparent; + z-index: 40; +} + +.legendItem { + display: flex; + gap: 8px; + align-items: center; + font-size: 13px; + color: #444; +} + +.pageContainerDarkMode .legendItem { + color: #ccc; +} + +.completedDotSmall, +.inProgressDotSmall, +.notStartedDotSmall { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + +.completedDotSmall { + background: #28a745; +} + +.inProgressDotSmall { + background: #ffc107; +} + +.notStartedDotSmall { + background: #6c757d; +} + +@media (width <= 800px) { + .subjectTooltipTop { + min-width: 260px; + } + + .tooltipCounts { + flex-direction: column; + gap: 6px; + align-items: center; + } + + .tooltipCount { + justify-content: flex-start; + width: 100%; + padding-left: 12px; + } + + .chartWrapper { + min-height: 360px; + } + + .subjectTooltipBottomLegend { + gap: 12px; + flex-wrap: wrap; + } +} + +.completedText { + color: #28a745; + font-weight: 600; +} + +.inProgressText { + color: #ffc107; + font-weight: 600; +} + +.notStartedText { + color: #6c757d; + font-weight: 600; +} diff --git a/src/constants/bmdashboard/knowledgeEvolutionConstants.js b/src/constants/bmdashboard/knowledgeEvolutionConstants.js new file mode 100644 index 0000000000..df8bb9c7ea --- /dev/null +++ b/src/constants/bmdashboard/knowledgeEvolutionConstants.js @@ -0,0 +1,3 @@ +export const FETCH_KNOWLEDGE_EVOLUTION_DATA_REQUEST = 'FETCH_KNOWLEDGE_EVOLUTION_DATA_REQUEST'; +export const FETCH_KNOWLEDGE_EVOLUTION_DATA_SUCCESS = 'FETCH_KNOWLEDGE_EVOLUTION_DATA_SUCCESS'; +export const FETCH_KNOWLEDGE_EVOLUTION_DATA_FAILURE = 'FETCH_KNOWLEDGE_EVOLUTION_DATA_FAILURE'; diff --git a/src/reducers/bmdashboard/knowledgeEvolutionReducer.js b/src/reducers/bmdashboard/knowledgeEvolutionReducer.js new file mode 100644 index 0000000000..f1815d9d2c --- /dev/null +++ b/src/reducers/bmdashboard/knowledgeEvolutionReducer.js @@ -0,0 +1,27 @@ +import { + FETCH_KNOWLEDGE_EVOLUTION_DATA_REQUEST, + FETCH_KNOWLEDGE_EVOLUTION_DATA_SUCCESS, + FETCH_KNOWLEDGE_EVOLUTION_DATA_FAILURE, +} from '../../constants/bmdashboard/knowledgeEvolutionConstants'; + +const initialState = { + loading: false, + data: null, + error: null, +}; + +// eslint-disable-next-line default-param-last +const knowledgeEvolutionReducer = (state = initialState, action) => { + switch (action.type) { + case FETCH_KNOWLEDGE_EVOLUTION_DATA_REQUEST: + return { ...state, loading: true, error: null }; + case FETCH_KNOWLEDGE_EVOLUTION_DATA_SUCCESS: + return { ...state, loading: false, data: action.payload }; + case FETCH_KNOWLEDGE_EVOLUTION_DATA_FAILURE: + return { ...state, loading: false, error: action.payload }; + default: + return state; + } +}; + +export default knowledgeEvolutionReducer; diff --git a/src/reducers/index.js b/src/reducers/index.js index 68ca63e94b..c2a0be0e9c 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -70,6 +70,7 @@ import issueReducer from './bmdashboard/issueReducer'; import dashboardReducer from './dashboardReducer'; import HGNFormReducer from './hgnFormReducers'; import injuriesReducer from './injuries'; +import knowledgeEvolutionReducer from './bmdashboard/knowledgeEvolutionReducer'; import { timeOffRequestsReducer } from './timeOffRequestReducer'; import { totalOrgSummaryReducer } from './totalOrgSummaryReducer'; // import { weeklyProjectSummaryReducer } from './bmdashboard/weeklyProjectSummaryReducer'; @@ -173,6 +174,7 @@ const localReducers = { dashboard: dashboardReducer, injuries: injuriesReducer, weeklyProjectSummary: weeklyProjectSummaryReducer, + knowledgeEvolution: knowledgeEvolutionReducer, costBreakdown: costBreakdownReducer, // lbdashboard diff --git a/src/routes.jsx b/src/routes.jsx index f2fc47c514..70061df044 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -53,6 +53,7 @@ import EquipmentUpdate from './components/BMDashboard/Tools/EquipmentUpdate'; import Toolslist from './components/BMDashboard/Tools/ToolsList'; import WeeklyProjectSummary from './components/BMDashboard/WeeklyProjectSummary/WeeklyProjectSummary'; import AnalyticsDashboard from './components/JobCCDashboard/JobAnalytics/JobAnalytics'; +import KnowledgeEvolution from './components/BMDashboard/KnowledgeEvolution/KnowledgeEvolution'; import { ExperienceDonutChart } from './components/ExperienceDonutChart'; import FaqHistory from './components/Faq/FaqHistory'; import FaqManagement from './components/Faq/FaqManagement'; @@ -956,6 +957,11 @@ export default ( exact component={ToolsAvailabilityPage} /> + `${APIEndpoint}/bm/orgLocation/${projectId}`, BM_PROJECT_MEMBERS: projectId => `${APIEndpoint}/bm/project/${projectId}/users`, + KNOWLEDGE_EVOLUTION: `${APIEndpoint}/student/knowledge-evolution`, BM_UPDATE_NAME_AND_UNIT: invtypeId => `${APIEndpoint}/bm/invtypes/material/${invtypeId}`, BM_ITEM_UPDATE_HISTORY: invtypeId => `${APIEndpoint}/bm/invtypes/${invtypeId}/history`,