CareerExplorer build out
This commit is contained in:
parent
f9674643d5
commit
0194e9fc19
@ -144,38 +144,7 @@ app.post('/api/register', async (req, res) => {
|
||||
});
|
||||
|
||||
|
||||
// Route for login
|
||||
app.post('/api/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
const query = 'SELECT * FROM user_auth WHERE username = ?';
|
||||
db.get(query, [username], async (err, row) => {
|
||||
if (err) {
|
||||
console.error('Error fetching user:', err.message);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await bcrypt.compare(password, row.hashed_password);
|
||||
if (!isPasswordValid) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
const { user_id } = row; // This gets the correct user_id from the row object
|
||||
const token = jwt.sign({ userId: row.user_id }, SECRET_KEY, { expiresIn: '2h' });
|
||||
res.status(200).json({ token });
|
||||
});
|
||||
});
|
||||
|
||||
// Route to handle user sign-in (customized)
|
||||
// Route to handle user sign-in (updated with career_priorities and career_list)
|
||||
app.post('/api/signin', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
@ -183,7 +152,7 @@ app.post('/api/signin', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Both username and password are required' });
|
||||
}
|
||||
|
||||
// JOIN user_profile to fetch is_premium, email, or whatever columns you need
|
||||
// Updated query includes career_priorities and career_list
|
||||
const query = `
|
||||
SELECT
|
||||
user_auth.user_id,
|
||||
@ -194,7 +163,9 @@ app.post('/api/signin', async (req, res) => {
|
||||
user_profile.career_situation,
|
||||
user_profile.email,
|
||||
user_profile.firstname,
|
||||
user_profile.lastname
|
||||
user_profile.lastname,
|
||||
user_profile.career_priorities, -- new field
|
||||
user_profile.career_list -- new field
|
||||
FROM user_auth
|
||||
LEFT JOIN user_profile ON user_auth.user_id = user_profile.user_id
|
||||
WHERE user_auth.username = ?
|
||||
@ -217,20 +188,16 @@ app.post('/api/signin', async (req, res) => {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
// user_id from the row
|
||||
const { user_id } = row;
|
||||
|
||||
// Generate JWT
|
||||
const token = jwt.sign({ userId: user_id }, SECRET_KEY, { expiresIn: '2h' });
|
||||
const token = jwt.sign({ userId: row.user_id }, SECRET_KEY, { expiresIn: '2h' });
|
||||
|
||||
// Return user object including is_premium and other columns
|
||||
// The front end can store this in state (e.g. setUser).
|
||||
// Return fully updated user object
|
||||
res.status(200).json({
|
||||
message: 'Login successful',
|
||||
token,
|
||||
userId: user_id,
|
||||
userId: row.user_id,
|
||||
user: {
|
||||
user_id,
|
||||
user_id: row.user_id,
|
||||
firstname: row.firstname,
|
||||
lastname: row.lastname,
|
||||
email: row.email,
|
||||
@ -238,11 +205,14 @@ app.post('/api/signin', async (req, res) => {
|
||||
is_premium: row.is_premium,
|
||||
is_pro_premium: row.is_pro_premium,
|
||||
career_situation: row.career_situation,
|
||||
career_priorities: row.career_priorities, // newly added
|
||||
career_list: row.career_list, // newly added
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/// Check if username already exists
|
||||
app.get('/api/check-username/:username', (req, res) => {
|
||||
const { username } = req.params;
|
||||
@ -263,6 +233,129 @@ app.get('/api/check-username/:username', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Upsert user profile with career_priorities and career_list
|
||||
app.post('/api/user-profile', (req, res) => {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Authorization token is required' });
|
||||
}
|
||||
|
||||
let userId;
|
||||
try {
|
||||
const decoded = jwt.verify(token, SECRET_KEY);
|
||||
userId = decoded.userId;
|
||||
} catch (error) {
|
||||
console.error('JWT verification failed:', error);
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
zipCode,
|
||||
state,
|
||||
area,
|
||||
careerSituation,
|
||||
interest_inventory_answers,
|
||||
career_priorities,
|
||||
career_list,
|
||||
} = req.body;
|
||||
|
||||
if (!firstName || !lastName || !email || !zipCode || !state || !area) {
|
||||
return res.status(400).json({ error: 'All fields are required (except interest_inventory_answers, career_priorities, career_list)' });
|
||||
}
|
||||
|
||||
// Check for existing user profile
|
||||
const checkQuery = `SELECT * FROM user_profile WHERE user_id = ?;`;
|
||||
db.get(checkQuery, [userId], (err, existingRow) => {
|
||||
if (err) {
|
||||
console.error('Error checking profile:', err.message);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
|
||||
// Preserve existing data if not provided
|
||||
const finalAnswers = interest_inventory_answers === undefined
|
||||
? existingRow?.interest_inventory_answers || null
|
||||
: interest_inventory_answers;
|
||||
|
||||
const finalCareerPriorities = career_priorities === undefined
|
||||
? existingRow?.career_priorities || null
|
||||
: career_priorities;
|
||||
|
||||
const finalCareerList = career_list === undefined
|
||||
? existingRow?.career_list || null
|
||||
: career_list;
|
||||
|
||||
if (existingRow) {
|
||||
// Update existing profile
|
||||
const updateQuery = `
|
||||
UPDATE user_profile
|
||||
SET firstname = ?,
|
||||
lastname = ?,
|
||||
email = ?,
|
||||
zipcode = ?,
|
||||
state = ?,
|
||||
area = ?,
|
||||
career_situation = ?,
|
||||
interest_inventory_answers = ?,
|
||||
career_priorities = ?,
|
||||
career_list = ?
|
||||
WHERE user_id = ?
|
||||
`;
|
||||
const params = [
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
zipCode,
|
||||
state,
|
||||
area,
|
||||
careerSituation || existingRow.career_situation,
|
||||
finalAnswers,
|
||||
finalCareerPriorities,
|
||||
finalCareerList,
|
||||
userId,
|
||||
];
|
||||
db.run(updateQuery, params, function (err) {
|
||||
if (err) {
|
||||
console.error('Error updating profile:', err.message);
|
||||
return res.status(500).json({ error: 'Failed to update user profile' });
|
||||
}
|
||||
return res.status(200).json({ message: 'User profile updated successfully' });
|
||||
});
|
||||
} else {
|
||||
// Insert new profile
|
||||
const insertQuery = `
|
||||
INSERT INTO user_profile
|
||||
(firstname, lastname, email, zipcode, state, area, career_situation, interest_inventory_answers, career_priorities, career_list, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
const params = [
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
zipCode,
|
||||
state,
|
||||
area,
|
||||
careerSituation || null,
|
||||
finalAnswers,
|
||||
finalCareerPriorities,
|
||||
finalCareerList,
|
||||
userId,
|
||||
];
|
||||
db.run(insertQuery, params, function (err) {
|
||||
if (err) {
|
||||
console.error('Error inserting profile:', err.message);
|
||||
return res.status(500).json({ error: 'Failed to create user profile' });
|
||||
}
|
||||
return res
|
||||
.status(201)
|
||||
.json({ message: 'User profile created successfully', id: this.lastID });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Route to fetch user profile
|
||||
|
17379
final_master_career_list.json
Normal file
17379
final_master_career_list.json
Normal file
File diff suppressed because it is too large
Load Diff
31707
public/careers_with_ratings.json
Normal file
31707
public/careers_with_ratings.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,8 @@ import GettingStarted from './components/GettingStarted.js';
|
||||
import SignIn from './components/SignIn.js';
|
||||
import SignUp from './components/SignUp.js';
|
||||
import PlanningLanding from './components/PlanningLanding.js';
|
||||
import CareerExplorer from './components/CareerExplorer.js';
|
||||
import EducationalPrograms from './components/EducationalPrograms.js';
|
||||
import PreparingLanding from './components/PreparingLanding.js';
|
||||
import EnhancingLanding from './components/EnhancingLanding.js';
|
||||
import RetirementLanding from './components/RetirementLanding.js';
|
||||
@ -236,6 +238,8 @@ function App() {
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/profile" element={<UserProfile />} />
|
||||
<Route path="/planning" element={<PlanningLanding />} />
|
||||
<Route path="/career-explorer" element={<CareerExplorer />} />
|
||||
<Route path="/educational-programs" element={<EducationalPrograms />} />
|
||||
<Route path="/preparing" element={<PreparingLanding />} />
|
||||
<Route path="/enhancing" element={<EnhancingLanding />} />
|
||||
<Route path="/retirement" element={<RetirementLanding />} />
|
||||
|
367
src/components/CareerExplorer.js
Normal file
367
src/components/CareerExplorer.js
Normal file
@ -0,0 +1,367 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import CareerSuggestions from './CareerSuggestions.js';
|
||||
import CareerPrioritiesModal from './CareerPrioritiesModal.js';
|
||||
import CareerModal from './CareerModal.js';
|
||||
import CareerSearch from './CareerSearch.js';
|
||||
import axios from 'axios';
|
||||
|
||||
function CareerExplorer({ handleCareerClick, userState, areaTitle }) {
|
||||
const location = useLocation();
|
||||
const apiUrl = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
const [userProfile, setUserProfile] = useState(null);
|
||||
const [masterCareerRatings, setMasterCareerRatings] = useState([]);
|
||||
const [careerList, setCareerList] = useState([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [careerSuggestions, setCareerSuggestions] = useState([]);
|
||||
const [careersWithJobZone, setCareersWithJobZone] = useState([]);
|
||||
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',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/careers_with_ratings.json')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch ratings JSON');
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setMasterCareerRatings(data))
|
||||
.catch((err) => console.error('Error fetching career ratings:', err));
|
||||
}, []);
|
||||
|
||||
const priorities = useMemo(() => {
|
||||
return userProfile?.career_priorities ? JSON.parse(userProfile.career_priorities) : {};
|
||||
}, [userProfile]);
|
||||
|
||||
const priorityKeys = ['interests', 'meaning', 'stability', 'growth', 'balance', 'recognition'];
|
||||
|
||||
const getCareerRatingsBySocCode = (socCode) => {
|
||||
return masterCareerRatings.find(c => c.soc_code === socCode)?.ratings || {};
|
||||
};
|
||||
|
||||
|
||||
const addCareerToList = (career) => {
|
||||
const masterRatings = getCareerRatingsBySocCode(career.code);
|
||||
|
||||
const fitRatingMap = {
|
||||
Best: 5,
|
||||
Great: 4,
|
||||
Good: 3,
|
||||
};
|
||||
|
||||
const interestsRating = priorities.interests === 'I’m not sure yet'
|
||||
? parseInt(prompt('Rate your interest in this career (1-5):', '3'), 10)
|
||||
: fitRatingMap[career.fit] || masterRatings.interests || 3;
|
||||
|
||||
const meaningRating = parseInt(prompt('How meaningful is this career to you? (1-5):', '3'), 10);
|
||||
|
||||
const stabilityRating = career.ratings && career.ratings.stability !== undefined
|
||||
? career.ratings.stability
|
||||
: masterRatings.stability || 3;
|
||||
|
||||
const growthRating = masterRatings.growth || 3;
|
||||
const balanceRating = masterRatings.balance || 3;
|
||||
const recognitionRating = masterRatings.recognition || 3;
|
||||
|
||||
const careerWithUserRatings = {
|
||||
...career,
|
||||
ratings: {
|
||||
interests: interestsRating,
|
||||
meaning: meaningRating,
|
||||
stability: stabilityRating,
|
||||
growth: growthRating,
|
||||
balance: balanceRating,
|
||||
recognition: recognitionRating,
|
||||
},
|
||||
};
|
||||
|
||||
setCareerList((prevList) => {
|
||||
if (prevList.some((c) => c.code === career.code)) {
|
||||
alert('Career already in comparison list.');
|
||||
return prevList;
|
||||
}
|
||||
return [...prevList, careerWithUserRatings];
|
||||
});
|
||||
};
|
||||
|
||||
const removeCareerFromList = (careerCode) => {
|
||||
setCareerList((prevList) => prevList.filter((c) => c.code !== careerCode));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
|
||||
const priorities = profileData.career_priorities
|
||||
? JSON.parse(profileData.career_priorities)
|
||||
: {};
|
||||
|
||||
const allAnswered = ['interests', 'meaning', 'stability', 'growth', 'balance', 'recognition'].every(
|
||||
(key) => priorities[key]
|
||||
);
|
||||
|
||||
if (!allAnswered) {
|
||||
setShowModal(true);
|
||||
}
|
||||
} else {
|
||||
setShowModal(true); // profile not found, show modal by default
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching user profile:', err);
|
||||
setShowModal(true); // on error, default to showing modal
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserProfile();
|
||||
}, [apiUrl]);
|
||||
|
||||
// Load suggestions from Interest Inventory if provided (optional)
|
||||
useEffect(() => {
|
||||
if (location.state?.careerSuggestions) {
|
||||
setCareerSuggestions(location.state.careerSuggestions);
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
// Fetch Job Zones if suggestions are provided
|
||||
useEffect(() => {
|
||||
const fetchJobZones = async () => {
|
||||
if (!careerSuggestions.length) return;
|
||||
|
||||
const flatSuggestions = careerSuggestions.flat();
|
||||
const socCodes = flatSuggestions.map((career) => career.code);
|
||||
try {
|
||||
const response = await axios.post(`${apiUrl}/job-zones`, { socCodes });
|
||||
const jobZoneData = response.data;
|
||||
const updatedCareers = flatSuggestions.map((career) => ({
|
||||
...career,
|
||||
job_zone: jobZoneData[career.code.slice(0, -3)]?.job_zone || null,
|
||||
}));
|
||||
setCareersWithJobZone(updatedCareers);
|
||||
} catch (error) {
|
||||
console.error('Error fetching job zone information:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchJobZones();
|
||||
}, [careerSuggestions, apiUrl]);
|
||||
|
||||
// Filtering logic (Job Zone and Fit)
|
||||
const filteredCareers = useMemo(() => {
|
||||
return careersWithJobZone.filter((career) => {
|
||||
const jobZoneMatches = selectedJobZone
|
||||
? career.job_zone !== null &&
|
||||
career.job_zone !== undefined &&
|
||||
Number(career.job_zone) === Number(selectedJobZone)
|
||||
: true;
|
||||
|
||||
const fitMatches = selectedFit ? career.fit === selectedFit : true;
|
||||
return jobZoneMatches && fitMatches;
|
||||
});
|
||||
}, [careersWithJobZone, selectedJobZone, selectedFit]);
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="career-explorer-container bg-white p-6 rounded shadow">
|
||||
{showModal && (
|
||||
<CareerPrioritiesModal
|
||||
userProfile={userProfile}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">Explore Careers</h2>
|
||||
<CareerSearch
|
||||
onCareerSelected={(careerObj) => handleCareerClick(careerObj)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-4">Career Comparison Matrix</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; // default to 3 if not rated via InterestInventory
|
||||
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">
|
||||
<button className="text-red-500" onClick={() => removeCareerFromList(career.code)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
) : <p>No careers added to comparison.</p>}
|
||||
|
||||
|
||||
<div className="flex gap-4 mb-4">
|
||||
<select
|
||||
className="border px-3 py-1 rounded"
|
||||
value={selectedJobZone}
|
||||
onChange={(e) => setSelectedJobZone(e.target.value)}
|
||||
>
|
||||
<option value="">All Preparation Levels</option>
|
||||
{Object.entries(jobZoneLabels).map(([zone, label]) => (
|
||||
<option key={zone} value={zone}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="border px-3 py-1 rounded"
|
||||
value={selectedFit}
|
||||
onChange={(e) => setSelectedFit(e.target.value)}
|
||||
>
|
||||
<option value="">All Fit Levels</option>
|
||||
{Object.entries(fitLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<CareerSuggestions
|
||||
careerSuggestions={filteredCareers}
|
||||
onCareerClick={(career) => {
|
||||
setSelectedCareer(career);
|
||||
handleCareerClick(career);
|
||||
}}
|
||||
setLoading={setLoading}
|
||||
setProgress={setProgress}
|
||||
userState={userState}
|
||||
areaTitle={areaTitle}
|
||||
/>
|
||||
|
||||
{selectedCareer && (
|
||||
<CareerModal
|
||||
career={selectedCareer}
|
||||
closeModal={() => setSelectedCareer(null)}
|
||||
userState={userState}
|
||||
areaTitle={areaTitle}
|
||||
userZipcode={userProfile?.zipcode}
|
||||
addCareerToList={addCareerToList} // <-- explicitly added here
|
||||
/>
|
||||
)}
|
||||
|
||||
<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;
|
219
src/components/CareerModal.js
Normal file
219
src/components/CareerModal.js
Normal file
@ -0,0 +1,219 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { getFullStateName } from '../utils/stateUtils.js';
|
||||
import { fetchSchools, clientGeocodeZip, haversineDistance} from '../utils/apiUtils.js';
|
||||
|
||||
const apiUrl = process.env.REACT_APP_API_URL;
|
||||
|
||||
function CareerModal({ career, userState, areaTitle, userZipcode, closeModal, addCareerToList }) {
|
||||
const [careerDetails, setCareerDetails] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCareerClick = async () => {
|
||||
const socCode = career.code;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const cipResponse = await fetch(`${apiUrl}/cip/${socCode}`);
|
||||
if (!cipResponse.ok) throw new Error('Failed to fetch CIP Code');
|
||||
const { cipCode } = await cipResponse.json();
|
||||
const cleanedCipCode = cipCode.replace('.', '').slice(0, 4);
|
||||
|
||||
const jobDetailsResponse = await fetch(`${apiUrl}/onet/career-description/${socCode}`);
|
||||
if (!jobDetailsResponse.ok) throw new Error('Failed to fetch job description');
|
||||
const { description, tasks } = await jobDetailsResponse.json();
|
||||
|
||||
const salaryResponse = await axios.get(`${apiUrl}/salary`, {
|
||||
params: { socCode: socCode.split('.')[0], area: areaTitle },
|
||||
}).catch(() => ({ data: {} }));
|
||||
|
||||
const economicResponse = await axios.get(`${apiUrl}/projections/${socCode.split('.')[0]}`, {
|
||||
params: { state: getFullStateName(userState) },
|
||||
}).catch(() => ({ data: {} }));
|
||||
|
||||
const filteredSchools = await fetchSchools(cleanedCipCode, userState);
|
||||
|
||||
let userLat = null, userLng = null;
|
||||
if (userZipcode) {
|
||||
try {
|
||||
const geocodeResult = await clientGeocodeZip(userZipcode);
|
||||
userLat = geocodeResult.lat;
|
||||
userLng = geocodeResult.lng;
|
||||
} catch (err) {
|
||||
console.warn('Unable to geocode user ZIP.');
|
||||
}
|
||||
}
|
||||
|
||||
const schoolsWithDistance = filteredSchools.map(sch => {
|
||||
const lat2 = sch.LATITUDE ? parseFloat(sch.LATITUDE) : null;
|
||||
const lon2 = sch.LONGITUD ? parseFloat(sch.LONGITUD) : null;
|
||||
|
||||
if (userLat && userLng && lat2 && lon2) {
|
||||
const distMiles = haversineDistance(userLat, userLng, lat2, lon2);
|
||||
return { ...sch, distance: `${distMiles.toFixed(1)} mi` };
|
||||
}
|
||||
return { ...sch, distance: 'N/A' };
|
||||
});
|
||||
|
||||
|
||||
const sData = salaryResponse.data || {};
|
||||
const salaryDataPoints = sData && Object.keys(sData).length > 0 ? [
|
||||
{ percentile: '10th', regionalSalary: sData.regional?.regional_PCT10 || 0, nationalSalary: sData.national?.national_PCT10 || 0 },
|
||||
{ percentile: '25th', regionalSalary: sData.regional?.regional_PCT25 || 0, nationalSalary: sData.national?.national_PCT25 || 0 },
|
||||
{ percentile: 'Median', regionalSalary: sData.regional?.regional_MEDIAN || 0, nationalSalary: sData.national?.national_MEDIAN || 0 },
|
||||
{ percentile: '75th', regionalSalary: sData.regional?.regional_PCT75 || 0, nationalSalary: sData.national?.national_PCT75 || 0 },
|
||||
{ percentile: '90th', regionalSalary: sData.regional?.regional_PCT90 || 0, nationalSalary: sData.national?.national_PCT90 || 0 },
|
||||
] : [];
|
||||
|
||||
setCareerDetails({
|
||||
...career,
|
||||
jobDescription: description,
|
||||
tasks,
|
||||
economicProjections: economicResponse.data || {},
|
||||
salaryData: salaryDataPoints,
|
||||
schools: schoolsWithDistance,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setError('Failed to load career details.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleCareerClick();
|
||||
}, [career, userState, areaTitle, userZipcode]);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (error) return <div>{error}</div>;
|
||||
|
||||
const calculateStabilityRating = (salaryData) => {
|
||||
const medianSalaryObj = salaryData.find(s => s.percentile === 'Median');
|
||||
const medianSalary = medianSalaryObj?.regionalSalary || medianSalaryObj?.nationalSalary || 0;
|
||||
|
||||
if (medianSalary >= 90000) return 5;
|
||||
if (medianSalary >= 70000) return 4;
|
||||
if (medianSalary >= 50000) return 3;
|
||||
if (medianSalary >= 30000) return 2;
|
||||
return 1;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center overflow-auto z-50">
|
||||
<div className="bg-white rounded-lg shadow-lg w-full max-w-5xl p-6 m-4 max-h-[90vh] overflow-y-auto">
|
||||
|
||||
<div className="flex justify-between items-center mb-4 pb-2 border-b">
|
||||
<h2 className="text-2xl font-bold text-blue-600">{careerDetails.title}</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const stabilityRating = calculateStabilityRating(careerDetails.salaryData);
|
||||
addCareerToList({
|
||||
...careerDetails,
|
||||
ratings: {
|
||||
stability: stabilityRating
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="text-white bg-green-500 hover:bg-green-600 rounded px-3 py-1"
|
||||
>
|
||||
Add to Comparison
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="text-white bg-red-500 hover:bg-red-600 rounded px-3 py-1"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job Description */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold mb-1">Job Description:</h3>
|
||||
<p className="text-gray-700">{careerDetails.jobDescription}</p>
|
||||
</div>
|
||||
|
||||
{/* Tasks (full width) */}
|
||||
<div className="mb-4 border-t pt-3">
|
||||
<h3 className="text-lg font-semibold mb-2">Tasks:</h3>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{careerDetails.tasks.map((task, i) => (
|
||||
<li key={i}>{task}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Salary and Economic Projections side-by-side */}
|
||||
<div className="flex flex-col md:flex-row gap-4 border-t pt-3">
|
||||
{/* Salary Data */}
|
||||
<div className="md:w-1/2 overflow-x-auto">
|
||||
<h3 className="text-lg font-semibold mb-2">Salary Data:</h3>
|
||||
<table className="w-full text-left border border-gray-300 rounded">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="px-3 py-2 border-b">Percentile</th>
|
||||
<th className="px-3 py-2 border-b">Regional Salary</th>
|
||||
<th className="px-3 py-2 border-b">National Salary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{careerDetails.salaryData.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-3 py-2 border-b">{s.percentile}</td>
|
||||
<td className="px-3 py-2 border-b">${s.regionalSalary.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 border-b">${s.nationalSalary.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Economic Projections */}
|
||||
<div className="md:w-1/2 overflow-x-auto">
|
||||
<h3 className="text-lg font-semibold mb-2">Economic Projections:</h3>
|
||||
<table className="w-full text-left border border-gray-300 rounded">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="px-3 py-2 border-b">Region</th>
|
||||
<th className="px-3 py-2 border-b">Current Jobs</th>
|
||||
<th className="px-3 py-2 border-b">Jobs in 10 yrs</th>
|
||||
<th className="px-3 py-2 border-b">Growth %</th>
|
||||
<th className="px-3 py-2 border-b">Annual Openings</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{careerDetails.economicProjections.state && (
|
||||
<tr>
|
||||
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.state.area}</td>
|
||||
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.state.base.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.state.projection.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.state.percentChange}%</td>
|
||||
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.state.annualOpenings.toLocaleString()}</td>
|
||||
</tr>
|
||||
)}
|
||||
{careerDetails.economicProjections.national && (
|
||||
<tr>
|
||||
<td className="px-3 py-2 border-b">National</td>
|
||||
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.national.base.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.national.projection.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.national.percentChange}%</td>
|
||||
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.national.annualOpenings.toLocaleString()}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CareerModal;
|
113
src/components/CareerPrioritiesModal.js
Normal file
113
src/components/CareerPrioritiesModal.js
Normal file
@ -0,0 +1,113 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
|
||||
const CareerPrioritiesModal = ({ userProfile, onClose }) => {
|
||||
const [responses, setResponses] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile?.career_priorities) {
|
||||
setResponses(JSON.parse(userProfile.career_priorities));
|
||||
}
|
||||
}, [userProfile]);
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: 'interests',
|
||||
text: 'What kinds of activities do you naturally enjoy?',
|
||||
options: ['I know my interests (completed inventory)', 'I’m not sure yet'],
|
||||
},
|
||||
{
|
||||
id: 'meaning',
|
||||
text: 'Is it important your job helps others or makes a difference?',
|
||||
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
|
||||
},
|
||||
{
|
||||
id: 'stability',
|
||||
text: 'How important is it that your career pays well?',
|
||||
options: ['Very important', 'Somewhat important', 'Not as important'],
|
||||
},
|
||||
{
|
||||
id: 'growth',
|
||||
text: 'Do you want clear chances to advance and grow professionally?',
|
||||
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
|
||||
},
|
||||
{
|
||||
id: 'balance',
|
||||
text: 'Do you prefer a job with flexible hours and time outside work?',
|
||||
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
|
||||
},
|
||||
{
|
||||
id: 'recognition',
|
||||
text: 'How important is it to have a career that others admire?',
|
||||
options: ['Very important', 'Somewhat important', 'Not as important'],
|
||||
},
|
||||
];
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = {
|
||||
firstName: userProfile.firstname,
|
||||
lastName: userProfile.lastname,
|
||||
email: userProfile.email,
|
||||
zipCode: userProfile.zipcode,
|
||||
state: userProfile.state,
|
||||
area: userProfile.area,
|
||||
careerSituation: userProfile.career_situation || null,
|
||||
career_priorities: JSON.stringify(responses),
|
||||
};
|
||||
|
||||
try {
|
||||
await authFetch('/api/user-profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error saving priorities:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const allAnswered = questions.every(q => responses[q.id]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center">
|
||||
<div className="bg-white p-6 rounded-lg shadow-lg w-full max-w-2xl overflow-y-auto max-h-[90vh]">
|
||||
<h2 className="text-xl font-bold mb-4">Tell us what's important to you</h2>
|
||||
{questions.map(q => (
|
||||
<div key={q.id} className="mb-4">
|
||||
<label className="block mb-2 font-medium">{q.text}</label>
|
||||
<select
|
||||
value={responses[q.id] || ''}
|
||||
onChange={(e) =>
|
||||
setResponses({ ...responses, [q.id]: e.target.value })
|
||||
}
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select an answer
|
||||
</option>
|
||||
{q.options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!allAnswered}
|
||||
className={`px-4 py-2 rounded ${allAnswered ? 'bg-blue-600 text-white' : 'bg-gray-300 cursor-not-allowed'}`}
|
||||
>
|
||||
Save Answers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CareerPrioritiesModal;
|
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { fetchSchools } from '../utils/apiUtils.ja';
|
||||
import { fetchSchools } from '../utils/apiUtils.js';
|
||||
|
||||
function EducationalPrograms({ cipCode, userState }) {
|
||||
const [schools, setSchools] = useState([]);
|
||||
|
@ -156,7 +156,7 @@ const InterestInventory = () => {
|
||||
const { careers: careerSuggestions, riaSecScores } = data;
|
||||
|
||||
if (Array.isArray(careerSuggestions) && Array.isArray(riaSecScores)) {
|
||||
navigate('/dashboard', { state: { careerSuggestions, riaSecScores } });
|
||||
navigate('/career-explorer', { state: { careerSuggestions, riaSecScores } });
|
||||
} else {
|
||||
throw new Error('Invalid data format from the server.');
|
||||
}
|
||||
|
@ -13,31 +13,39 @@ function PlanningLanding() {
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6 text-center">
|
||||
Discover career options that match your interests, skills, and potential.
|
||||
AptivaAI helps you find your ideal career path, provides insights into the educational requirements,
|
||||
AptivaAI helps you find your ideal career path, provides insights into educational requirements,
|
||||
expected salaries, job market trends, and more.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => navigate('/interest-inventory')}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
|
||||
<div>
|
||||
<Button className="w-full" onClick={() => navigate('/interest-inventory')}>
|
||||
Take Interest Inventory
|
||||
</Button>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Identify your interests and discover careers aligned with your strengths.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => navigate('/career-explorer')}
|
||||
>
|
||||
<div>
|
||||
<Button className="w-full" onClick={() => navigate('/career-explorer')}>
|
||||
Explore Career Paths
|
||||
</Button>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Research detailed career profiles, job descriptions, salaries, and employment outlooks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => navigate('/educational-programs')}
|
||||
>
|
||||
<div>
|
||||
<Button className="w-full" onClick={() => navigate('/educational-programs')}>
|
||||
Discover Educational Programs
|
||||
</Button>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Find the right educational programs, degrees, and certifications needed to pursue your chosen career.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
75
src/utils/PopulateCareerRatings.js
Normal file
75
src/utils/PopulateCareerRatings.js
Normal file
@ -0,0 +1,75 @@
|
||||
import axios from 'axios';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
const careersFile = '../../updated_career_data_final.json';
|
||||
const outputFile = '../../careers_with_ratings.json';
|
||||
|
||||
const onetUsername = 'aptivaai';
|
||||
const onetPassword = '2296ahq';
|
||||
|
||||
// Sleep function for delay between calls
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// Helper function to convert O*Net numeric scores (0-100) to a 1-5 scale
|
||||
const mapScoreToScale = (score) => {
|
||||
if(score >= 80) return 5;
|
||||
if(score >= 60) return 4;
|
||||
if(score >= 40) return 3;
|
||||
if(score >= 20) return 2;
|
||||
return 1;
|
||||
};
|
||||
|
||||
// Fully corrected function to fetch ratings from O*Net API
|
||||
const fetchCareerRatingsCorrected = async (socCode) => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`https://services.onetcenter.org/ws/online/occupations/${socCode}/details`,
|
||||
{ auth: { username: onetUsername, password: onetPassword } }
|
||||
);
|
||||
const data = response.data;
|
||||
|
||||
// Correctly parse the work_values array from O*Net's structure
|
||||
const workValues = Array.isArray(data.work_values?.element) ? data.work_values.element : [];
|
||||
|
||||
// Correctly parse the bright_outlook boolean from O*Net's structure
|
||||
const brightOutlook = data.occupation.tags.bright_outlook === true;
|
||||
|
||||
// Extract numerical scores for balance and recognition
|
||||
const balanceValue = workValues.find(v => v.name === 'Working Conditions')?.score.value || 0;
|
||||
const recognitionValue = workValues.find(v => v.name === 'Recognition')?.score.value || 0;
|
||||
|
||||
// Return mapped ratings accurately reflecting O*Net data
|
||||
return {
|
||||
growth: brightOutlook ? 5 : 1,
|
||||
balance: mapScoreToScale(balanceValue),
|
||||
recognition: mapScoreToScale(recognitionValue)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching details for ${socCode}:`, error.message);
|
||||
return {
|
||||
growth: 1,
|
||||
balance: 1,
|
||||
recognition: 1
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Main function to populate ratings for all careers
|
||||
const populateRatingsCorrected = async () => {
|
||||
const careersData = JSON.parse(await fs.readFile(careersFile, 'utf8'));
|
||||
for (let career of careersData) {
|
||||
const ratings = await fetchCareerRatingsCorrected(career.soc_code);
|
||||
if (ratings) {
|
||||
career.ratings = ratings;
|
||||
console.log(`Fetched ratings for: ${career.title}`);
|
||||
}
|
||||
// Wait for .5 seconds between API calls to avoid hitting API limits
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
await fs.writeFile(outputFile, JSON.stringify(careersData, null, 2));
|
||||
console.log(`Ratings populated to: ${outputFile}`);
|
||||
};
|
||||
|
||||
// Call the corrected function to populate ratings
|
||||
populateRatingsCorrected();
|
@ -17,6 +17,35 @@ export const fetchAreasByState = async (state) => {
|
||||
}
|
||||
};
|
||||
|
||||
export async function clientGeocodeZip(zip) {
|
||||
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
|
||||
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(zip)}&key=${apiKey}`;
|
||||
|
||||
const resp = await axios.get(url);
|
||||
if (resp.data.status === 'OK' && resp.data.results && resp.data.results.length > 0) {
|
||||
return resp.data.results[0].geometry.location; // { lat, lng }
|
||||
}
|
||||
throw new Error('Geocoding failed.');
|
||||
}
|
||||
|
||||
// utils/apiUtils.js
|
||||
|
||||
export function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
const R = 3959; // radius of earth in miles
|
||||
const toRad = (val) => (val * Math.PI) / 180;
|
||||
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) *
|
||||
Math.cos(toRad(lat2)) *
|
||||
Math.sin(dLon / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c;
|
||||
}
|
||||
|
||||
// Fetch schools
|
||||
export const fetchSchools = async (cipCode, state = '', level = '', type = '') => {
|
||||
|
158
src/utils/handleCareerClick.js
Normal file
158
src/utils/handleCareerClick.js
Normal file
@ -0,0 +1,158 @@
|
||||
// ============= handleCareerClick =============
|
||||
const handleCareerClick = useCallback(
|
||||
async (career) => {
|
||||
console.log('[handleCareerClick] career =>', career);
|
||||
const socCode = career.code;
|
||||
console.log('[handleCareerClick] career.code =>', socCode);
|
||||
setSelectedCareer(career);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCareerDetails({});
|
||||
setSchools([]);
|
||||
setSalaryData([]);
|
||||
setEconomicProjections({});
|
||||
setTuitionData([]);
|
||||
|
||||
if (!socCode) {
|
||||
console.error('SOC Code is missing');
|
||||
setError('SOC Code is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// CIP fetch
|
||||
const cipResponse = await fetch(`${apiUrl}/cip/${socCode}`);
|
||||
if (!cipResponse.ok) throw new Error('Failed to fetch CIP Code');
|
||||
const { cipCode } = await cipResponse.json();
|
||||
const cleanedCipCode = cipCode.replace('.', '').slice(0, 4);
|
||||
|
||||
// Job details
|
||||
const jobDetailsResponse = await fetch(`${apiUrl}/onet/career-description/${socCode}`);
|
||||
if (!jobDetailsResponse.ok) throw new Error('Failed to fetch job description');
|
||||
const { description, tasks } = await jobDetailsResponse.json();
|
||||
|
||||
// Salary
|
||||
let salaryResponse;
|
||||
try {
|
||||
salaryResponse = await axios.get(`${apiUrl}/salary`, {
|
||||
params: { socCode: socCode.split('.')[0], area: areaTitle },
|
||||
});
|
||||
} catch (error) {
|
||||
salaryResponse = { data: {} };
|
||||
}
|
||||
|
||||
const fullName = getFullStateName(userState);
|
||||
|
||||
// Economic
|
||||
let economicResponse;
|
||||
try {
|
||||
economicResponse = await axios.get(`${apiUrl}/projections/${socCode.split('.')[0]}`, {
|
||||
params: { state: fullName }, // e.g. "Kentucky"
|
||||
});
|
||||
} catch (error) {
|
||||
economicResponse = { data: {} };
|
||||
}
|
||||
|
||||
// Tuition
|
||||
let tuitionResponse;
|
||||
try {
|
||||
tuitionResponse = await axios.get(`${apiUrl}/tuition`, {
|
||||
params: { cipCode: cleanedCipCode, state: userState },
|
||||
});
|
||||
} catch (error) {
|
||||
tuitionResponse = { data: {} };
|
||||
}
|
||||
|
||||
// ** FETCH SCHOOLS NORMALLY **
|
||||
const filteredSchools = await fetchSchools(cleanedCipCode, userState);
|
||||
|
||||
// ** 1) Geocode user zip once on the client **
|
||||
let userLat = null;
|
||||
let userLng = null;
|
||||
if (userZipcode) {
|
||||
try {
|
||||
const geocodeResult = await clientGeocodeZip(userZipcode);
|
||||
userLat = geocodeResult.lat;
|
||||
userLng = geocodeResult.lng;
|
||||
} catch (err) {
|
||||
console.warn('Unable to geocode user ZIP, distances will be N/A.');
|
||||
}
|
||||
}
|
||||
|
||||
// ** 2) Compute Haversine distance locally for each school **
|
||||
const schoolsWithDistance = filteredSchools.map((sch) => {
|
||||
// only if we have lat/lon for both user + school
|
||||
const lat2 = sch.LATITUDE ? parseFloat(sch.LATITUDE) : null;
|
||||
const lon2 = sch.LONGITUD ? parseFloat(sch.LONGITUD) : null;
|
||||
|
||||
if (userLat && userLng && lat2 && lon2) {
|
||||
const distMiles = haversineDistance(userLat, userLng, lat2, lon2);
|
||||
return {
|
||||
...sch,
|
||||
distance: distMiles.toFixed(1) + ' mi',
|
||||
duration: 'N/A',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...sch,
|
||||
distance: 'N/A',
|
||||
duration: 'N/A',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Build salary array
|
||||
const sData = salaryResponse.data || {};
|
||||
const salaryDataPoints =
|
||||
sData && Object.keys(sData).length > 0
|
||||
? [
|
||||
{
|
||||
percentile: '10th Percentile',
|
||||
regionalSalary: parseInt(sData.regional?.regional_PCT10, 10) || 0,
|
||||
nationalSalary: parseInt(sData.national?.national_PCT10, 10) || 0,
|
||||
},
|
||||
{
|
||||
percentile: '25th Percentile',
|
||||
regionalSalary: parseInt(sData.regional?.regional_PCT25, 10) || 0,
|
||||
nationalSalary: parseInt(sData.national?.national_PCT25, 10) || 0,
|
||||
},
|
||||
{
|
||||
percentile: 'Median',
|
||||
regionalSalary: parseInt(sData.regional?.regional_MEDIAN, 10) || 0,
|
||||
nationalSalary: parseInt(sData.national?.national_MEDIAN, 10) || 0,
|
||||
},
|
||||
{
|
||||
percentile: '75th Percentile',
|
||||
regionalSalary: parseInt(sData.regional?.regional_PCT75, 10) || 0,
|
||||
nationalSalary: parseInt(sData.national?.national_PCT75, 10) || 0,
|
||||
},
|
||||
{
|
||||
percentile: '90th Percentile',
|
||||
regionalSalary: parseInt(sData.regional?.regional_PCT90, 10) || 0,
|
||||
nationalSalary: parseInt(sData.national?.national_PCT90, 10) || 0,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
// Build final details
|
||||
const updatedCareerDetails = {
|
||||
...career,
|
||||
jobDescription: description,
|
||||
tasks: tasks,
|
||||
economicProjections: economicResponse.data || {},
|
||||
salaryData: salaryDataPoints,
|
||||
schools: schoolsWithDistance,
|
||||
tuitionData: tuitionResponse.data || [],
|
||||
};
|
||||
|
||||
setCareerDetails(updatedCareerDetails);
|
||||
updateChatbotContext({ careerDetails: updatedCareerDetails });
|
||||
} catch (error) {
|
||||
console.error('Error processing career click:', error.message);
|
||||
setError('Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[userState, apiUrl, areaTitle, userZipcode, updateChatbotContext]
|
||||
);
|
35
src/utils/stateUtils.js
Normal file
35
src/utils/stateUtils.js
Normal file
@ -0,0 +1,35 @@
|
||||
// src/utils/stateUtils.js
|
||||
|
||||
export 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' },
|
||||
];
|
||||
|
||||
export function getFullStateName(code) {
|
||||
const found = STATES.find((s) => s.code === code?.toUpperCase());
|
||||
return found ? found.name : '';
|
||||
}
|
71
src/utils/testOutputFile.js
Normal file
71
src/utils/testOutputFile.js
Normal file
@ -0,0 +1,71 @@
|
||||
import axios from 'axios';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
const testOutputFile = '../../test_careers_with_ratings.json';
|
||||
|
||||
const onetUsername = 'aptivaai';
|
||||
const onetPassword = '2296ahq';
|
||||
|
||||
const testSocCodes = [
|
||||
'19-4071.00',
|
||||
'15-1241.00',
|
||||
];
|
||||
|
||||
const mapScoreToScale = (score) => {
|
||||
if(score >= 80) return 5;
|
||||
if(score >= 60) return 4;
|
||||
if(score >= 40) return 3;
|
||||
if(score >= 20) return 2;
|
||||
return 1;
|
||||
};
|
||||
|
||||
const fetchCareerRatingsTest = async (socCode) => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`https://services.onetcenter.org/ws/online/occupations/${socCode}/details`,
|
||||
{ auth: { username: onetUsername, password: onetPassword } }
|
||||
);
|
||||
const data = response.data;
|
||||
|
||||
const workValues = Array.isArray(data.work_values?.element) ? data.work_values.element : [];
|
||||
|
||||
const brightOutlook = data.occupation.tags.bright_outlook === true;
|
||||
|
||||
const balanceValue = workValues.find(v => v.name === 'Working Conditions')?.score.value || 0;
|
||||
const recognitionValue = workValues.find(v => v.name === 'Recognition')?.score.value || 0;
|
||||
|
||||
return {
|
||||
soc_code: socCode,
|
||||
title: data.occupation.title,
|
||||
ratings: {
|
||||
growth: brightOutlook ? 5 : 1,
|
||||
balance: mapScoreToScale(balanceValue),
|
||||
recognition: mapScoreToScale(recognitionValue)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching details for ${socCode}:`, error.message);
|
||||
return {
|
||||
soc_code,
|
||||
title: "Fetch Error",
|
||||
ratings: { growth: 1, balance: 1, recognition: 1 }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const runQuickTest = async () => {
|
||||
const results = [];
|
||||
|
||||
for (const socCode of testSocCodes) {
|
||||
const careerData = await fetchCareerRatingsTest(socCode);
|
||||
if (careerData) {
|
||||
results.push(careerData);
|
||||
console.log(`Fetched ratings for: ${careerData.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(testOutputFile, JSON.stringify(results, null, 2));
|
||||
console.log(`Test ratings populated to: ${testOutputFile}`);
|
||||
};
|
||||
|
||||
runQuickTest();
|
20
test_careers_with_ratings.json
Normal file
20
test_careers_with_ratings.json
Normal file
@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"soc_code": "19-4071.00",
|
||||
"title": "Forest and Conservation Technicians",
|
||||
"ratings": {
|
||||
"growth": 1,
|
||||
"balance": 3,
|
||||
"recognition": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"soc_code": "15-1241.00",
|
||||
"title": "Computer Network Architects",
|
||||
"ratings": {
|
||||
"growth": 5,
|
||||
"balance": 4,
|
||||
"recognition": 3
|
||||
}
|
||||
}
|
||||
]
|
32531
updated_career_data_final.json
Normal file
32531
updated_career_data_final.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user