586 lines
21 KiB
JavaScript
586 lines
21 KiB
JavaScript
// Dashboard.js
|
|
|
|
import axios from 'axios';
|
|
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js';
|
|
|
|
import { CareerSuggestions } from './CareerSuggestions.js';
|
|
import PopoutPanel from './PopoutPanel.js';
|
|
import MilestoneTracker from './MilestoneTracker.js';
|
|
import CareerSearch from './CareerSearch.js'; // <--- Import your new search
|
|
import Chatbot from "./Chatbot.js";
|
|
|
|
import './Dashboard.css';
|
|
import { Bar } from 'react-chartjs-2';
|
|
import { fetchSchools } from '../utils/apiUtils.js';
|
|
|
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
|
|
|
function Dashboard() {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
|
|
// ============= Existing States =============
|
|
const [careerSuggestions, setCareerSuggestions] = useState([]);
|
|
const [careerDetails, setCareerDetails] = useState(null);
|
|
const [riaSecScores, setRiaSecScores] = useState([]);
|
|
const [selectedCareer, setSelectedCareer] = useState(null);
|
|
const [schools, setSchools] = useState([]);
|
|
const [salaryData, setSalaryData] = useState([]);
|
|
const [economicProjections, setEconomicProjections] = useState(null);
|
|
const [tuitionData, setTuitionData] = useState(null);
|
|
|
|
// Overall Dashboard loading
|
|
const [loading, setLoading] = useState(false);
|
|
const [progress, setProgress] = useState(0);
|
|
|
|
const [error, setError] = useState(null);
|
|
const [userState, setUserState] = useState(null);
|
|
const [areaTitle, setAreaTitle] = useState(null);
|
|
const [userZipcode, setUserZipcode] = useState(null);
|
|
const [riaSecDescriptions, setRiaSecDescriptions] = useState([]);
|
|
const [selectedJobZone, setSelectedJobZone] = useState('');
|
|
const [careersWithJobZone, setCareersWithJobZone] = useState([]);
|
|
const [selectedFit, setSelectedFit] = useState('');
|
|
const [results, setResults] = useState([]);
|
|
const [chatbotContext, setChatbotContext] = useState({});
|
|
|
|
// Show session expired modal
|
|
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false);
|
|
const [sessionHandled, setSessionHandled] = useState(false);
|
|
|
|
// ============= NEW State =============
|
|
// Holds the full career object { title, soc_code, cip_code } typed in CareerSearch
|
|
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
|
|
|
|
// We'll treat "loading" as "loadingSuggestions"
|
|
const loadingSuggestions = loading;
|
|
// We'll consider the popout panel visible if there's a selectedCareer
|
|
const popoutVisible = !!selectedCareer;
|
|
|
|
// ============= Auth & URL Setup =============
|
|
const apiUrl = process.env.REACT_APP_API_URL || '';
|
|
|
|
// AUTH fetch
|
|
const authFetch = async (url, options = {}, onUnauthorized) => {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) {
|
|
console.log("Token is missing, triggering session expired modal.");
|
|
if (typeof onUnauthorized === 'function') onUnauthorized();
|
|
return null;
|
|
}
|
|
const finalOptions = {
|
|
...options,
|
|
headers: {
|
|
...(options.headers || {}),
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
};
|
|
try {
|
|
const res = await fetch(url, finalOptions);
|
|
console.log("Response Status:", res.status);
|
|
if (res.status === 401 || res.status === 403) {
|
|
console.log("Session expired, triggering session expired modal.");
|
|
if (typeof onUnauthorized === 'function') onUnauthorized();
|
|
return null;
|
|
}
|
|
return res;
|
|
} catch (err) {
|
|
console.error("Fetch error:", err);
|
|
if (typeof onUnauthorized === 'function') onUnauthorized();
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// ============= User Profile Fetch =============
|
|
const fetchUserProfile = async () => {
|
|
const res = await authFetch(`${apiUrl}/user-profile`);
|
|
if (!res) return;
|
|
|
|
if (res.ok) {
|
|
const profileData = await res.json();
|
|
setUserState(profileData.state);
|
|
setAreaTitle(profileData.area.trim() || '');
|
|
setUserZipcode(profileData.zipcode);
|
|
} else {
|
|
console.error('Failed to fetch user profile');
|
|
}
|
|
};
|
|
|
|
// ============= Lifecycle: Load Profile, Setup =============
|
|
useEffect(() => {
|
|
fetchUserProfile();
|
|
}, [apiUrl]); // load once
|
|
|
|
// ============= jobZone & fit Setup =============
|
|
const jobZoneLabels = {
|
|
'1': 'Little or No Preparation',
|
|
'2': 'Some Preparation Needed',
|
|
'3': 'Medium Preparation Needed',
|
|
'4': 'Considerable Preparation Needed',
|
|
'5': 'Extensive Preparation Needed'
|
|
};
|
|
|
|
const fitLabels = {
|
|
'Best': 'Best - Very Strong Match',
|
|
'Great': 'Great - Strong Match',
|
|
'Good': 'Good - Less Strong Match'
|
|
};
|
|
|
|
// Fetch job zones for each career suggestion
|
|
useEffect(() => {
|
|
const fetchJobZones = async () => {
|
|
if (careerSuggestions.length === 0) return;
|
|
const socCodes = careerSuggestions.map((career) => career.code);
|
|
try {
|
|
const response = await axios.post(`${apiUrl}/job-zones`, { socCodes });
|
|
const jobZoneData = response.data;
|
|
const updatedCareers = careerSuggestions.map((career) => ({
|
|
...career,
|
|
job_zone: jobZoneData[career.code.slice(0, -3)]?.job_zone || null,
|
|
}));
|
|
setCareersWithJobZone(updatedCareers);
|
|
} catch (error) {
|
|
console.error('Error fetching job zone information:', error);
|
|
}
|
|
};
|
|
fetchJobZones();
|
|
}, [careerSuggestions, apiUrl]);
|
|
|
|
// Filter careers by job zone, fit
|
|
const filteredCareers = useMemo(() => {
|
|
return careersWithJobZone.filter((career) => {
|
|
const jobZoneMatches = selectedJobZone
|
|
? career.job_zone !== null &&
|
|
career.job_zone !== undefined &&
|
|
typeof career.job_zone === 'number' &&
|
|
Number(career.job_zone) === Number(selectedJobZone)
|
|
: true;
|
|
|
|
const fitMatches = selectedFit ? career.fit === selectedFit : true;
|
|
return jobZoneMatches && fitMatches;
|
|
});
|
|
}, [careersWithJobZone, selectedJobZone, selectedFit]);
|
|
|
|
// Merge updated data into chatbot context
|
|
const updateChatbotContext = (updatedData) => {
|
|
setChatbotContext((prevContext) => {
|
|
const mergedContext = {
|
|
...prevContext,
|
|
...Object.keys(updatedData).reduce((acc, key) => {
|
|
if (updatedData[key] !== undefined && updatedData[key] !== null) {
|
|
acc[key] = updatedData[key];
|
|
}
|
|
return acc;
|
|
}, {}),
|
|
};
|
|
return mergedContext;
|
|
});
|
|
};
|
|
|
|
// Our final array for CareerSuggestions
|
|
const memoizedCareerSuggestions = useMemo(() => filteredCareers, [filteredCareers]);
|
|
|
|
// ============= Popout Panel Setup =============
|
|
const memoizedPopoutPanel = useMemo(() => {
|
|
return (
|
|
<PopoutPanel
|
|
isVisible={!!selectedCareer}
|
|
data={careerDetails}
|
|
schools={schools}
|
|
salaryData={salaryData}
|
|
economicProjections={economicProjections}
|
|
tuitionData={tuitionData}
|
|
closePanel={() => setSelectedCareer(null)}
|
|
loading={loading}
|
|
error={error}
|
|
userState={userState}
|
|
results={results}
|
|
updateChatbotContext={updateChatbotContext}
|
|
/>
|
|
);
|
|
}, [
|
|
selectedCareer,
|
|
careerDetails,
|
|
schools,
|
|
salaryData,
|
|
economicProjections,
|
|
tuitionData,
|
|
loading,
|
|
error,
|
|
userState,
|
|
results
|
|
]);
|
|
|
|
// ============= On Page Load: get careerSuggestions from location.state, etc. =============
|
|
useEffect(() => {
|
|
let descriptions = [];
|
|
if (location.state) {
|
|
const { careerSuggestions: suggestions, riaSecScores: scores } = location.state || {};
|
|
descriptions = (scores || []).map((score) => score.description || "No description available.");
|
|
setCareerSuggestions(suggestions || []);
|
|
setRiaSecScores(scores || []);
|
|
setRiaSecDescriptions(descriptions);
|
|
} else {
|
|
console.warn('No data found, redirecting to Interest Inventory');
|
|
navigate('/interest-inventory');
|
|
}
|
|
}, [location.state, navigate]);
|
|
|
|
// Once userState, areaTitle, userZipcode, etc. are set, update chatbot
|
|
useEffect(() => {
|
|
if (
|
|
careerSuggestions.length > 0 &&
|
|
riaSecScores.length > 0 &&
|
|
userState !== null &&
|
|
areaTitle !== null &&
|
|
userZipcode !== null
|
|
) {
|
|
const newChatbotContext = {
|
|
careerSuggestions: [...careersWithJobZone],
|
|
riaSecScores: [...riaSecScores],
|
|
userState: userState || "",
|
|
areaTitle: areaTitle || "",
|
|
userZipcode: userZipcode || "",
|
|
};
|
|
setChatbotContext(newChatbotContext);
|
|
}
|
|
}, [careerSuggestions, riaSecScores, userState, areaTitle, userZipcode, careersWithJobZone]);
|
|
|
|
// ============= handleCareerClick (for tile clicks) =============
|
|
const handleCareerClick = useCallback(
|
|
async (career) => {
|
|
console.log('[handleCareerClick] career =>', career);
|
|
const socCode = career.code;
|
|
console.log('[handleCareerClick] career.code =>', socCode);
|
|
setSelectedCareer(career);
|
|
setLoading(true);
|
|
setError(null);
|
|
setCareerDetails({});
|
|
setSchools([]);
|
|
setSalaryData([]);
|
|
setEconomicProjections({});
|
|
setTuitionData([]);
|
|
|
|
if (!socCode) {
|
|
console.error('SOC Code is missing');
|
|
setError('SOC Code is missing');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// CIP fetch
|
|
const cipResponse = await fetch(`${apiUrl}/cip/${socCode}`);
|
|
if (!cipResponse.ok) throw new Error('Failed to fetch CIP Code');
|
|
const { cipCode } = await cipResponse.json();
|
|
const cleanedCipCode = cipCode.replace('.', '').slice(0, 4);
|
|
|
|
// Job details
|
|
const jobDetailsResponse = await fetch(`${apiUrl}/onet/career-description/${socCode}`);
|
|
if (!jobDetailsResponse.ok) throw new Error('Failed to fetch job description');
|
|
const { description, tasks } = await jobDetailsResponse.json();
|
|
|
|
// Salary
|
|
let salaryResponse;
|
|
try {
|
|
salaryResponse = await axios.get(`${apiUrl}/salary`, { params: { socCode: socCode.split('.')[0], area: areaTitle } });
|
|
} catch (error) {
|
|
salaryResponse = { data: {} };
|
|
}
|
|
|
|
// Economic
|
|
let economicResponse;
|
|
try {
|
|
economicResponse = await axios.get(`${apiUrl}/projections/${socCode.split('.')[0]}`);
|
|
} catch (error) {
|
|
economicResponse = { data: {} };
|
|
}
|
|
|
|
// Tuition
|
|
let tuitionResponse;
|
|
try {
|
|
tuitionResponse = await axios.get(`${apiUrl}/tuition`, { params: { cipCode: cleanedCipCode, state: userState } });
|
|
} catch (error) {
|
|
tuitionResponse = { data: {} };
|
|
}
|
|
|
|
// Fetch schools
|
|
const filteredSchools = await fetchSchools(cleanedCipCode, userState);
|
|
const schoolsWithDistance = await Promise.all(filteredSchools.map(async (school) => {
|
|
const schoolAddress = `${school.Address}, ${school.City}, ${school.State} ${school.ZIP}`;
|
|
try {
|
|
const response = await axios.post(`${apiUrl}/maps/distance`, {
|
|
userZipcode,
|
|
destinations: schoolAddress,
|
|
});
|
|
const { distance, duration } = response.data;
|
|
return { ...school, distance, duration };
|
|
} catch (error) {
|
|
return { ...school, distance: 'N/A', duration: 'N/A' };
|
|
}
|
|
}));
|
|
|
|
// Build salary array
|
|
const sData = salaryResponse.data || {};
|
|
const salaryDataPoints = sData && Object.keys(sData).length > 0
|
|
? [
|
|
{
|
|
percentile: "10th Percentile",
|
|
regionalSalary: parseInt(sData.regional?.regional_PCT10, 10) || 0,
|
|
nationalSalary: parseInt(sData.national?.national_PCT10, 10) || 0
|
|
},
|
|
{
|
|
percentile: "25th Percentile",
|
|
regionalSalary: parseInt(sData.regional?.regional_PCT25, 10) || 0,
|
|
nationalSalary: parseInt(sData.national?.national_PCT25, 10) || 0
|
|
},
|
|
{
|
|
percentile: "Median",
|
|
regionalSalary: parseInt(sData.regional?.regional_MEDIAN, 10) || 0,
|
|
nationalSalary: parseInt(sData.national?.national_MEDIAN, 10) || 0
|
|
},
|
|
{
|
|
percentile: "75th Percentile",
|
|
regionalSalary: parseInt(sData.regional?.regional_PCT75, 10) || 0,
|
|
nationalSalary: parseInt(sData.national?.national_PCT75, 10) || 0
|
|
},
|
|
{
|
|
percentile: "90th Percentile",
|
|
regionalSalary: parseInt(sData.regional?.regional_PCT90, 10) || 0,
|
|
nationalSalary: parseInt(sData.national?.national_PCT90, 10) || 0
|
|
},
|
|
]
|
|
: [];
|
|
|
|
// Build final details
|
|
const updatedCareerDetails = {
|
|
...career,
|
|
jobDescription: description,
|
|
tasks: tasks,
|
|
economicProjections: (economicResponse.data || {}),
|
|
salaryData: salaryDataPoints,
|
|
schools: schoolsWithDistance,
|
|
tuitionData: (tuitionResponse.data || []),
|
|
};
|
|
|
|
setCareerDetails(updatedCareerDetails);
|
|
updateChatbotContext({ careerDetails: updatedCareerDetails });
|
|
|
|
} catch (error) {
|
|
console.error('Error processing career click:', error.message);
|
|
setError('Failed to load data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[userState, apiUrl, areaTitle, userZipcode, updateChatbotContext]
|
|
);
|
|
|
|
// ============= Letting typed careers open PopoutPanel =============
|
|
// Called if the user picks a career in "CareerSearch" => { title, soc_code, cip_code }
|
|
const handleCareerFromSearch = useCallback((obj) => {
|
|
// Convert to shape used by handleCareerClick => { code, title, cipCode }
|
|
const adapted = {
|
|
code: obj.soc_code,
|
|
title: obj.title,
|
|
cipCode: obj.cip_code
|
|
};
|
|
console.log('[Dashboard -> handleCareerFromSearch] adapted =>', adapted);
|
|
handleCareerClick(adapted);
|
|
}, [handleCareerClick]);
|
|
|
|
// If the user typed a career and clicked confirm
|
|
useEffect(() => {
|
|
if (pendingCareerForModal) {
|
|
console.log('[useEffect] pendingCareerForModal =>', pendingCareerForModal);
|
|
handleCareerFromSearch(pendingCareerForModal);
|
|
setPendingCareerForModal(null);
|
|
}
|
|
}, [pendingCareerForModal, handleCareerFromSearch]);
|
|
|
|
// ============= RIASEC Chart Data =============
|
|
const chartData = {
|
|
labels: riaSecScores.map((score) => score.area),
|
|
datasets: [
|
|
{
|
|
label: 'RIASEC Scores',
|
|
data: riaSecScores.map((score) => score.score),
|
|
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
|
borderColor: 'rgba(75, 192, 192, 1)',
|
|
borderWidth: 1,
|
|
},
|
|
],
|
|
};
|
|
|
|
// ============= Hide the spinner if popout is open =============
|
|
const renderLoadingOverlay = () => {
|
|
if (!loadingSuggestions || popoutVisible) return null;
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-50">
|
|
<div className="rounded bg-white p-6 shadow-lg">
|
|
<div className="mb-2 w-full max-w-md rounded bg-gray-200">
|
|
<div
|
|
className="h-2 rounded bg-blue-500 transition-all"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
<p className="mt-1 text-center text-sm text-gray-600">
|
|
{progress}% — Loading Career Suggestions...
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="dashboard">
|
|
{showSessionExpiredModal && (
|
|
<div className="modal-overlay">
|
|
<div className="modal">
|
|
<h3>Session Expired</h3>
|
|
<p>Your session has expired or is invalid.</p>
|
|
<div className="modal-actions">
|
|
<button className="confirm-btn" onClick={() => setShowSessionExpiredModal(false)}>
|
|
Stay Signed In
|
|
</button>
|
|
<button
|
|
className="confirm-btn"
|
|
onClick={() => {
|
|
localStorage.removeItem("token");
|
|
localStorage.removeItem("UserId");
|
|
setShowSessionExpiredModal(false);
|
|
navigate("/signin");
|
|
}}
|
|
>
|
|
Sign In Again
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{renderLoadingOverlay()}
|
|
|
|
<div className="dashboard-content">
|
|
{/* ====== 1) The new CareerSearch bar ====== */}
|
|
|
|
|
|
{/* Existing filters + suggestions */}
|
|
<div className="career-suggestions-container">
|
|
<div
|
|
className="career-suggestions-header"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
marginBottom: '15px',
|
|
justifyContent: 'center',
|
|
gap: '15px'
|
|
}}
|
|
>
|
|
<label>
|
|
Preparation Level:
|
|
<select
|
|
value={selectedJobZone}
|
|
onChange={(e) => setSelectedJobZone(Number(e.target.value))}
|
|
style={{ marginLeft: '5px', padding: '2px', width: '200px' }}
|
|
>
|
|
<option value="">All Preparation Levels</option>
|
|
{Object.entries(jobZoneLabels).map(([zone, label]) => (
|
|
<option key={zone} value={zone}>{label}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<label>
|
|
Fit:
|
|
<select
|
|
value={selectedFit}
|
|
onChange={(e) => setSelectedFit(e.target.value)}
|
|
style={{ marginLeft: '5px', padding: '2px', width: '150px' }}
|
|
>
|
|
<option value="">All Fit Levels</option>
|
|
{Object.entries(fitLabels).map(([key, label]) => (
|
|
<option key={key} value={key}>{label}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div style={{ marginLeft: 'auto' }}>
|
|
<CareerSearch
|
|
onCareerSelected={(careerObj) => {
|
|
console.log('[Dashboard] onCareerSelected =>', careerObj);
|
|
// Set the "pendingCareerForModal" so our useEffect fires below
|
|
setPendingCareerForModal(careerObj);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<CareerSuggestions
|
|
careerSuggestions={memoizedCareerSuggestions}
|
|
onCareerClick={handleCareerClick}
|
|
setLoading={setLoading}
|
|
setProgress={setProgress}
|
|
userState={userState}
|
|
areaTitle={areaTitle}
|
|
/>
|
|
</div>
|
|
|
|
{/* RIASEC Container */}
|
|
<div className="riasec-container">
|
|
<div className="riasec-scores">
|
|
<h2>RIASEC Scores</h2>
|
|
<Bar data={chartData} />
|
|
</div>
|
|
<div className="riasec-descriptions">
|
|
<h3>RIASEC Personality Descriptions</h3>
|
|
{riaSecDescriptions.length > 0 ? (
|
|
<ul>
|
|
{riaSecDescriptions.map((desc, index) => (
|
|
<li key={index}>
|
|
<strong>{riaSecScores[index]?.area}:</strong> {desc}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p>Loading descriptions...</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* The PopoutPanel */}
|
|
{memoizedPopoutPanel}
|
|
|
|
{/* Chatbot */}
|
|
<div className="chatbot-widget">
|
|
{careerSuggestions.length > 0 ? (
|
|
<Chatbot context={chatbotContext} />
|
|
) : (
|
|
<p>Loading Chatbot...</p>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className="data-source-acknowledgment"
|
|
style={{
|
|
marginTop: '20px',
|
|
padding: '10px',
|
|
borderTop: '1px solid #ccc',
|
|
fontSize: '12px',
|
|
color: '#666',
|
|
textAlign: 'center'
|
|
}}
|
|
>
|
|
<p>
|
|
Career results and RIASEC scores are provided by
|
|
<a href="https://www.onetcenter.org" target="_blank" rel="noopener noreferrer"> O*Net</a>, in conjunction with the
|
|
<a href="https://www.bls.gov" target="_blank" rel="noopener noreferrer"> Bureau of Labor Statistics</a>, and the
|
|
<a href="https://nces.ed.gov" target="_blank" rel="noopener noreferrer"> National Center for Education Statistics (NCES)</a>.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Dashboard;
|