Distance Calculations

This commit is contained in:
Josh 2024-12-30 22:53:42 +00:00
parent b7461ef06b
commit 2331378114
12 changed files with 461 additions and 252 deletions

View File

@ -2,7 +2,7 @@ ONET_USERNAME=aptivaai
ONET_PASSWORD=2296ahq
REACT_APP_BLS_API_KEY=80d7a65a809a43f3a306a41ec874d231
GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
REACT_APP_GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
COLLEGE_SCORECARD_KEY = BlZ0tIdmXVGI4G8NxJ9e6dXEiGUfAfnQJyw8bumj
REACT_APP_API_URL=http://localhost:5001

4
.gitignore vendored
View File

@ -13,10 +13,6 @@
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*

View File

@ -23,6 +23,8 @@ dotenv.config({ path: envPath });
console.log(`Loaded environment variables from: ${envPath}`);
console.log('ONET_USERNAME:', process.env.ONET_USERNAME);
console.log('ONET_PASSWORD:', process.env.ONET_PASSWORD);
console.log('Google Maps API Key:', process.env.GOOGLE_MAPS_API_KEY)
const allowedOrigins = ['http://localhost:3000', 'http://34.16.120.118:3000', 'https://dev.aptivaai.com'];
const mappingFilePath = '/home/jcoakley/aptiva-dev1-app/public/CIP_to_ONET_SOC.xlsx';
@ -49,6 +51,22 @@ const initDB = async () => {
// Initialize database before starting the server
initDB();
let userProfileDb;
const initUserProfileDb = async () => {
try {
userProfileDb = await open({
filename: '/home/jcoakley/aptiva-dev1-app/user_profile.db', // Path to user_profile.db
driver: sqlite3.Database
});
console.log('Connected to user_profile.db.');
} catch (error) {
console.error('Error connecting to user_profile.db:', error);
}
};
// Initialize user_profile.db before starting the server
initUserProfileDb();
// Add security headers using helmet
app.use(
helmet({
@ -158,34 +176,88 @@ app.get('/api/onet/questions', async (req, res) => {
}
});
// New route to handle Google Maps geocoding
app.get('/api/maps/distance', async (req, res) => {
const { origins, destinations } = req.query;
console.log('Query parameters received:', req.query); // Log the entire query object
// Helper function to geocode an address or zip code
// Function to geocode a ZIP code
const geocodeZipCode = async (zipCode) => {
try {
const geocodeUrl = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(userZipcode)}&key=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20`;
console.log('Constructed Geocode URL:', geocodeUrl); // Check if encoding helps
if (!origins || !destinations) {
console.error('Missing parameters:', { origins, destinations });
return res
.status(400)
.json({ error: 'Origin and destination parameters are required.' });
const response = await axios.get(geocodeUrl);
if (response.data.status === 'OK' && response.data.results.length > 0) {
const location = response.data.results[0].geometry.location;
return location; // Return the latitude and longitude
} else {
throw new Error('Geocoding failed');
}
} catch (error) {
console.error('Error geocoding ZIP code:', error.message);
return null;
}
};
app.post('/api/maps/distance', async (req, res) => {
const { userZipcode, destinations } = req.body;
if (!userZipcode || !destinations) {
return res.status(400).json({ error: 'User ZIP code and destination address are required.' });
}
const apiKey = process.env.GOOGLE_MAPS_API_KEY; // Use the Google Maps API key from the environment variable
const distanceUrl = `https://maps.googleapis.com/maps/api/distancematrix/json?origins=${encodeURIComponent(
origins
)}&destinations=${encodeURIComponent(destinations)}&units=imperial&key=${apiKey}`;
console.log('Constructed Distance Matrix API URL:', distanceUrl);
try {
const response = await axios.get(distanceUrl);
res.status(200).json(response.data);
const googleMapsApiKey = process.env.GOOGLE_MAPS_API_KEY; // Get the API key
// Geocode the user's ZIP code
const geocodeUrl = `https://maps.googleapis.com/maps/api/geocode/json?address=${userZipcode}&components=country:US&key=${googleMapsApiKey}`;
const geocodeResponse = await axios.get(geocodeUrl);
if (geocodeResponse.data.status === 'OK' && geocodeResponse.data.results.length > 0) {
const userLocation = geocodeResponse.data.results[0].geometry.location; // Get lat, lng
const origins = `${userLocation.lat},${userLocation.lng}`; // User's location as lat/lng
// Call the Distance Matrix API using the geocoded user location and school address
const distanceUrl = `https://maps.googleapis.com/maps/api/distancematrix/json?origins=${origins}&destinations=${encodeURIComponent(destinations)}&units=imperial&key=${googleMapsApiKey}`;
const distanceResponse = await axios.get(distanceUrl);
if (distanceResponse.data.status !== 'OK') {
return res.status(500).json({ error: 'Error fetching distance from Google Maps API' });
}
const { distance, duration } = distanceResponse.data.rows[0].elements[0];
res.json({ distance: distance.text, duration: duration.text });
} else {
return res.status(400).json({ error: 'Unable to geocode user ZIP code.' });
}
} catch (error) {
console.error('Error fetching distance data:', error.message);
res.status(500).json({ error: 'Failed to fetch distance data', details: error.message });
console.error('Error during distance calculation:', error);
res.status(500).json({ error: 'Internal server error', details: error.message });
}
});
// Route to fetch user profile by ID including ZIP code
app.get('/api/user-profile/:id', (req, res) => {
const { id } = req.params;
if (!id) {
return res.status(400).json({ error: 'Profile ID is required' });
}
const query = `SELECT area, zipcode FROM user_profile WHERE id = ?`;
db.get(query, [id], (err, row) => {
if (err) {
console.error('Error fetching user profile:', err.message);
return res.status(500).json({ error: 'Failed to fetch user profile' });
}
if (!row) {
return res.status(404).json({ error: 'Profile not found' });
}
res.json({ area: row.area, zipcode: row.zipcode });
});
});
// Load the economic projections data from the Excel file
const projectionsFilePath = path.resolve(__dirname, '..', 'public', 'occprj.xlsx'); // Adjusted path
@ -498,7 +570,7 @@ app.get('/api/user-profile/:id', (req, res) => {
return res.status(400).json({ error: 'Profile ID is required' });
}
const query = `SELECT area FROM user_profile WHERE id = ?`;
const query = `SELECT area, zipcode FROM user_profile WHERE id = ?`;
db.get(query, [id], (err, row) => {
if (err) {
console.error('Error fetching user profile:', err.message);
@ -509,7 +581,7 @@ app.get('/api/user-profile/:id', (req, res) => {
return res.status(404).json({ error: 'Profile not found' });
}
res.json({ area: row.area });
res.json({ area: row.area, zipcode: row.zipcode });
});
});

8
package-lock.json generated
View File

@ -16378,16 +16378,16 @@
}
},
"node_modules/typescript": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
"node": ">=4.2.0"
}
},
"node_modules/unbox-primitive": {

BIN
public/ltprojections.xlsx Normal file

Binary file not shown.

View File

@ -9,22 +9,19 @@ export function CareerSuggestions({ careerSuggestions = [], onCareerClick }) {
return (
<div>
<h2>Career Suggestions</h2>
<ul>
{careerSuggestions.map((career, index) => {
return (
<li key={index}>
<button
onClick={() => {
console.log('Button clicked for:', career);
onCareerClick(career);
}}
>
{career.title || `Career ${index + 1}`}
</button>
</li>
);
})}
</ul>
<div className="career-suggestions-grid">
{careerSuggestions.map((career) => (
<button
key={career.code}
className="career-button"
onClick={() => onCareerClick(career)}
>
{career.title}
</button>
))}
</div>
</div>
);
}
};
export default CareerSuggestions;

View File

@ -1,13 +1,20 @@
/* Dashboard.css */
/* Main Dashboard Layout */
.dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); /* Ensure responsive layout */
grid-template-columns: 1fr 2fr; /* Two columns: careers on the left, RIASEC on the right */
gap: 20px;
min-height: 100vh; /* Full height */
padding: 20px;
background-color: #f4f7fa;
}
.dashboard-content {
flex-grow: 1; /* Push acknowledgment to the bottom */
}
/* Sections in Dashboard */
.dashboard section {
background-color: #ffffff;
padding: 15px;
@ -22,6 +29,7 @@
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
}
/* Headings */
h2 {
font-size: 1.4rem;
color: #2b4a67;
@ -30,88 +38,50 @@ h2 {
padding-bottom: 4px;
}
.career-suggestions h1 {
font-size: 1.75rem; /* Adjusted size for better hierarchy */
margin-bottom: 15px;
color: #2b4a67;
}
.career-list {
/* Career Suggestions Grid */
.career-suggestions-container {
display: flex;
flex-direction: column;
gap: 10px;
}
.career-item {
padding: 8px;
background-color: #eaf6fb;
border: 1px solid #d1e7f0;
border-radius: 4px;
transition: background-color 0.2s ease;
.career-suggestions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); /* Flexible grid */
gap: 10px; /* Even spacing */
padding: 10px;
}
.career-item:hover {
background-color: #d4edf6;
}
.career-item h3 {
font-size: 1.1rem; /* Smaller font for career titles */
margin-bottom: 4px;
font-weight: bold;
color: #34596a;
}
.career-item p {
font-size: 0.9rem;
margin: 4px 0;
color: #555555;
}
.riasec-scores h2 {
font-size: 1.5rem;
margin-bottom: 20px;
}
.chart-container {
margin: 20px 0;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.sign-out-container {
text-align: center;
margin-top: 20px;
}
.sign-out-btn {
padding: 10px 20px;
.career-button {
padding: 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 20px;
font-size: 1rem;
width: 150px;
text-align: center;
white-space: normal; /* Allow wrapping */
}
.sign-out-btn:hover {
.career-button:hover {
background-color: #0056b3;
}
.career-item.selected {
background-color: #cce5ff; /* Light blue background when selected */
border: 2px solid #0056b3; /* Dark blue border */
cursor: pointer;
/* RIASEC Scores and Descriptions */
.riasec-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.career-item:hover {
background-color: #e6f7ff; /* Lighter blue when hovering */
.riasec-scores {
padding: 15px;
border-radius: 8px;
background-color: #ffffff;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
}
.riasec-descriptions {
margin-top: 20px;
padding: 15px;
border-radius: 8px;
background-color: #f1f8ff;
@ -131,3 +101,42 @@ h2 {
.riasec-descriptions strong {
color: #007bff;
}
/* Data Source Acknowledgment */
.data-source-acknowledgment {
grid-column: span 2; /* Make acknowledgment span both columns */
margin-top: 20px;
padding: 10px;
border-top: 1px solid #ccc;
font-size: 12px;
color: #666;
text-align: center;
}
/* Chart Container */
.chart-container {
margin: 20px 0;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
/* Sign Out Button */
.sign-out-container {
text-align: center;
margin-top: 20px;
}
.sign-out-btn {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
font-size: 1rem;
width: 150px;
}
.sign-out-btn:hover {
background-color: #0056b3;
}

View File

@ -27,10 +27,12 @@ function Dashboard() {
const [error, setError] = useState(null);
const [userState, setUserState] = useState(null);
const [areaTitle, setAreaTitle] = useState(null);
const [userZipcode, setUserZipcode] = useState(null);
const [riaSecDescriptions, setRiaSecDescriptions] = useState([]);
// Dynamic API URL
const apiUrl = process.env.REACT_APP_API_URL || '';
const googleMapsApiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
useEffect(() => {
let descriptions = []; // Declare outside for scope accessibility
@ -58,10 +60,11 @@ function Dashboard() {
const profileData = await profileResponse.json();
console.log('Fetched User Profile:', profileData);
const { state, area } = profileData; // Use 'area' instead of 'AREA_TITLE'
const { state, area, zipcode } = profileData; // Use 'area' instead of 'AREA_TITLE'
setUserState(state);
setAreaTitle(area && area.trim() ? area.trim() : ''); // Ensure 'area' is set correctly
console.log('Profile Data Set:', { state, area });
setUserZipcode(zipcode); // Set 'zipcode' in the state
console.log('Profile Data Set:', { state, area, zipcode });
} else {
console.error('Failed to fetch user profile');
}
@ -80,10 +83,10 @@ function Dashboard() {
setLoading(true); // Enable loading state only when career is clicked
setError(null); // Clear previous errors
setCareerDetails({}); // Reset career details to avoid undefined errors
setSchools([]);
setSalaryData([]);
setEconomicProjections({});
setTuitionData([]);
setSchools([]); // Reset schools
setSalaryData([]); // Reset salary data
setEconomicProjections({}); // Reset economic projections
setTuitionData([]); // Reset tuition data
if (!socCode) {
console.error('SOC Code is missing');
@ -93,32 +96,72 @@ function Dashboard() {
try {
// Step 1: Fetch CIP Code
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 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);
// Step 2: Fetch Data in Parallel
const [filteredSchools, economicResponse, tuitionResponse, salaryResponse] = await Promise.all([
fetchSchools(cleanedCipCode, userState),
// Step 2: Fetch Data in Parallel
const [filteredSchools, economicResponse, tuitionResponse, salaryResponse] = await Promise.all([
fetchSchools(cleanedCipCode, userState),
axios.get(`${apiUrl}/projections/${socCode.split('.')[0]}`),
axios.get(`${apiUrl}/tuition`, { // <-- Removed CIP code from URL path
axios.get(`${apiUrl}/tuition`, {
params: {
cipCode: cleanedCipCode, // Moved to query params
cipCode: cleanedCipCode,
state: userState
},
}),
axios.get(`${apiUrl}/salary`, {
params: {
socCode: socCode.split('.')[0],
area: areaTitle // Pass the areaTitle directly as it is
area: areaTitle
},
}),
]);
// Step 3: Format Salary Data
// Check if `userZipcode` is set correctly
const currentUserZipcode = userZipcode;
if (!currentUserZipcode) {
console.error("User Zipcode is not set correctly:", currentUserZipcode);
return;
}
// Step 3: Add distance information to each school
const schoolsWithDistance = await Promise.all(filteredSchools.map(async (school) => {
// Combine Street Address, City, State, and ZIP to form the full school address
const schoolAddress = `${school.Address}, ${school.City}, ${school.State} ${school.ZIP}`;
if (!currentUserZipcode || !schoolAddress) {
console.error('Missing ZIP codes or school address:', { currentUserZipcode, schoolAddress });
return { ...school, distance: 'Error', duration: 'Error' };
}
console.log("Calculating distance for User Zipcode:", currentUserZipcode, "and School Address:", schoolAddress);
try {
// Send the user's ZIP code and school address to the backend
const response = await axios.post(`${apiUrl}/maps/distance`, {
userZipcode: currentUserZipcode, // Pass the ZIP code (or lat/lng) of the user
destinations: schoolAddress, // Pass the full school address
});
const { distance, duration } = response.data;
return {
...school, // Keep all school data
distance, // Add the distance value
duration, // Add the duration value
};
} catch (error) {
console.error('Error fetching distance:', error);
return {
...school,
distance: 'Error',
duration: 'Error',
};
}
}));
// Step 4: Format Salary Data
const salaryDataPoints = [
{ percentile: '10th Percentile', value: salaryResponse.data.A_PCT10 || 0 },
{ percentile: '25th Percentile', value: salaryResponse.data.A_PCT25 || 0 },
@ -127,14 +170,14 @@ function Dashboard() {
{ percentile: '90th Percentile', value: salaryResponse.data.A_PCT90 || 0 },
];
// Step 4: Consolidate Career Details
setCareerDetails({
...career,
economicProjections: economicResponse.data,
salaryData: salaryDataPoints,
schools: filteredSchools,
tuitionData: tuitionResponse.data,
});
// Step 5: Consolidate Career Details
setCareerDetails({
...career,
economicProjections: economicResponse.data,
salaryData: salaryDataPoints,
schools: schoolsWithDistance, // Add schools with distances
tuitionData: tuitionResponse.data,
});
} catch (error) {
console.error('Error processing career click:', error.message);
setError('Failed to load data');
@ -145,6 +188,7 @@ function Dashboard() {
[userState, apiUrl, areaTitle]
);
const chartData = {
labels: riaSecScores.map((score) => score.area),
datasets: [
@ -200,6 +244,26 @@ function Dashboard() {
userState={userState}
/>
)}
{/* Acknowledgment Section */}
<div
className="data-source-acknowledgment"
style={{
marginTop: '20px',
padding: '10px',
borderTop: '1px solid #ccc',
fontSize: '12px',
color: '#666',
textAlign: 'center'
}}
>
<p>
Career results and RIASEC scores are provided by
<a href="https://www.onetcenter.org" target="_blank" rel="noopener noreferrer"> O*Net</a>, in conjunction with the
<a href="https://www.bls.gov" target="_blank" rel="noopener noreferrer"> Bureau of Labor Statistics</a>, and the
<a href="https://nces.ed.gov" target="_blank" rel="noopener noreferrer"> National Center for Education Statistics (NCES)</a>.
</p>
</div>
</div>
);
}

View File

@ -1,67 +1,62 @@
// EconomicProjections (in EconomicProjections.js)
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import * as XLSX from 'xlsx';
function EconomicProjections({ socCode }) {
const [projections, setProjections] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
console.log(axios.defaults.baseURL); // Check if baseURL is set
const apiUrl = process.env.REACT_APP_API_URL || '/api';
useEffect(() => {
if (socCode) {
const cleanedSocCode = socCode.split('.')[0]; // Clean the SOC code inside the component
const cleanedSocCode = socCode.split('.')[0]; // Clean the SOC code
console.log(`Fetching economic projections for cleaned SOC code: ${cleanedSocCode}`);
const fetchProjections = async () => {
const projectionsUrl = `${apiUrl}/projections/${cleanedSocCode}`;
// Log URL and SOC code only in development
if (process.env.NODE_ENV !== 'production') {
console.log(`Fetching projections from: ${projectionsUrl}`);
console.log(`Cleaned SOC Code: ${cleanedSocCode}`);
}
try {
const cleanedSocCode = socCode.split('.')[0]; // Clean SOC code
const projectionsUrl = `${apiUrl}/projections/${cleanedSocCode}`;
try {
// Load the Excel file from public folder
const response = await fetch('/public/ltprojections.xlsx');
const arrayBuffer = await response.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
// Log URL and SOC code only in development
if (process.env.NODE_ENV !== 'production') {
console.log(`Fetching projections from: ${projectionsUrl}`);
console.log(`Cleaned SOC Code: ${cleanedSocCode}`);
// Assuming data is in the first sheet
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(sheet);
// Find the projections matching the SOC code
const filteredProjections = data.find(
(row) => row['SOC Code'] === cleanedSocCode
);
if (filteredProjections) {
setProjections(filteredProjections);
} else {
throw new Error('No data found for the given SOC code.');
}
} catch (err) {
setError('Error loading economic projections.');
console.error('Projections Fetch Error:', err.message);
} finally {
setLoading(false);
}
// API call to fetch projections
const response = await axios.get(projectionsUrl);
if (response.status === 200 && response.data) {
setProjections(response.data); // Set projections
if (process.env.NODE_ENV !== 'production') {
console.log('Projections Response:', response.data); // Log data in development
}
} else {
throw new Error('Invalid response from server.');
}
} catch (err) {
setError('Error fetching economic projections.');
console.error('Projections Fetch Error:', err.message);
}
};
};
fetchProjections();
}
}, [socCode,apiUrl]); // This runs when the socCode prop changes
}, [socCode]);
if (error) {
return <div className="error">{error}</div>;
}
if (!projections) {
return <div>Loading economic projections...</div>;
if (loading) {
return <div className="loading-spinner">Loading...</div>;
}
return (
<div className="economic-projections">
<h3>Economic Projections for {projections.Occupation || 'Unknown Occupation'}</h3>
<h3>Economic Projections for {projections['Occupation Title'] || 'Unknown Occupation'}</h3>
<ul>
<li>2022 Employment: {projections['2022 Employment']}</li>
<li>2032 Employment: {projections['2032 Employment']}</li>

View File

@ -5,16 +5,15 @@ function LoanRepayment({ schools, salaryData, earningHorizon }) {
const [interestRate, setInterestRate] = useState(5.5); // Default federal loan interest rate
const [loanTerm, setLoanTerm] = useState(10); // Default loan term (10 years)
const [extraPayment, setExtraPayment] = useState(0); // Extra monthly payment
const [currentSalary, setCurrentSalary] = useState(0); // Current salary input
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [sortBy, setSortBy] = useState('monthlyPayment'); // Default sort option
const [currentSalary, setCurrentSalary] = useState(0); // New state for current salary
// Validation function
const validateInputs = () => {
if (!schools || schools.length === 0 || !salaryData || salaryData.length === 0) {
setError('School or salary data is missing.');
if (!schools || schools.length === 0) {
setError('School data is missing. Loan calculations cannot proceed.');
return false;
}
@ -42,10 +41,10 @@ function LoanRepayment({ schools, salaryData, earningHorizon }) {
return true;
};
// Calculate loan details for each school
// Loan calculation function for all schools
const calculateLoanDetails = () => {
if (!validateInputs()) return;
setLoading(true);
if (!validateInputs()) return; // Validate inputs before calculation
const schoolResults = schools.map((school) => {
const tuition = tuitionType === 'inState' ? school.inState : school.outOfState;
const monthlyRate = interestRate / 12 / 100;
@ -69,23 +68,36 @@ function LoanRepayment({ schools, salaryData, earningHorizon }) {
const totalLoanCost = extraMonthlyPayment * monthsWithExtra;
// Calculate net gain
const salary = salaryData[0].value; // 10th percentile salary as default
const totalSalary = salary * earningHorizon;
const netGain = totalSalary - totalLoanCost;
// Handle missing salary data
let salary = salaryData && salaryData[0]?.value ? salaryData[0].value : null;
let netGain = 'N/A';
let monthlySalary = 'N/A';
if (salary) {
// Calculate net gain
const totalSalary = salary * earningHorizon;
const currentSalaryEarnings = currentSalary * earningHorizon * Math.pow(1.03, earningHorizon); // 3% growth
netGain = (totalSalary - totalLoanCost - currentSalaryEarnings).toFixed(2);
// Monthly salary
monthlySalary = (salary / 12).toFixed(2);
}
return {
...school,
tuition,
monthlyPayment: minimumMonthlyPayment.toFixed(2),
totalMonthlyPayment: extraMonthlyPayment.toFixed(2), // Add total payment including extra
totalLoanCost: totalLoanCost.toFixed(2),
netGain: netGain.toFixed(2),
netGain,
monthlySalary,
};
});
setResults(schoolResults);
};
return (
<div>
<h2>Loan Repayment and ROI Analysis</h2>
@ -93,45 +105,68 @@ function LoanRepayment({ schools, salaryData, earningHorizon }) {
<div>
<label>
Tuition Type:
<select value={tuitionType} onChange={(e) => setTuitionType(e.target.value)}>
<select
value={tuitionType}
onChange={(e) => setTuitionType(e.target.value)}
>
<option value="inState">In-State</option>
<option value="outOfState">Out-of-State</option>
</select>
</label>
<label>
Interest Rate (%):
<input type="number" value={interestRate} onChange={(e) => setInterestRate(Number(e.target.value))} />
<input
type="number"
value={interestRate}
onChange={(e) => setInterestRate(Number(e.target.value))}
onFocus={(e) => e.target.select()}
min="0"
/>
</label>
<label>
Loan Term (Years):
<input type="number" value={loanTerm} onChange={(e) => setLoanTerm(Number(e.target.value))} />
<input
type="number"
value={loanTerm}
onChange={(e) => setLoanTerm(Number(e.target.value))}
onFocus={(e) => e.target.select()}
min="0"
/>
</label>
<label>
Extra Monthly Payment ($):
<input
type="number"
type="number"
value={extraPayment}
placeholder="0" // Placeholder added
onChange={(e) => setExtraPayment(e.target.value)}
onFocus={(e) => e.target.select()} // Clear field on focus
/>
onChange={(e) => setExtraPayment(Number(e.target.value))}
onFocus={(e) => e.target.select()}
min="0"
/>
</label>
<label>
Current Salary ($):
Current Salary (Gross Annual $):
<input
type="number"
value={currentSalary}
placeholder="0" // Placeholder added
onChange={(e) => setCurrentSalary(e.target.value)}
onFocus={(e) => e.target.select()} // Clear field on focus
onFocus={(e) => e.target.select()}
min="0"
/>
</label>
<button onClick={calculateLoanDetails} disabled={loading}>
{loading ? 'Calculating...' : 'Calculate'}
</button>
</div>
{/* Results Section */}
{/* Error Message */}
{error && <p style={{ color: 'red' }}>{error}</p>}
{/* Results Display */}
{results.length > 0 && (
<div>
<h3>Comparison by School</h3>
@ -140,14 +175,22 @@ function LoanRepayment({ schools, salaryData, earningHorizon }) {
<h4>{result.schoolName}</h4>
<p>Total Tuition: ${result.tuition}</p>
<p>Monthly Payment: ${result.monthlyPayment}</p>
<p>Total Monthly Payment (with extra): ${result.totalMonthlyPayment}</p>
<p>Total Loan Cost: ${result.totalLoanCost}</p>
<p>Net Gain: ${result.netGain}</p>
<p>Net Gain: {result.netGain}</p>
<p>Monthly Salary (Gross): {result.monthlySalary}</p>
</div>
))}
</div>
)}
{/* Salary Warning */}
{!salaryData || salaryData.length === 0 ? (
<p style={{ color: 'red' }}>Salary data is not available for this profession. Loan calculations are limited.</p>
) : null}
</div>
);
}
export default LoanRepayment;

View File

@ -1,28 +1,42 @@
.popout-panel {
position: fixed;
top: 0;
right: 0;
width: 40%;
height: 100%;
background-color: #fff;
box-shadow: -3px 0 5px rgba(0, 0, 0, 0.3);
padding: 20px;
overflow-y: auto;
transform: translateX(0);
transition: transform 0.3s ease-in-out;
}
position: fixed;
top: 0;
right: 0;
width: 40%; /* Default width for larger screens */
height: 100%;
background-color: #fff;
box-shadow: -3px 0 5px rgba(0, 0, 0, 0.3);
padding: 20px;
overflow-y: auto;
transform: translateX(0); /* Ensure visibility by default */
transition: transform 0.3s ease-in-out;
z-index: 1000; /* Ensures it is above other elements */
}
.close-btn {
position: absolute;
top: 10px;
right: 15px;
background-color: #dc3545;
color: #fff;
border: none;
padding: 5px 10px;
font-size: 1rem;
cursor: pointer;
/* Mobile responsiveness */
@media (max-width: 768px) {
.popout-panel {
width: 100%; /* Use full width for smaller screens */
height: 100%; /* Cover full height */
left: 0; /* Ensure it appears on the left for mobile */
right: unset; /* Override right alignment */
}
}
/* Close button adjustments for mobile */
.close-btn {
position: absolute;
top: 10px;
right: 15px;
background-color: #dc3545;
color: #fff;
border: none;
padding: 8px 12px;
font-size: 1rem;
cursor: pointer;
z-index: 1001; /* Keep button above the panel */
}
h3 {
margin-top: 20px;
@ -53,3 +67,9 @@
background-color: #0056b3;
}
/* Fix for overflow issue */
html, body {
overflow-x: hidden; /* Prevent horizontal scrolling */
}

View File

@ -11,6 +11,7 @@ function PopoutPanel({
}) {
console.log('PopoutPanel Props:', { data, loading, error, userState });
if (loading) {
return (
<div className="popout-panel">
@ -78,17 +79,19 @@ function PopoutPanel({
tuition['INSTNM']?.toLowerCase().trim() === // Corrected field
school['INSTNM']?.toLowerCase().trim() // Match institution name
);
console.log('Schools Data in PopoutPanel:', schools);
return (
<li key={index}>
<strong>{school['INSTNM']}</strong> {/* Updated field */}
<br />
Degree Type: {school['CREDDESC'] || 'N/A'} {/* Updated field */}
Degree Type: {school['CREDDESC'] || 'Degree type information is not available for this program'} {/* Updated field */}
<br />
In-State Tuition: ${school['In_state cost'] || 'N/A'} {/* Updated field */}
In-State Tuition: ${school['In_state cost'] || 'Tuition information is not available for this school'} {/* Updated field */}
<br />
Out-of-State Tuition: ${school['Out_state cost'] || 'N/A'} {/* Updated field */}
Out-of-State Tuition: ${school['Out_state cost'] || 'Tuition information is not available for this school'} {/* Updated field */}
<br />
Distance: {school['distance'] || 'Distance to school not available'} {/* Added Distance field */}
<br />
Website:
<a href={school['Website']} target="_blank"> {/* Updated field */}
{school['Website']}
@ -98,7 +101,7 @@ function PopoutPanel({
})}
</ul>
) : (
<p>No schools available.</p>
<p>No schools of higher education are available for this career path.</p>
)}
@ -111,7 +114,7 @@ function PopoutPanel({
<li>Total Change: {economicProjections['Total Change'] || 'N/A'}</li>
</ul>
) : (
<p>No economic projections available.</p>
<p>No economic projections available for this career path.</p>
)}
{/* Salary Data Points */}
@ -136,24 +139,34 @@ function PopoutPanel({
</tbody>
</table>
) : (
<p>No salary data available.</p>
<p>No salary data is available for this career path.</p>
)}
{/* Loan Repayment Analysis */}
{tenthPercentileSalary > 0 && (
<div className="loan-repayment-analysis">
<h3>Loan Repayment Analysis</h3>
<LoanRepayment
schools={schools.map((school) => {
const years = getProgramLength(school['CREDDESC']); // Updated field
return {
schoolName: school['INSTNM'], // Updated field
inState: parseFloat(school['In_state cost'] * years) || 0, // Updated field
outOfState: parseFloat(school['Out_state cost'] * years) || 0, // Updated field
};
})}
salaryData={[{ percentile: '10th Percentile', value: tenthPercentileSalary, growthRate: 0.03 }]}
earningHorizon={10}
/>
)}
schools={schools.map((school) => {
const years = getProgramLength(school['CREDDESC']);
return {
schoolName: school['INSTNM'],
inState: parseFloat(school['In_state cost'] * years) || 0,
outOfState: parseFloat(school['Out_state cost'] * years) || 0,
};
})}
salaryData={
tenthPercentileSalary > 0
? [{ percentile: '10th Percentile', value: tenthPercentileSalary, growthRate: 0.03 }]
: []
}
earningHorizon={10}
/>
{!tenthPercentileSalary && (
<p>
<em>Salary data unavailable. Loan details are based on cost alone.</em>
</p>
)}
</div>
</div>
);
}