dev1/src/components/CareerExplorer.js

1157 lines
39 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, useContext } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import ChatCtx from '../contexts/ChatCtx.js';
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 { setChatSnapshot } = useContext(ChatCtx);
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);
// 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 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,
fromManualSearch: true
};
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',
];
/* ---------- core context: sent every turn ---------- */
const coreCtx = useMemo(() => {
// 1) Riasec scores
const riasecScores = userProfile?.riasec_scores
? JSON.parse(userProfile.riasec_scores)
: null;
// 2) priority weights normalised 0-1
const priorityWeights = priorities ? {
stability : priorityWeight('stability' , priorities.stability) / 5,
growth : priorityWeight('growth' , priorities.growth) / 5,
balance : priorityWeight('balance' , priorities.balance) / 5,
recognition : priorityWeight('recognition', priorities.recognition)/ 5,
interests : priorityWeight('interests' , priorities.interests) / 5,
mission : priorityWeight('meaning' , priorities.meaning) / 5,
} : null;
return { riasecScores, priorityWeights };
}, [userProfile, priorities]);
/* ---------- modal context: exists only while a modal is open ---------- */
const modalCtx = useMemo(() => {
if (!selectedCareer || !careerDetails) return null;
const medianRow = careerDetails.salaryData
?.find(r => r.percentile === "Median");
return {
socCode : selectedCareer.code,
title : selectedCareer.title,
aiRisk : careerDetails.aiRisk?.riskLevel ?? "n/a",
salary : medianRow
? { regional : medianRow.regionalSalary,
national : medianRow.nationalSalary }
: null,
projections : careerDetails.economicProjections ?? {},
description : careerDetails.jobDescription,
tasks : careerDetails.tasks,
};
}, [selectedCareer, careerDetails]);
useEffect(() => {
// send null when no modal is open → ChatDrawer simply omits it
setChatSnapshot({ coreCtx, modalCtx });
}, [coreCtx, modalCtx, setChatSnapshot]);
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 =
!career.fromManualSearch && // ← skip the shortcut if manual
priorities.interests &&
priorities.interests !== "Im not sure yet";
const defaultInterestValue =
userHasInventory
?
(fitRatingMap[career.fit] || masterRatings.interests || 3)
:
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
// ------------------------------------------------------
// CareerExplorer.js
const handleSelectForEducation = (career) => {
if (!career) return;
// ─── 1. Ask first ─────────────────────────────────────────────
const ok = window.confirm(
`Are you sure you want to move on to Educational Programs for “${career.title}”?`
);
if (!ok) return;
// ─── 2. Make sure we have a full SOC code ─────────────────────
const fullSoc = career.soc_code || career.code || '';
if (!fullSoc) {
alert('Sorry this career is missing a valid SOC code.');
return;
}
// ─── 3. Find & clean CIP codes (may be empty) ─────────────────
const match = masterCareerRatings.find(r => r.soc_code === fullSoc);
const rawCips = match?.cip_codes ?? []; // original array
const cleanedCips = cleanCipCodes(rawCips); // “0402”, “1409”, …
// ─── 4. Persist ONE tidy object for later pages ───────────────
const careerForStorage = {
...career,
soc_code : fullSoc,
cip_code : rawCips // keep the raw list; page cleans them again if needed
};
localStorage.setItem('selectedCareer', JSON.stringify(careerForStorage));
// ─── 5. Off we go ─────────────────────────────────────────────
navigate('/educational-programs', {
state: {
socCode : fullSoc,
cipCodes : cleanedCips, // can be [], page handles it
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]);
useEffect(() => {
/* ---------- add-to-comparison ---------- */
const onAdd = (e) => {
console.log('[onAdd] detail →', e.detail);
const { socCode, careerName } = e.detail || {};
if (!socCode) {
console.warn('[add-career] missing socCode aborting');
return;
}
// 1. see if the career is already in the filtered list
let career = filteredCareers.find((c) => c.code === socCode);
// 2. if not, make a stub so the list can still save
if (!career) {
career = {
code : socCode,
title: careerName || '(name unavailable)',
fit : 'Good',
};
}
// 3. push it into the comparison table
addCareerToList(career);
};
/* ---------- open-modal ---------- */
const onOpen = (e) => {
const { socCode } = e.detail || {};
if (!socCode) return;
const career = filteredCareers.find((c) => c.code === socCode);
if (career) handleCareerClick(career);
};
window.addEventListener('add-career', onAdd);
window.addEventListener('open-career', onOpen);
return () => {
window.removeEventListener('add-career', onAdd);
window.removeEventListener('open-career', onOpen);
};
}, [filteredCareers, addCareerToList, handleCareerClick]);
// ------------------------------------------------------
// 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>
<div className="flex items-baseline mb-4 gap-2">
<h2 className="text-xl font-semibold mb-4">Career Comparison</h2>
{/* quick-edit link */}
<button
type="button"
onClick={() => setShowModal(true)} // ← re-uses existing modal state
className="text-blue-600 underline text-sm focus:outline-none"
>
Edit priorities
</button>
</div>
{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">
This page includes information from&nbsp;
<a
href="https://www.onetcenter.org"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
O*NET OnLine
</a>
&nbsp;by the U.S. Department of Labor, Employment & Training Administration
(USDOL/ETA). Used under the&nbsp;
<a
href="https://creativecommons.org/licenses/by/4.0/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
CC&nbsp;BY&nbsp;4.0 license
</a>
. **O*NET®** is a trademark of USDOL/ETA. Salary and employment data are
enriched with resources from the&nbsp;
<a
href="https://www.bls.gov"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Bureau of Labor Statistics
</a>
&nbsp;and program information from the&nbsp;
<a
href="https://nces.ed.gov"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
National Center for Education Statistics
</a>
.
</div>
</div>
);
}
export default CareerExplorer;