1157 lines
39 KiB
JavaScript
1157 lines
39 KiB
JavaScript
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,
|
||
'I’m 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 !== "I’m 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 || 'I’m 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
|
||
<a
|
||
href="https://www.onetcenter.org"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="underline"
|
||
>
|
||
O*NET OnLine
|
||
</a>
|
||
by the U.S. Department of Labor, Employment & Training Administration
|
||
(USDOL/ETA). Used under the
|
||
<a
|
||
href="https://creativecommons.org/licenses/by/4.0/"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="underline"
|
||
>
|
||
CC BY 4.0 license
|
||
</a>
|
||
. **O*NET®** is a trademark of USDOL/ETA. Salary and employment data are
|
||
enriched with resources from the
|
||
<a
|
||
href="https://www.bls.gov"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="underline"
|
||
>
|
||
Bureau of Labor Statistics
|
||
</a>
|
||
and program information from the
|
||
<a
|
||
href="https://nces.ed.gov"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="underline"
|
||
>
|
||
National Center for Education Statistics
|
||
</a>
|
||
.
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default CareerExplorer;
|