Fixed Loading bar in Dashboard, updated InterestInventory UI.

This commit is contained in:
Josh 2025-04-30 16:22:33 +00:00
parent 359b69bbb6
commit 961f994220
4 changed files with 278 additions and 198 deletions

View File

@ -1,121 +1,121 @@
import React, { useEffect, useState } from 'react';
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 [loading, setLoading] = useState(true);
const [progress, setProgress] = useState(0);
useEffect(() => {
// If no careers provided, stop any loading state
if (!careerSuggestions || careerSuggestions.length === 0) {
setLoading(false);
return;
}
const token = localStorage.getItem('token'); // Get auth token
const token = localStorage.getItem('token');
const checkCareerDataAvailability = async () => {
setLoading(true);
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;
// Helper function to increment the global progress
const updateProgress = () => {
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) => {
try {
const headers = {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
// e.g. "15-1199.00" => "15-1199"
const strippedSoc = career.code.split('.')[0];
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 {
const response = await axios.get(url, { headers });
updateProgress(); // ✅ Update progress on success
return response.data;
} 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);
}
});
try {
const updatedCareerList = await Promise.all(careerPromises);
setUpdatedCareers(updatedCareerList);
} finally {
setLoading(false);
}
};
};
checkCareerDataAvailability();
}, [careerSuggestions, apiUrl, userState, areaTitle]);
}, [careerSuggestions, userState, areaTitle, setLoading, setProgress]);
return (
<div>
{loading ? (
<div className="progress-container">
<div className="progress-bar" style={{
width: `${progress}%`,
maxWidth: "100%", }}>
{Math.round(progress)}%
</div>
<p>Loading Career Suggestions...</p>
</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 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>
);
}

View File

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

View File

@ -2,66 +2,57 @@ import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ClipLoader } from 'react-spinners';
import authFetch from '../utils/authFetch.js';
import './InterestInventory.css';
const InterestInventory = () => {
const [questions, setQuestions] = useState([]);
const [responses, setResponses] = useState({});
const [currentPage, setCurrentPage] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const questionsPerPage = 6;
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = 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 () => {
setLoading(true); // Start loading
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
}
};
const questionsPerPage = 6;
const totalPages = Math.ceil(questions.length / questionsPerPage) || 1;
useEffect(() => {
fetchQuestions();
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 () => {
try {
const res = await authFetch('/api/user-profile', {
method: 'GET',
});
const res = await authFetch('/api/user-profile', { method: 'GET' });
if (!res || !res.ok) throw new Error('Failed to fetch user profile');
const data = await res.json();
setUserProfile(data);
} catch (err) {
@ -69,11 +60,10 @@ const InterestInventory = () => {
}
};
// Restore previously saved answers if available
useEffect(() => {
const storedAnswers = userProfile?.interest_inventory_answers;
if (questions.length === 60 && storedAnswers && storedAnswers.length === 60) {
if (questions.length === 60 && storedAnswers?.length === 60) {
const restored = {};
storedAnswers.split('').forEach((val, index) => {
restored[index + 1] = val;
@ -82,22 +72,13 @@ const InterestInventory = () => {
}
}, [questions, userProfile]);
const handleResponseChange = (questionIndex, value) => {
setResponses((prevResponses) => ({
...prevResponses,
setResponses((prev) => ({
...prev,
[questionIndex]: value,
}));
};
const randomizeAnswers = () => {
const randomizedResponses = {};
questions.forEach((question) => {
randomizedResponses[question.index] = Math.floor(Math.random() * 5) + 1;
});
setResponses(randomizedResponses);
};
const validateCurrentPage = () => {
const start = (currentPage - 1) * questionsPerPage;
const end = currentPage * questionsPerPage;
@ -106,9 +87,8 @@ const InterestInventory = () => {
const unanswered = currentQuestions.filter(
(q) => !responses[q.index] || responses[q.index] === '0'
);
if (unanswered.length > 0) {
alert(`Please answer all questions before proceeding.`);
alert('Please answer all questions before proceeding.');
return false;
}
return true;
@ -125,32 +105,45 @@ const InterestInventory = () => {
}
};
const randomizeAnswers = () => {
const randomized = {};
questions.forEach((question) => {
randomized[question.index] = Math.floor(Math.random() * 5) + 1; // 15
});
setResponses(randomized);
};
const handleSubmit = async () => {
if (!validateCurrentPage()) return;
// Combine answers into a 60-char string
const answers = Array.from({ length: 60 }, (_, i) => responses[i + 1] || '0').join('');
await authFetch(`${apiUrl}/user-profile`, {
method: 'POST',
body: JSON.stringify({
firstName: userProfile?.firstname,
lastName: userProfile?.lastname,
email: userProfile?.email,
zipCode: userProfile?.zipcode,
state: userProfile?.state,
area: userProfile?.area,
careerSituation: userProfile?.career_situation || null,
interest_inventory_answers: answers,
}),
});
// First save the answers to user profile
try {
await authFetch('/api/user-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: userProfile?.firstname,
lastName: userProfile?.lastname,
email: userProfile?.email,
zipCode: userProfile?.zipcode,
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 {
setIsSubmitting(true);
setError(null); // Clear previous errors
const url = `${process.env.REACT_APP_API_URL}/onet/submit_answers`;
const response = await authFetch(url, {
setError(null);
const response = await authFetch(`${process.env.REACT_APP_API_URL}/onet/submit_answers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
@ -159,43 +152,77 @@ const InterestInventory = () => {
if (!response.ok) {
throw new Error(`Failed to submit answers: ${response.statusText}`);
}
const data = await response.json();
console.log("Careers Response:", data);
const { careers: careerSuggestions, riaSecScores } = data;
if (Array.isArray(careerSuggestions) && Array.isArray(riaSecScores)) {
navigate('/dashboard', { state: { careerSuggestions, riaSecScores } });
} else {
console.error("Invalid data format:", data);
alert("Failed to process results. Please try again later.");
throw new Error('Invalid data format from the server.');
}
} catch (error) {
console.error("Error submitting answers:", error.message);
alert("Failed to submit answers. Please try again later.");
console.error('Error submitting answers:', error.message);
alert('Failed to submit answers. Please try again later.');
} finally {
setIsSubmitting(false);
}
};
// Compute which questions to show
const start = (currentPage - 1) * questionsPerPage;
const end = currentPage * questionsPerPage;
const currentQuestions = questions.slice(start, end);
return (
<div className="interest-inventory-container">
<h2>Interest Inventory</h2>
{loading && <ClipLoader size={35} color="#4A90E2" />}
{error && <p style={{ color: 'red' }}>{error}</p>}
// Calculate progress for the bar
const totalQuestions = 60;
const answeredCount = Object.keys(responses).filter((key) => responses[key] !== '0').length;
const progressPercent = Math.round((answeredCount / totalQuestions) * 100);
{questions.length > 0 ? (
<div className="questions-container">
return (
<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) => (
<div key={question.index} className="question">
<label>{question.text}</label>
<div key={question.index} className="flex flex-col">
<label className="mb-1 font-medium text-gray-700">
{question.text}
</label>
<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)}
value={responses[question.index] || '0'}
>
@ -209,20 +236,47 @@ const InterestInventory = () => {
</div>
))}
</div>
) : (
<p>Loading questions.</p>
)}
<div className="pagination-buttons">
{currentPage > 1 && (
<button type="button" onClick={handlePreviousPage}>Previous</button>
)}
{currentPage * questionsPerPage < questions.length ? (
<button type="button" onClick={handleNextPage}>Next</button>
) : (
<button type="button" onClick={handleSubmit} disabled={isSubmitting}>Submit</button>
)}
<button type="button" onClick={randomizeAnswers} disabled={isSubmitting}>Randomize Answers</button>
{/* Pagination / Action Buttons */}
<div className="mt-6 flex flex-wrap items-center justify-between space-y-2 sm:space-y-0">
<div className="flex space-x-2">
{currentPage > 1 && (
<button
type="button"
onClick={handlePreviousPage}
className="rounded bg-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-400"
>
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>
);

Binary file not shown.