dev1/src/components/CareerExplorer.js

719 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import CareerSuggestions from './CareerSuggestions.js';
import CareerPrioritiesModal from './CareerPrioritiesModal.js';
import CareerModal from './CareerModal.js';
import CareerSearch from './CareerSearch.js';
import { Button } from './ui/button.js';
import axios from 'axios';
const STATES = [
{ name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' },
{ name: 'Arkansas', code: 'AR' }, { name: 'California', code: 'CA' }, { name: 'Colorado', code: 'CO' },
{ name: 'Connecticut', code: 'CT' }, { name: 'Delaware', code: 'DE' }, { name: 'District of Columbia', code: 'DC' },
{ name: 'Florida', code: 'FL' }, { name: 'Georgia', code: 'GA' }, { name: 'Hawaii', code: 'HI' },
{ name: 'Idaho', code: 'ID' }, { name: 'Illinois', code: 'IL' }, { name: 'Indiana', code: 'IN' },
{ name: 'Iowa', code: 'IA' }, { name: 'Kansas', code: 'KS' }, { name: 'Kentucky', code: 'KY' },
{ name: 'Louisiana', code: 'LA' }, { name: 'Maine', code: 'ME' }, { name: 'Maryland', code: 'MD' },
{ name: 'Massachusetts', code: 'MA' }, { name: 'Michigan', code: 'MI' }, { name: 'Minnesota', code: 'MN' },
{ name: 'Mississippi', code: 'MS' }, { name: 'Missouri', code: 'MO' }, { name: 'Montana', code: 'MT' },
{ name: 'Nebraska', code: 'NE' }, { name: 'Nevada', code: 'NV' }, { name: 'New Hampshire', code: 'NH' },
{ name: 'New Jersey', code: 'NJ' }, { name: 'New Mexico', code: 'NM' }, { name: 'New York', code: 'NY' },
{ name: 'North Carolina', code: 'NC' }, { name: 'North Dakota', code: 'ND' }, { name: 'Ohio', code: 'OH' },
{ name: 'Oklahoma', code: 'OK' }, { name: 'Oregon', code: 'OR' }, { name: 'Pennsylvania', code: 'PA' },
{ name: 'Rhode Island', code: 'RI' }, { name: 'South Carolina', code: 'SC' }, { name: 'South Dakota', code: 'SD' },
{ name: 'Tennessee', code: 'TN' }, { name: 'Texas', code: 'TX' }, { name: 'Utah', code: 'UT' },
{ name: 'Vermont', code: 'VT' }, { name: 'Virginia', code: 'VA' }, { name: 'Washington', code: 'WA' },
{ name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' },
];
// -------------- CIP HELPER FUNCTIONS --------------
// 1) Insert leading zero if there's only 1 digit before the decimal
function ensureTwoDigitsBeforeDecimal(cipStr) {
// e.g. "4.0201" => "04.0201"
return cipStr.replace(/^(\d)\./, '0$1.');
}
// 2) Clean an array of CIP codes, e.g. ["4.0201", "14.0901"] => ["0402", "1409"]
function cleanCipCodes(cipArray) {
return cipArray.map((code) => {
let codeStr = code.toString();
codeStr = ensureTwoDigitsBeforeDecimal(codeStr); // ensure "04.0201"
return codeStr.replace('.', '').slice(0, 4); // => "040201" => "0402"
});
}
function getFullStateName(code) {
const found = STATES.find((s) => s.code === code?.toUpperCase());
return found ? found.name : '';
}
function CareerExplorer() {
const navigate = useNavigate();
const location = useLocation();
const apiUrl = process.env.REACT_APP_API_URL || '';
const [userProfile, setUserProfile] = useState(null);
const [masterCareerRatings, setMasterCareerRatings] = useState([]);
const [careerList, setCareerList] = useState([]);
const [careerDetails, setCareerDetails] = useState(null);
const [showModal, setShowModal] = useState(false);
const [userState, setUserState] = useState(null);
const [areaTitle, setAreaTitle] = useState(null);
const [userZipcode, setUserZipcode] = useState(null);
const [error, setError] = useState(null);
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
const [careerSuggestions, setCareerSuggestions] = useState([]);
const [careersWithJobZone, setCareersWithJobZone] = useState([]);
const [salaryData, setSalaryData] = useState([]);
const [economicProjections, setEconomicProjections] = useState(null);
const [selectedJobZone, setSelectedJobZone] = useState('');
const [selectedFit, setSelectedFit] = useState('');
const [selectedCareer, setSelectedCareer] = useState(null);
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
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',
};
// ===================== Load user profile =====================
useEffect(() => {
setLoading(true);
const fetchUserProfile = async () => {
try {
const token = localStorage.getItem('token');
const res = await axios.get(`${apiUrl}/user-profile`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 200) {
const profileData = res.data;
console.log('[fetchUserProfile] loaded profileData =>', profileData);
setUserProfile(profileData);
setUserState(profileData.state);
setAreaTitle(profileData.area);
setUserZipcode(profileData.zipcode);
// If they have a saved career list
if (profileData.career_list) {
setCareerList(JSON.parse(profileData.career_list));
}
// If they have interest inventory, fetch suggestions
if (profileData.interest_inventory_answers) {
const answers = profileData.interest_inventory_answers;
const careerSuggestionsRes = await axios.post(`${apiUrl}/onet/submit_answers`, {
answers,
state: profileData.state,
area: profileData.area,
});
const { careers = [] } = careerSuggestionsRes.data || {};
setCareerSuggestions(careers.flat());
} else {
setCareerSuggestions([]);
}
// Check if priorities answered
const priorities = profileData.career_priorities
? JSON.parse(profileData.career_priorities)
: {};
const allAnswered = ['interests','meaning','stability','growth','balance','recognition']
.every((key) => priorities[key]);
if (!allAnswered) {
setShowModal(true);
}
} else {
setShowModal(true);
}
} catch (err) {
console.error('Error fetching user profile:', err);
setShowModal(true);
setLoading(false);
}
};
fetchUserProfile();
}, [apiUrl]);
// ===================== If location.state has careerSuggestions =====================
useEffect(() => {
if (location.state?.careerSuggestions) {
setCareerSuggestions(location.state.careerSuggestions);
}
}, [location.state]);
// ===================== Fetch job zones for suggestions =====================
useEffect(() => {
const fetchJobZones = async () => {
if (!careerSuggestions.length) return;
const flatSuggestions = careerSuggestions.flat();
const socCodes = flatSuggestions.map((career) => career.code);
try {
const response = await axios.post(`${apiUrl}/job-zones`, { socCodes });
const jobZoneData = response.data;
const updatedCareers = flatSuggestions.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]);
// ===================== handleCareerClick (detail fetch) =====================
const handleCareerClick = useCallback(
async (career) => {
console.log('[handleCareerClick] career =>', career);
const socCode = career.code;
setSelectedCareer(career);
setError(null);
setCareerDetails(null);
setSalaryData([]);
setEconomicProjections({});
setSelectedCareer(career);
if (!socCode) {
console.error('SOC Code is missing');
setError('SOC Code is missing');
setLoading(false);
return;
}
try {
// CIP fetch
const cipResponse = await fetch(`${apiUrl}/cip/${socCode}`);
if (!cipResponse.ok) {
setError(
`We're sorry, but specific details for "${career.title}" are not available at this time.`
);
setCareerDetails({ error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`});
setLoading(false);
return;
}
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){
setCareerDetails({ error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`});
setLoading(false);
return;
}
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: {} };
}
// 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,
},
]
: [];
// Economic
const fullStateName = getFullStateName(userState);
let economicResponse = { data: {} };
try {
economicResponse = await axios.get(`${apiUrl}/projections/${socCode.split('.')[0]}`, {
params: { state: fullStateName },
});
} catch (error) {
economicResponse = { data: {} };
}
// Build final details
const updatedCareerDetails = {
...career,
jobDescription: description,
tasks,
salaryData: salaryDataPoints,
economicProjections: economicResponse.data || {},
};
setCareerDetails(updatedCareerDetails);
} catch (error) {
console.error('Error processing career click:', error.message);
setCareerDetails({
error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`,
});
} finally {
setLoading(false);
}
},
[userState, apiUrl, areaTitle, userZipcode]
);
// ===================== handleCareerFromSearch =====================
const handleCareerFromSearch = useCallback(
(obj) => {
const adapted = {
code: obj.soc_code,
title: obj.title,
cipCode: obj.cip_code,
};
console.log('[Dashboard -> handleCareerFromSearch] adapted =>', adapted);
handleCareerClick(adapted);
},
[handleCareerClick]
);
useEffect(() => {
if (pendingCareerForModal) {
console.log('[useEffect] pendingCareerForModal =>', pendingCareerForModal);
handleCareerFromSearch(pendingCareerForModal);
setPendingCareerForModal(null);
}
}, [pendingCareerForModal, handleCareerFromSearch]);
// ===================== Load careers_with_ratings for CIP arrays =====================
useEffect(() => {
fetch('/careers_with_ratings.json')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch ratings JSON');
return res.json();
})
.then((data) => setMasterCareerRatings(data))
.catch((err) => console.error('Error fetching career ratings:', err));
}, []);
const priorities = useMemo(() => {
return userProfile?.career_priorities ? JSON.parse(userProfile.career_priorities) : {};
}, [userProfile]);
const priorityKeys = ['interests', 'meaning', 'stability', 'growth', 'balance', 'recognition'];
const getCareerRatingsBySocCode = (socCode) => {
return masterCareerRatings.find((c) => c.soc_code === socCode)?.ratings || {};
};
// ===================== Save comparison list to backend =====================
const saveCareerListToBackend = async (newCareerList) => {
try {
const token = localStorage.getItem('token');
await axios.post(
`${apiUrl}/user-profile`,
{
firstName: userProfile?.firstname,
lastName: userProfile?.lastname,
email: userProfile?.email,
zipCode: userProfile?.zipcode,
state: userProfile?.state,
area: userProfile?.area,
careerSituation: userProfile?.career_situation,
interest_inventory_answers: userProfile?.interest_inventory_answers,
career_priorities: userProfile?.career_priorities,
career_list: JSON.stringify(newCareerList),
},
{
headers: { Authorization: `Bearer ${token}` },
}
);
} catch (err) {
console.error('Error saving career_list:', err);
}
};
// ===================== Add/Remove from comparison =====================
const addCareerToList = (career) => {
const masterRatings = getCareerRatingsBySocCode(career.code);
const fitRatingMap = {
Best: 5,
Great: 4,
Good: 3,
};
const interestsRating =
priorities.interests === "Im not sure yet"
? parseInt(prompt("Rate your interest in this career (1-5):", "3"), 10)
: fitRatingMap[career.fit] || masterRatings.interests || 3;
const meaningRating = parseInt(
prompt("How important do you feel this job is to society or the world? (1-5):", "3"),
10
);
const stabilityRating =
career.ratings && career.ratings.stability !== undefined
? career.ratings.stability
: masterRatings.stability || 3;
const growthRating = masterRatings.growth || 3;
const balanceRating = masterRatings.balance || 3;
const recognitionRating = masterRatings.recognition || 3;
const careerWithUserRatings = {
...career,
ratings: {
interests: interestsRating,
meaning: meaningRating,
stability: stabilityRating,
growth: growthRating,
balance: balanceRating,
recognition: recognitionRating,
},
};
setCareerList((prevList) => {
if (prevList.some((c) => c.code === career.code)) {
alert("Career already in comparison list.");
return prevList;
}
const newList = [...prevList, careerWithUserRatings];
saveCareerListToBackend(newList);
return newList;
});
};
const removeCareerFromList = (careerCode) => {
setCareerList((prevList) => {
const newList = prevList.filter((c) => c.code !== careerCode);
saveCareerListToBackend(newList);
return newList;
});
};
// ===================== Let user pick a career from comparison => "Select for Education" =====================
const handleSelectForEducation = (career) => {
// 1) Confirm
const confirmed = window.confirm(
`Are you sure you want to move on to Educational Programs for ${career.title}?`
);
if (!confirmed) return;
// 2) Look up CIP codes from masterCareerRatings by SOC code
const matching = masterCareerRatings.find((r) => r.soc_code === career.code);
if (!matching) {
alert(`No CIP codes found for ${career.title}.`);
return;
}
// 3) Clean CIP codes
const rawCips = matching.cip_codes || [];
const cleanedCips = cleanCipCodes(rawCips); // from top-level function
console.log('cleanedCips =>', cleanedCips);
// 4) Navigate
navigate('/educational-programs', {
state: {
socCode: career.code,
cipCodes: cleanedCips,
careerTitle: career.title,
userZip: userZipcode,
userState: userState,
},
});
};
// ===================== Filter logic for jobZone, Fit =====================
const filteredCareers = useMemo(() => {
return careersWithJobZone.filter((career) => {
const jobZoneMatches = selectedJobZone
? career.job_zone !== null &&
career.job_zone !== undefined &&
Number(career.job_zone) === Number(selectedJobZone)
: true;
const fitMatches = selectedFit ? career.fit === selectedFit : true;
return jobZoneMatches && fitMatches;
});
}, [careersWithJobZone, selectedJobZone, selectedFit]);
// Weighted “match score” logic. (unchanged)
const priorityWeight = (priority, response) => {
const weightMap = {
interests: {
'I know my interests (completed inventory)': 5,
'Im not sure yet': 1,
},
meaning: {
'Yes, very important': 5,
'Somewhat important': 3,
'Not as important': 1,
},
stability: {
'Very important': 5,
'Somewhat important': 3,
'Not as important': 1,
},
growth: {
'Yes, very important': 5,
'Somewhat important': 3,
'Not as important': 1,
},
balance: {
'Yes, very important': 5,
'Somewhat important': 3,
'Not as important': 1,
},
recognition: {
'Very important': 5,
'Somewhat important': 3,
'Not as important': 1,
},
};
return weightMap[priority][response] || 1;
};
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="career-explorer-container bg-white p-6 rounded shadow">
{renderLoadingOverlay()}
{showModal && (
<CareerPrioritiesModal
userProfile={userProfile}
onClose={() => setShowModal(false)}
/>
)}
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">
Explore Careers - use the tools below to find your perfect career
</h2>
<CareerSearch
onCareerSelected={(careerObj) => {
console.log('[Dashboard] onCareerSelected =>', careerObj);
setPendingCareerForModal(careerObj);
}}
/>
</div>
<h2 className="text-xl font-semibold mb-4">Career Comparison</h2>
{careerList.length ? (
<table className="w-full mb-4">
<thead>
<tr>
<th className="border p-2">Career</th>
{priorityKeys.map((priority) => (
<th key={priority} className="border p-2 capitalize">
{priority}
</th>
))}
<th className="border p-2">Match</th>
<th className="border p-2">Actions</th>
</tr>
</thead>
<tbody>
{careerList.map((career) => {
const ratings = career.ratings || {};
const interestsRating = ratings.interests || 3;
const meaningRating = ratings.meaning || 3;
const stabilityRating = ratings.stability || 3;
const growthRating = ratings.growth || 3;
const balanceRating = ratings.balance || 3;
const recognitionRating = ratings.recognition || 3;
const userInterestsWeight = priorityWeight('interests', priorities.interests || 'Im not sure yet');
const userMeaningWeight = priorityWeight('meaning', priorities.meaning);
const userStabilityWeight = priorityWeight('stability', priorities.stability);
const userGrowthWeight = priorityWeight('growth', priorities.growth);
const userBalanceWeight = priorityWeight('balance', priorities.balance);
const userRecognitionWeight = priorityWeight('recognition', priorities.recognition);
const totalWeight =
userInterestsWeight +
userMeaningWeight +
userStabilityWeight +
userGrowthWeight +
userBalanceWeight +
userRecognitionWeight;
const weightedScore =
interestsRating * userInterestsWeight +
meaningRating * userMeaningWeight +
stabilityRating * userStabilityWeight +
growthRating * userGrowthWeight +
balanceRating * userBalanceWeight +
recognitionRating * userRecognitionWeight;
const matchScore = (weightedScore / (totalWeight * 5)) * 100;
return (
<tr key={career.code}>
<td className="border p-2">{career.title}</td>
<td className="border p-2">{interestsRating}</td>
<td className="border p-2">{meaningRating}</td>
<td className="border p-2">{stabilityRating}</td>
<td className="border p-2">{growthRating}</td>
<td className="border p-2">{balanceRating}</td>
<td className="border p-2">{recognitionRating}</td>
<td className="border p-2 font-bold">{matchScore.toFixed(1)}%</td>
<td className="border p-2 space-x-2">
<Button
className="bg-red-600 text-black-500"
onClick={() => removeCareerFromList(career.code)}
>
Remove
</Button>
{/* New Button -> "Select for Education" */}
<Button
className="bg-green-600 text-white"
onClick={() => handleSelectForEducation(career)}
>
Search for Education
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<p>No careers added to comparison.</p>
)}
<div className="flex gap-4 mb-4">
<select
className="border px-3 py-1 rounded"
value={selectedJobZone}
onChange={(e) => setSelectedJobZone(e.target.value)}
>
<option value="">All Preparation Levels</option>
{Object.entries(jobZoneLabels).map(([zone, label]) => (
<option key={zone} value={zone}>
{label}
</option>
))}
</select>
<select
className="border px-3 py-1 rounded"
value={selectedFit}
onChange={(e) => setSelectedFit(e.target.value)}
>
<option value="">All Fit Levels</option>
{Object.entries(fitLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
<CareerSuggestions
careerSuggestions={filteredCareers}
onCareerClick={(career) => {
setSelectedCareer(career);
handleCareerClick(career);
}}
setLoading={setLoading}
setProgress={setProgress}
userState={userState}
areaTitle={areaTitle}
/>
{selectedCareer && (
<CareerModal
career={selectedCareer}
careerDetails={careerDetails}
closeModal={() => {
setSelectedCareer(null);
setCareerDetails(null);
}}
addCareerToList={addCareerToList}
/>
)}
<div className="mt-6 text-xs text-gray-500 border-t pt-2">
Career results and details provided by
<a href="https://www.onetcenter.org" target="_blank" rel="noopener noreferrer">
{' '}
O*Net
</a>
, in partnership with
<a href="https://www.bls.gov" target="_blank" rel="noopener noreferrer">
{' '}
Bureau of Labor Statistics
</a>
and
<a href="https://nces.ed.gov" target="_blank" rel="noopener noreferrer">
{' '}
NCES
</a>
.
</div>
</div>
);
}
export default CareerExplorer;