Fixed Loading bar in Dashboard, updated InterestInventory UI.
This commit is contained in:
parent
c96cea59bd
commit
0738457a83
@ -1,81 +1,94 @@
|
||||
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);
|
||||
};
|
||||
|
||||
const careerPromises = careerSuggestions.map(async (career) => {
|
||||
// Universal fetch helper
|
||||
const fetchJSON = async (url, params) => {
|
||||
try {
|
||||
const headers = {
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
const fetchJSON = async (url) => {
|
||||
try {
|
||||
const response = await axios.get(url, { headers });
|
||||
updateProgress(); // ✅ Update progress on success
|
||||
},
|
||||
params: params || {},
|
||||
});
|
||||
updateProgress(); // increment if success
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
updateProgress(); // ✅ Update progress even if failed
|
||||
updateProgress(); // increment even on failure
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch Data in Parallel
|
||||
const [cipData, jobDetailsData, economicData, salaryResponse] = await Promise.all([
|
||||
// Map over careerSuggestions to fetch CIP, job details, economic, salary data in parallel
|
||||
const careerPromises = careerSuggestions.map(async (career) => {
|
||||
try {
|
||||
// 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/${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;
|
||||
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 = salaryResponse === null || salaryResponse === undefined;
|
||||
const isEconomicMissing =
|
||||
!economicData ||
|
||||
Object.values(economicData).every((val) => val === 'N/A' || val === '*');
|
||||
const isSalaryMissing = !salaryData;
|
||||
|
||||
const isLimitedData = isCipMissing || isJobDetailsMissing || isEconomicMissing || isSalaryMissing;
|
||||
const isLimitedData =
|
||||
isCipMissing || isJobDetailsMissing || isEconomicMissing || isSalaryMissing;
|
||||
|
||||
return { ...career, limitedData: isLimitedData };
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
...career,
|
||||
limitedData: isLimitedData,
|
||||
};
|
||||
} catch (err) {
|
||||
// If any errors occur mid-logic, mark it limited
|
||||
return { ...career, limitedData: true };
|
||||
}
|
||||
});
|
||||
@ -89,21 +102,9 @@ export function CareerSuggestions({ careerSuggestions = [], userState, areaTitle
|
||||
};
|
||||
|
||||
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
|
||||
@ -111,12 +112,11 @@ export function CareerSuggestions({ careerSuggestions = [], userState, areaTitle
|
||||
className={`career-button ${career.limitedData ? 'limited-data' : ''}`}
|
||||
onClick={() => onCareerClick(career)}
|
||||
>
|
||||
{career.title} {career.limitedData && <span className="warning-icon">⚠️</span>}
|
||||
{career.title}
|
||||
{career.limitedData && <span className="warning-icon"> ⚠️</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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,13 +105,25 @@ const InterestInventory = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const randomizeAnswers = () => {
|
||||
const randomized = {};
|
||||
questions.forEach((question) => {
|
||||
randomized[question.index] = Math.floor(Math.random() * 5) + 1; // 1–5
|
||||
});
|
||||
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`, {
|
||||
// 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,
|
||||
@ -143,14 +135,15 @@ const InterestInventory = () => {
|
||||
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">
|
||||
{/* 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}>Previous</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreviousPage}
|
||||
className="rounded bg-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-400"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
)}
|
||||
{currentPage * questionsPerPage < questions.length ? (
|
||||
<button type="button" onClick={handleNextPage}>Next</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}>Submit</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>
|
||||
)}
|
||||
<button type="button" onClick={randomizeAnswers} disabled={isSubmitting}>Randomize Answers</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>
|
||||
);
|
||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user