Fixed Loading bar in Dashboard, updated InterestInventory UI.

This commit is contained in:
Josh 2025-04-30 16:22:33 +00:00
parent c96cea59bd
commit 0738457a83
4 changed files with 278 additions and 198 deletions

View File

@ -1,121 +1,121 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import axios from 'axios'; import axios from 'axios';
import './Dashboard.css'; import './Dashboard.css'; // or replace with Tailwind classes if desired
const apiUrl = process.env.REACT_APP_API_URL || ''; // ✅ Load API URL directly const apiUrl = process.env.REACT_APP_API_URL || '';
export function CareerSuggestions({ careerSuggestions = [], userState, areaTitle, onCareerClick }) { export function CareerSuggestions({
careerSuggestions = [],
userState,
areaTitle,
setLoading,
setProgress,
onCareerClick,
}) {
const [updatedCareers, setUpdatedCareers] = useState([]); const [updatedCareers, setUpdatedCareers] = useState([]);
const [loading, setLoading] = useState(true);
const [progress, setProgress] = useState(0);
useEffect(() => { useEffect(() => {
// If no careers provided, stop any loading state
if (!careerSuggestions || careerSuggestions.length === 0) { if (!careerSuggestions || careerSuggestions.length === 0) {
setLoading(false); setLoading(false);
return; return;
} }
const token = localStorage.getItem('token'); // Get auth token const token = localStorage.getItem('token');
const checkCareerDataAvailability = async () => { const checkCareerDataAvailability = async () => {
setLoading(true); setLoading(true);
setProgress(0); setProgress(0);
const totalSteps = careerSuggestions.length * 4; // Each career has 4 API checks
// Each career has 4 external calls
const totalSteps = careerSuggestions.length * 4;
let completedSteps = 0; let completedSteps = 0;
// Helper function to increment the global progress
const updateProgress = () => { const updateProgress = () => {
completedSteps += 1; completedSteps += 1;
setProgress((completedSteps / totalSteps) * 100); const percent = Math.round((completedSteps / totalSteps) * 100);
setProgress(percent);
}; };
// Universal fetch helper
const fetchJSON = async (url, params) => {
try {
const response = await axios.get(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
params: params || {},
});
updateProgress(); // increment if success
return response.data;
} catch (error) {
updateProgress(); // increment even on failure
return null;
}
};
// Map over careerSuggestions to fetch CIP, job details, economic, salary data in parallel
const careerPromises = careerSuggestions.map(async (career) => { const careerPromises = careerSuggestions.map(async (career) => {
try { try {
const headers = { // e.g. "15-1199.00" => "15-1199"
Authorization: `Bearer ${token}`, const strippedSoc = career.code.split('.')[0];
Accept: 'application/json',
const [cipData, jobDetailsData, economicData, salaryData] = await Promise.all([
fetchJSON(`${apiUrl}/cip/${career.code}`),
fetchJSON(`${apiUrl}/onet/career-description/${career.code}`),
fetchJSON(`${apiUrl}/projections/${strippedSoc}`),
fetchJSON(`${apiUrl}/salary`, {
socCode: strippedSoc,
area: areaTitle,
}),
]);
// Evaluate if any data is missing
const isCipMissing = !cipData || Object.keys(cipData).length === 0;
const isJobDetailsMissing = !jobDetailsData || Object.keys(jobDetailsData).length === 0;
const isEconomicMissing =
!economicData ||
Object.values(economicData).every((val) => val === 'N/A' || val === '*');
const isSalaryMissing = !salaryData;
const isLimitedData =
isCipMissing || isJobDetailsMissing || isEconomicMissing || isSalaryMissing;
return {
...career,
limitedData: isLimitedData,
}; };
} catch (err) {
// If any errors occur mid-logic, mark it limited
return { ...career, limitedData: true };
}
});
const fetchJSON = async (url) => { try {
try { const updatedCareerList = await Promise.all(careerPromises);
const response = await axios.get(url, { headers }); setUpdatedCareers(updatedCareerList);
updateProgress(); // ✅ Update progress on success } finally {
return response.data; setLoading(false);
} catch (error) {
updateProgress(); // ✅ Update progress even if failed
return null;
}
};
// Fetch Data in Parallel
const [cipData, jobDetailsData, economicData, salaryResponse] = await Promise.all([
fetchJSON(`${apiUrl}/cip/${career.code}`),
fetchJSON(`${apiUrl}/onet/career-description/${career.code}`),
fetchJSON(`${apiUrl}/projections/${career.code.split('.')[0]}`),
axios.get(`${apiUrl}/salary`, {
params: { socCode: career.code.split('.')[0], area: areaTitle },
headers,
}).then((res) => {
updateProgress();
return res.data;
}).catch((error) => {
updateProgress();
if (error.response?.status === 404) {
return null;
}
return error.response;
}),
]);
const isCipMissing = !cipData || Object.keys(cipData).length === 0;
const isJobDetailsMissing = !jobDetailsData || Object.keys(jobDetailsData).length === 0;
const isEconomicMissing = !economicData || Object.values(economicData).every(val => val === "N/A" || val === "*");
const isSalaryMissing = salaryResponse === null || salaryResponse === undefined;
const isLimitedData = isCipMissing || isJobDetailsMissing || isEconomicMissing || isSalaryMissing;
return { ...career, limitedData: isLimitedData };
} catch (error) {
return { ...career, limitedData: true };
} }
}); };
try {
const updatedCareerList = await Promise.all(careerPromises);
setUpdatedCareers(updatedCareerList);
} finally {
setLoading(false);
}
};
checkCareerDataAvailability(); checkCareerDataAvailability();
}, [careerSuggestions, apiUrl, userState, areaTitle]); }, [careerSuggestions, userState, areaTitle, setLoading, setProgress]);
return ( return (
<div> <div className="career-suggestions-grid">
{updatedCareers.map((career) => (
{loading ? ( <button
<div className="progress-container"> key={career.code}
<div className="progress-bar" style={{ className={`career-button ${career.limitedData ? 'limited-data' : ''}`}
width: `${progress}%`, onClick={() => onCareerClick(career)}
maxWidth: "100%", }}> >
{Math.round(progress)}% {career.title}
</div> {career.limitedData && <span className="warning-icon"> </span>}
<p>Loading Career Suggestions...</p> </button>
</div> ))}
) : (
<div className="career-suggestions-grid">
{updatedCareers.map((career) => (
<button
key={career.code}
className={`career-button ${career.limitedData ? 'limited-data' : ''}`}
onClick={() => onCareerClick(career)}
>
{career.title} {career.limitedData && <span className="warning-icon"></span>}
</button>
))}
</div>
)}
</div> </div>
); );
} }

View File

@ -26,6 +26,7 @@ function Dashboard() {
const [economicProjections, setEconomicProjections] = useState(null); const [economicProjections, setEconomicProjections] = useState(null);
const [tuitionData, setTuitionData] = useState(null); const [tuitionData, setTuitionData] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [userState, setUserState] = useState(null); const [userState, setUserState] = useState(null);
const [areaTitle, setAreaTitle] = useState(null); const [areaTitle, setAreaTitle] = useState(null);
@ -359,6 +360,26 @@ function Dashboard() {
], ],
}; };
const renderLoadingOverlay = () => {
if (!loading) 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 ( return (
<div className="dashboard"> <div className="dashboard">
{showSessionExpiredModal && ( {showSessionExpiredModal && (
@ -383,6 +404,8 @@ function Dashboard() {
</div> </div>
)} )}
{renderLoadingOverlay()}
<div className="dashboard-content"> <div className="dashboard-content">
<div className="career-suggestions-container"> <div className="career-suggestions-container">
@ -427,7 +450,10 @@ function Dashboard() {
<CareerSuggestions <CareerSuggestions
careerSuggestions={memoizedCareerSuggestions} careerSuggestions={memoizedCareerSuggestions}
onCareerClick={handleCareerClick} onCareerClick={handleCareerClick}
/> setLoading={setLoading}
setProgress={setProgress}
userState={userState}
areaTitle={areaTitle}/>
</div> </div>
<div className="riasec-container"> <div className="riasec-container">

View File

@ -1,79 +1,69 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ClipLoader } from 'react-spinners'; import { ClipLoader } from 'react-spinners';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import './InterestInventory.css';
const InterestInventory = () => { const InterestInventory = () => {
const [questions, setQuestions] = useState([]); const [questions, setQuestions] = useState([]);
const [responses, setResponses] = useState({}); const [responses, setResponses] = useState({});
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const questionsPerPage = 6;
const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [userProfile, setUserProfile] = useState(null); const [userProfile, setUserProfile] = useState(null);
const userId = localStorage.getItem('userId');
const apiUrl = process.env.REACT_APP_API_URL || ''; const navigate = useNavigate();
const fetchQuestions = async () => { const questionsPerPage = 6;
setLoading(true); // Start loading const totalPages = Math.ceil(questions.length / questionsPerPage) || 1;
setError(null); // Reset error state
const url = '/api/onet/questions?start=1&end=60';
try {
const response = await authFetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Failed to fetch questions: ${response.statusText}`);
}
const data = await response.json();
if (data && Array.isArray(data.questions)) {
setQuestions(data.questions);
console.log("Questions fetched:", data.questions);
} else {
throw new Error("Invalid question format.");
}
} catch (error) {
console.error("Error fetching questions:", error.message);
setError(error.message); // Set error message
} finally {
setLoading(false); // Stop loading
}
};
useEffect(() => { useEffect(() => {
fetchQuestions(); fetchQuestions();
fetchUserProfile(); fetchUserProfile();
}, []); }, []);
const fetchQuestions = async () => {
setLoading(true);
setError(null);
try {
const response = await authFetch('/api/onet/questions?start=1&end=60', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Failed to fetch questions: ${response.statusText}`);
}
const data = await response.json();
if (data && Array.isArray(data.questions)) {
setQuestions(data.questions);
} else {
throw new Error('Invalid question format.');
}
} catch (err) {
setError(err.message);
console.error('Error fetching questions:', err.message);
} finally {
setLoading(false);
}
};
const fetchUserProfile = async () => { const fetchUserProfile = async () => {
try { try {
const res = await authFetch('/api/user-profile', { const res = await authFetch('/api/user-profile', { method: 'GET' });
method: 'GET',
});
if (!res || !res.ok) throw new Error('Failed to fetch user profile'); if (!res || !res.ok) throw new Error('Failed to fetch user profile');
const data = await res.json(); const data = await res.json();
setUserProfile(data); setUserProfile(data);
} catch (err) { } catch (err) {
console.error('Error fetching user profile:', err.message); console.error('Error fetching user profile:', err.message);
} }
}; };
// Restore previously saved answers if available
useEffect(() => { useEffect(() => {
const storedAnswers = userProfile?.interest_inventory_answers; const storedAnswers = userProfile?.interest_inventory_answers;
if (questions.length === 60 && storedAnswers && storedAnswers.length === 60) { if (questions.length === 60 && storedAnswers?.length === 60) {
const restored = {}; const restored = {};
storedAnswers.split('').forEach((val, index) => { storedAnswers.split('').forEach((val, index) => {
restored[index + 1] = val; restored[index + 1] = val;
@ -81,23 +71,14 @@ const InterestInventory = () => {
setResponses(restored); setResponses(restored);
} }
}, [questions, userProfile]); }, [questions, userProfile]);
const handleResponseChange = (questionIndex, value) => { const handleResponseChange = (questionIndex, value) => {
setResponses((prevResponses) => ({ setResponses((prev) => ({
...prevResponses, ...prev,
[questionIndex]: value, [questionIndex]: value,
})); }));
}; };
const randomizeAnswers = () => {
const randomizedResponses = {};
questions.forEach((question) => {
randomizedResponses[question.index] = Math.floor(Math.random() * 5) + 1;
});
setResponses(randomizedResponses);
};
const validateCurrentPage = () => { const validateCurrentPage = () => {
const start = (currentPage - 1) * questionsPerPage; const start = (currentPage - 1) * questionsPerPage;
const end = currentPage * questionsPerPage; const end = currentPage * questionsPerPage;
@ -106,9 +87,8 @@ const InterestInventory = () => {
const unanswered = currentQuestions.filter( const unanswered = currentQuestions.filter(
(q) => !responses[q.index] || responses[q.index] === '0' (q) => !responses[q.index] || responses[q.index] === '0'
); );
if (unanswered.length > 0) { if (unanswered.length > 0) {
alert(`Please answer all questions before proceeding.`); alert('Please answer all questions before proceeding.');
return false; return false;
} }
return true; return true;
@ -125,77 +105,124 @@ const InterestInventory = () => {
} }
}; };
const randomizeAnswers = () => {
const randomized = {};
questions.forEach((question) => {
randomized[question.index] = Math.floor(Math.random() * 5) + 1; // 15
});
setResponses(randomized);
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!validateCurrentPage()) return; if (!validateCurrentPage()) return;
// Combine answers into a 60-char string
const answers = Array.from({ length: 60 }, (_, i) => responses[i + 1] || '0').join(''); const answers = Array.from({ length: 60 }, (_, i) => responses[i + 1] || '0').join('');
await authFetch(`${apiUrl}/user-profile`, { // First save the answers to user profile
method: 'POST', try {
body: JSON.stringify({ await authFetch('/api/user-profile', {
firstName: userProfile?.firstname, method: 'POST',
lastName: userProfile?.lastname, headers: { 'Content-Type': 'application/json' },
email: userProfile?.email, body: JSON.stringify({
zipCode: userProfile?.zipcode, firstName: userProfile?.firstname,
state: userProfile?.state, lastName: userProfile?.lastname,
area: userProfile?.area, email: userProfile?.email,
careerSituation: userProfile?.career_situation || null, zipCode: userProfile?.zipcode,
interest_inventory_answers: answers, state: userProfile?.state,
}), area: userProfile?.area,
}); careerSituation: userProfile?.career_situation || null,
interest_inventory_answers: answers,
}),
});
} catch (err) {
console.error('Error saving answers to user profile:', err.message);
}
// Then submit to the O*Net logic
try { try {
setIsSubmitting(true); setIsSubmitting(true);
setError(null); // Clear previous errors setError(null);
const url = `${process.env.REACT_APP_API_URL}/onet/submit_answers`; const response = await authFetch(`${process.env.REACT_APP_API_URL}/onet/submit_answers`, {
const response = await authFetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }), body: JSON.stringify({ answers }),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to submit answers: ${response.statusText}`); throw new Error(`Failed to submit answers: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
console.log("Careers Response:", data);
const { careers: careerSuggestions, riaSecScores } = data; const { careers: careerSuggestions, riaSecScores } = data;
if (Array.isArray(careerSuggestions) && Array.isArray(riaSecScores)) { if (Array.isArray(careerSuggestions) && Array.isArray(riaSecScores)) {
navigate('/dashboard', { state: { careerSuggestions, riaSecScores } }); navigate('/dashboard', { state: { careerSuggestions, riaSecScores } });
} else { } else {
console.error("Invalid data format:", data); throw new Error('Invalid data format from the server.');
alert("Failed to process results. Please try again later.");
} }
} catch (error) { } catch (error) {
console.error("Error submitting answers:", error.message); console.error('Error submitting answers:', error.message);
alert("Failed to submit answers. Please try again later."); alert('Failed to submit answers. Please try again later.');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
// Compute which questions to show
const start = (currentPage - 1) * questionsPerPage; const start = (currentPage - 1) * questionsPerPage;
const end = currentPage * questionsPerPage; const end = currentPage * questionsPerPage;
const currentQuestions = questions.slice(start, end); const currentQuestions = questions.slice(start, end);
return ( // Calculate progress for the bar
<div className="interest-inventory-container"> const totalQuestions = 60;
<h2>Interest Inventory</h2> const answeredCount = Object.keys(responses).filter((key) => responses[key] !== '0').length;
{loading && <ClipLoader size={35} color="#4A90E2" />} const progressPercent = Math.round((answeredCount / totalQuestions) * 100);
{error && <p style={{ color: 'red' }}>{error}</p>}
{questions.length > 0 ? ( return (
<div className="questions-container"> <div className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-4">
{/* Card Container */}
<div className="w-full max-w-xl rounded bg-white p-6 shadow-md">
<h2 className="mb-4 text-center text-2xl font-semibold">
Interest Inventory
</h2>
{/* Loading & Error States */}
{loading && (
<div className="flex justify-center">
<ClipLoader size={35} color="#4A90E2" />
</div>
)}
{error && (
<p className="mb-4 rounded bg-red-50 p-2 text-sm text-red-600">
{error}
</p>
)}
{/* Progress Bar & Page Indicator */}
<div className="mb-4">
<p className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</p>
<div className="mt-2 h-2 w-full overflow-hidden rounded bg-gray-200">
<div
className="h-full bg-blue-600 transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
<p className="mt-1 text-right text-xs text-gray-500">
{answeredCount} / {totalQuestions} answered
</p>
</div>
{/* Questions */}
<div className="space-y-4">
{currentQuestions.map((question) => ( {currentQuestions.map((question) => (
<div key={question.index} className="question"> <div key={question.index} className="flex flex-col">
<label>{question.text}</label> <label className="mb-1 font-medium text-gray-700">
{question.text}
</label>
<select <select
className="rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none"
onChange={(e) => handleResponseChange(question.index, e.target.value)} onChange={(e) => handleResponseChange(question.index, e.target.value)}
value={responses[question.index] || '0'} value={responses[question.index] || '0'}
> >
@ -209,20 +236,47 @@ const InterestInventory = () => {
</div> </div>
))} ))}
</div> </div>
) : (
<p>Loading questions.</p>
)}
<div className="pagination-buttons"> {/* Pagination / Action Buttons */}
{currentPage > 1 && ( <div className="mt-6 flex flex-wrap items-center justify-between space-y-2 sm:space-y-0">
<button type="button" onClick={handlePreviousPage}>Previous</button> <div className="flex space-x-2">
)} {currentPage > 1 && (
{currentPage * questionsPerPage < questions.length ? ( <button
<button type="button" onClick={handleNextPage}>Next</button> type="button"
) : ( onClick={handlePreviousPage}
<button type="button" onClick={handleSubmit} disabled={isSubmitting}>Submit</button> className="rounded bg-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-400"
)} >
<button type="button" onClick={randomizeAnswers} disabled={isSubmitting}>Randomize Answers</button> Previous
</button>
)}
{currentPage < totalPages ? (
<button
type="button"
onClick={handleNextPage}
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Next
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:bg-green-300"
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}
</div>
<button
type="button"
onClick={randomizeAnswers}
disabled={isSubmitting}
className="rounded bg-orange-500 px-4 py-2 text-white hover:bg-orange-600 disabled:bg-orange-300"
>
Randomize Answers
</button>
</div>
</div> </div>
</div> </div>
); );

Binary file not shown.