dev1/src/components/CareerExplorer.js

1021 lines
34 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 InterestMeaningModal from './InterestMeaningModal.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 || '';
// ---------- Component States ----------
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 [showInterestMeaningModal, setShowInterestMeaningModal] = useState(false);
const [modalData, setModalData] = useState({
career: null,
askForInterest: false,
defaultInterest: 3,
defaultMeaning: 3,
});
// ...
const fitRatingMap = {
Best: 5,
Great: 4,
Good: 3,
};
// This is where we'll hold ALL final suggestions (with job_zone merged)
const [careerSuggestions, setCareerSuggestions] = 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',
};
// --------------------------------------------------
// fetchSuggestions - combined suggestions + job zone
// --------------------------------------------------
const fetchSuggestions = async (answers, profileData) => {
if (!answers) {
setCareerSuggestions([]);
localStorage.removeItem('careerSuggestionsCache');
// Reset loading & progress if userProfile has no answers
setLoading(true);
setProgress(0);
setLoading(false);
return;
}
try {
setLoading(true);
setProgress(0);
// 1) O*NET answers -> initial career list
const submitRes = await axios.post(`${apiUrl}/onet/submit_answers`, {
answers,
state: profileData.state,
area: profileData.area,
});
const { careers = [] } = submitRes.data || {};
const flattened = careers.flat();
// We'll do an extra single call for job zones + 4 calls for each career:
// => total steps = 1 (jobZones) + (flattened.length * 4)
let totalSteps = 1 + (flattened.length * 4);
let completedSteps = 0;
// Increments the global progress bar
const increment = () => {
completedSteps++;
const pct = Math.round((completedSteps / totalSteps) * 100);
setProgress(pct);
};
// A helper that does a GET request, increments progress on success/fail
const fetchWithProgress = async (url, params) => {
try {
const res = await axios.get(url, { params });
increment();
return res.data;
} catch (err) {
increment();
return {}; // or null
}
};
// 2) job zones (one call for all SOC codes)
const socCodes = flattened.map((c) => c.code);
const zonesRes = await axios.post(`${apiUrl}/job-zones`, { socCodes }).catch(() => null);
// increment progress for this single request
increment();
const jobZoneData = zonesRes?.data || {};
// 3) For each career, also fetch CIP, job details, projections, salary
const enrichedPromiseArray = flattened.map(async (career) => {
const strippedSoc = career.code.split('.')[0];
// build URLs
const cipUrl = `${apiUrl}/cip/${career.code}`;
const jobDetailsUrl = `${apiUrl}/onet/career-description/${career.code}`;
const economicUrl = `${apiUrl}/projections/${strippedSoc}`;
const salaryParams = { socCode: strippedSoc, area: profileData.area };
// We'll fetch them in parallel with our custom fetchWithProgress:
const [cipRaw, jobRaw, ecoRaw, salRaw] = await Promise.all([
fetchWithProgress(cipUrl),
fetchWithProgress(jobDetailsUrl),
fetchWithProgress(economicUrl),
fetchWithProgress(`${apiUrl}/salary`, salaryParams),
]);
// parse data
const cip = cipRaw || {};
const jobDetails = jobRaw || {};
const economic = ecoRaw || {};
const salary = salRaw || {};
// Check if data is missing
const isCipMissing = !cip || Object.keys(cip).length === 0;
const isJobDetailsMissing = !jobDetails || Object.keys(jobDetails).length === 0;
const isEconomicMissing =
!economic || Object.values(economic).every((val) => val === 'N/A' || val === '*');
const isSalaryMissing = !salary || Object.keys(salary).length === 0;
const isLimitedData =
isCipMissing || isJobDetailsMissing || isEconomicMissing || isSalaryMissing;
return {
...career,
job_zone: jobZoneData[career.code.slice(0, -3)]?.job_zone || null,
limitedData: isLimitedData,
};
});
// Wait for everything to finish
const finalEnrichedCareers = await Promise.all(enrichedPromiseArray);
// Store final suggestions in local storage
localStorage.setItem('careerSuggestionsCache', JSON.stringify(finalEnrichedCareers));
// Update React state
setCareerSuggestions(finalEnrichedCareers);
} catch (err) {
console.error('[fetchSuggestions] Error:', err);
setCareerSuggestions([]);
localStorage.removeItem('careerSuggestionsCache');
} finally {
// Hide spinner
setLoading(false);
}
};
// --------------------------------------
// On mount, load suggestions from cache
// --------------------------------------
useEffect(() => {
const cached = localStorage.getItem('careerSuggestionsCache');
if (cached) {
const parsed = JSON.parse(cached);
if (parsed?.length) {
setCareerSuggestions(parsed);
}
}
}, []);
// --------------------------------------
// 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;
setUserProfile(profileData);
setUserState(profileData.state);
setAreaTitle(profileData.area);
setUserZipcode(profileData.zipcode);
if (profileData.career_list) {
setCareerList(JSON.parse(profileData.career_list));
}
if (!profileData.career_priorities) {
setShowModal(true);
}
} else {
setShowModal(true);
}
} catch (err) {
console.error('Error fetching user profile:', err);
setShowModal(true);
} finally {
setLoading(false);
}
};
fetchUserProfile();
}, [apiUrl]);
// ------------------------------------------------------
// If user came from Interest Inventory => auto-fetch
// ------------------------------------------------------
useEffect(() => {
if (
location.state?.fromInterestInventory &&
userProfile?.interest_inventory_answers
) {
fetchSuggestions(userProfile.interest_inventory_answers, userProfile);
// remove that state so refresh doesn't re-fetch
navigate('.', { replace: true });
}
}, [location.state, userProfile, fetchSuggestions, navigate]);
// ------------------------------------------------------
// handleCareerClick (detail fetch for CIP, Salary, etc.)
// ------------------------------------------------------
const handleCareerClick = useCallback(
async (career) => {
console.log('[handleCareerClick] career object:', JSON.stringify(career, null, 2));
const socCode = career.code;
setSelectedCareer(career);
setError(null);
setCareerDetails(null);
setSalaryData([]);
setEconomicProjections({});
setLoading(true);
if (!socCode) {
console.error('SOC Code is missing');
setError('SOC Code is missing');
setLoading(false);
return;
}
try {
// 1) 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);
// 2) Job details (description + tasks)
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();
// 3) Salary data
let salaryResponse;
try {
salaryResponse = await axios.get(`${apiUrl}/salary`, {
params: { socCode: socCode.split('.')[0], area: areaTitle },
});
} catch (error) {
salaryResponse = { data: {} };
}
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,
},
]
: [];
// 4) Economic Projections
const fullStateName = getFullStateName(userState); // your helper
let economicResponse = { data: {} };
try {
economicResponse = await axios.get(
`${apiUrl}/projections/${socCode.split('.')[0]}`,
{
params: { state: fullStateName },
}
);
} catch (error) {
economicResponse = { data: {} };
}
// ----------------------------------------------------
// 5) AI RISK ANALYSIS LOGIC
// Attempt to retrieve from server2 first;
// if not found => call server3 => store in server2.
// ----------------------------------------------------
let aiRisk = null;
const strippedSocCode = socCode.split('.')[0];
try {
// Check local DB first (SQLite -> server2)
const localRiskRes = await axios.get(`${apiUrl}/ai-risk/${socCode}`);
aiRisk = localRiskRes.data;
} catch (err) {
// If 404, we call server3's ChatGPT route at the SAME base url
if (err.response && err.response.status === 404) {
try {
const aiRes = await axios.post(`${apiUrl}/public/ai-risk-analysis`, {
socCode,
careerName: career.title,
jobDescription: description,
tasks,
});
const { riskLevel, reasoning } = aiRes.data;
// store it back in server2 to avoid repeated GPT calls
await axios.post(`${apiUrl}/ai-risk`, {
socCode,
careerName: aiRes.data.careerName,
jobDescription: aiRes.data.jobDescription,
tasks: aiRes.data.tasks,
riskLevel: aiRes.data.riskLevel,
reasoning: aiRes.data.reasoning,
});
// build final object
aiRisk = {
socCode,
careerName: career.title,
jobDescription: description,
tasks,
riskLevel,
reasoning,
};
} catch (err2) {
console.error('Error calling server3 or storing AI risk:', err2);
// fallback
}
} else {
console.error('Error fetching AI risk from server2:', err);
}
}
// 6) Build final details object
const updatedCareerDetails = {
...career,
jobDescription: description,
tasks,
salaryData: salaryDataPoints,
economicProjections: economicResponse.data || {},
aiRisk, // <--- Now we have it attached
};
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]
);
// ------------------------------------------------------
// 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]
);
// ------------------------------------------------------
// pendingCareerForModal effect
// ------------------------------------------------------
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));
}, []);
// ------------------------------------------------------
// Derived data / Helpers
// ------------------------------------------------------
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) => {
// 1) get default (pre-calculated) ratings from your JSON
const masterRatings = getCareerRatingsBySocCode(career.code);
// 2) figure out interest
const userHasInventory = priorities.interests !== "Im not sure yet";
const defaultInterestValue =
userHasInventory
? // if user has done inventory, we rely on fit rating or fallback to .json
(fitRatingMap[career.fit] || masterRatings.interests || 3)
: // otherwise, just start them at 3 (we'll ask in the modal)
3;
// 3) always ask for meaning, start at 3
const defaultMeaningValue = 3;
// 4) open the InterestMeaningModal instead of using prompt()
setModalData({
career,
masterRatings,
askForInterest: !userHasInventory,
defaultInterest: defaultInterestValue,
defaultMeaning: defaultMeaningValue,
});
setShowInterestMeaningModal(true);
};
const handleModalSave = ({ interest, meaning }) => {
const { career, masterRatings, askForInterest, defaultInterest } = modalData;
if (!career) return;
// If we asked for interest, use the user's input; otherwise keep the default
const finalInterest = askForInterest && interest !== null
? interest
: defaultInterest;
const finalMeaning = meaning;
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: finalInterest,
meaning: finalMeaning,
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;
});
};
// ------------------------------------------------------
// "Select for Education" => navigate with CIP codes
// ------------------------------------------------------
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;
localStorage.setItem("selectedCareer", JSON.stringify(career));
// 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);
// 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 careerSuggestions.filter((career) => {
// If user selected a jobZone, check if career.job_zone matches
const jobZoneMatches = selectedJobZone
? career.job_zone !== null &&
career.job_zone !== undefined &&
Number(career.job_zone) === Number(selectedJobZone)
: true;
// If user selected a fit, check if career.fit matches
const fitMatches = selectedFit ? career.fit === selectedFit : true;
return jobZoneMatches && fitMatches;
});
}, [careerSuggestions, 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;
};
// ------------------------------------------------------
// Loading Overlay
// ------------------------------------------------------
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>
);
};
// ------------------------------------------------------
// Render
// ------------------------------------------------------
return (
<div className="career-explorer-container bg-white p-6 rounded shadow">
{renderLoadingOverlay()}
{showModal && (
<CareerPrioritiesModal
userProfile={userProfile}
onClose={() => setShowModal(false)}
/>
)}
<div className="mb-4">
<h2 className="text-2xl font-semibold mb-2">
Explore Careers - use these tools to find your best fit
</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>
<Button
className="bg-green-600 text-white px-2 py-1 text-xs
sm:text-sm
whitespace-nowrap
"
onClick={() => handleSelectForEducation(career)}
>
Plan your Education/Skills
</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>
<Button
onClick={() => {
if (!userProfile?.interest_inventory_answers) {
alert('Please complete the Interest Inventory to get suggestions.');
return;
}
fetchSuggestions(userProfile.interest_inventory_answers, userProfile);
}}
className="bg-green-600 text-white px-3 py-1 text-xs sm:text-sm"
>
Reload Career Suggestions
</Button>
{/* Legend container with less internal gap, plus a left margin */}
<div className="flex items-center gap-1 ml-4">
<span className="warning-icon"></span>
<span>= Limited Data for this career path</span>
</div>
</div>
{/* Now we pass the *filteredCareers* into the CareerSuggestions component */}
<CareerSuggestions
careerSuggestions={filteredCareers}
onCareerClick={(career) => {
setSelectedCareer(career);
handleCareerClick(career);
}}
/>
<InterestMeaningModal
show={showInterestMeaningModal}
onClose={() => setShowInterestMeaningModal(false)}
onSave={handleModalSave}
careerTitle={modalData.career?.title || ""}
askForInterest={modalData.askForInterest}
defaultInterest={modalData.defaultInterest}
defaultMeaning={modalData.defaultMeaning}
/>
{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;