557 lines
18 KiB
JavaScript
557 lines
18 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { useLocation, useNavigate } from 'react-router-dom';
|
||
import CareerSearch from './CareerSearch.js';
|
||
import { ONET_DEFINITIONS } from './definitions.js';
|
||
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js';
|
||
|
||
// Helper to combine IM and LV for each KSA
|
||
function combineIMandLV(rows) {
|
||
const map = new Map();
|
||
|
||
for (const row of rows) {
|
||
const key = `${row.onetSocCode}::${row.elementName}::${row.ksa_type}`;
|
||
if (!map.has(key)) {
|
||
map.set(key, {
|
||
onetSocCode: row.onetSocCode,
|
||
elementName: row.elementName,
|
||
ksa_type: row.ksa_type,
|
||
importanceValue: null,
|
||
levelValue: null,
|
||
});
|
||
}
|
||
const entry = map.get(key);
|
||
if (row.scaleID === 'IM') {
|
||
entry.importanceValue = row.dataValue;
|
||
} else if (row.scaleID === 'LV') {
|
||
entry.levelValue = row.dataValue;
|
||
}
|
||
map.set(key, entry);
|
||
}
|
||
|
||
return Array.from(map.values());
|
||
}
|
||
|
||
function ensureHttp(urlString) {
|
||
if (!urlString) return '';
|
||
// If it already starts with 'http://' or 'https://', just return as-is.
|
||
if (/^https?:\/\//i.test(urlString)) {
|
||
return urlString;
|
||
}
|
||
// Otherwise prepend 'https://' (or 'http://').
|
||
return `https://${urlString}`;
|
||
}
|
||
|
||
// Convert numeric importance (1–5) to star or emoji compact representation
|
||
function renderImportance(val) {
|
||
const max = 5;
|
||
const rounded = Math.round(val);
|
||
const stars = '★'.repeat(rounded) + '☆'.repeat(max - rounded);
|
||
return `${stars}`;
|
||
}
|
||
|
||
// Convert numeric level (0–7) to bar or block representation
|
||
function renderLevel(val) {
|
||
const max = 7;
|
||
const rounded = Math.round(val);
|
||
const filled = '■'.repeat(rounded);
|
||
const empty = '□'.repeat(max - rounded);
|
||
return `${filled}${empty}`;
|
||
}
|
||
|
||
function EducationalProgramsPage() {
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
const [socCode, setsocCode] = useState(location.state?.socCode || '');
|
||
const [cipCodes, setCipCodes] = useState(location.state?.cipCodes || []);
|
||
|
||
const [userState, setUserState] = useState(location.state?.userState || '');
|
||
const [userZip, setUserZip] = useState(location.state?.userZip || '');
|
||
|
||
const [allKsaData, setAllKsaData] = useState([]);
|
||
const [ksaForCareer, setKsaForCareer] = useState([]);
|
||
const [loadingKsa, setLoadingKsa] = useState(false);
|
||
const [ksaError, setKsaError] = useState(null);
|
||
|
||
const [schools, setSchools] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
|
||
// Additional filters
|
||
const [sortBy, setSortBy] = useState('tuition');
|
||
const [maxTuition, setMaxTuition] = useState(20000);
|
||
const [maxDistance, setMaxDistance] = useState(100);
|
||
const [inStateOnly, setInStateOnly] = useState(false);
|
||
const [careerTitle, setCareerTitle] = useState(location.state?.careerTitle || '');
|
||
|
||
// If user picks a new career from CareerSearch
|
||
const handleCareerSelected = (foundObj) => {
|
||
setCareerTitle(foundObj.title || '');
|
||
let rawCips = Array.isArray(foundObj.cip_code) ? foundObj.cip_code : [foundObj.cip_code];
|
||
|
||
const cleanedCips = rawCips.map((code) => {
|
||
const codeStr = code.toString();
|
||
return codeStr.replace('.', '').slice(0, 4);
|
||
});
|
||
setCipCodes(cleanedCips);
|
||
setsocCode(foundObj.soc_code);
|
||
};
|
||
|
||
// Fixed handleSelectSchool (removed extra brace)
|
||
const handleSelectSchool = (school) => {
|
||
const proceed = window.confirm(
|
||
'You’re about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?'
|
||
);
|
||
if (proceed) {
|
||
navigate('/milestone-tracker', { state: { selectedSchool: school } });
|
||
};
|
||
};
|
||
|
||
function getSearchLinks(ksaName, careerTitle) {
|
||
const combinedQuery = `${careerTitle} ${ksaName}`.trim();
|
||
const encoded = encodeURIComponent(combinedQuery);
|
||
|
||
const courseraUrl = `https://www.coursera.org/search?query=${encoded}`;
|
||
const edxUrl = `https://www.edx.org/search?q=${encoded}`;
|
||
|
||
return [
|
||
{ title: 'Coursera', url: courseraUrl },
|
||
{ title: 'edX', url: edxUrl },
|
||
];
|
||
}
|
||
|
||
// Load KSA data once
|
||
useEffect(() => {
|
||
async function loadKsaData() {
|
||
setLoadingKsa(true);
|
||
setKsaError(null);
|
||
try {
|
||
const resp = await fetch('/ksa_data.json');
|
||
if (!resp.ok) {
|
||
throw new Error('Failed to fetch ksa_data.json');
|
||
}
|
||
let data = await resp.json();
|
||
|
||
// skip possible header row
|
||
data = data.filter((item) => item.onetSocCode !== 'O*NET-SOC Code');
|
||
|
||
setAllKsaData(data);
|
||
} catch (err) {
|
||
console.error('Error loading ksa_data.json:', err);
|
||
setKsaError('Could not load KSA data');
|
||
} finally {
|
||
setLoadingKsa(false);
|
||
}
|
||
}
|
||
loadKsaData();
|
||
}, []);
|
||
|
||
// Filter: only IM >=3, then combine IM+LV
|
||
useEffect(() => {
|
||
if (!socCode || !allKsaData.length) {
|
||
setKsaForCareer([]);
|
||
return;
|
||
}
|
||
let filtered = allKsaData.filter((r) => r.onetSocCode === socCode);
|
||
filtered = filtered.filter((r) => r.recommendSuppress !== 'Y');
|
||
filtered = filtered.filter((r) => ['IM', 'LV'].includes(r.scaleID));
|
||
|
||
let combined = combineIMandLV(filtered);
|
||
combined = combined.filter((item) => {
|
||
return item.importanceValue !== null && item.importanceValue >= 3;
|
||
});
|
||
combined.sort((a, b) => (b.importanceValue || 0) - (a.importanceValue || 0));
|
||
|
||
setKsaForCareer(combined);
|
||
}, [socCode, allKsaData]);
|
||
|
||
// Load user profile
|
||
useEffect(() => {
|
||
async function loadUserProfile() {
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
if (!token) {
|
||
console.warn('No token found, cannot load user-profile.');
|
||
return;
|
||
}
|
||
const res = await fetch('/api/user-profile', {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
if (!res.ok) {
|
||
throw new Error('Failed to fetch user profile');
|
||
}
|
||
const data = await res.json();
|
||
setUserZip(data.zipcode || '');
|
||
setUserState(data.state || '');
|
||
} catch (err) {
|
||
console.error('Error loading user profile:', err);
|
||
}
|
||
}
|
||
loadUserProfile();
|
||
}, []);
|
||
|
||
// Fetch schools once CIP codes are set
|
||
useEffect(() => {
|
||
if (!cipCodes.length) return;
|
||
|
||
const fetchData = async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const fetchedSchools = await fetchSchools(cipCodes);
|
||
|
||
let userLat = null;
|
||
let userLng = null;
|
||
if (userZip) {
|
||
try {
|
||
const geoResult = await clientGeocodeZip(userZip);
|
||
userLat = geoResult.lat;
|
||
userLng = geoResult.lng;
|
||
} catch (geoErr) {
|
||
console.warn('Unable to geocode user ZIP:', geoErr.message);
|
||
}
|
||
}
|
||
|
||
const schoolsWithDistance = fetchedSchools.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) };
|
||
}
|
||
return { ...sch, distance: null };
|
||
});
|
||
|
||
setSchools(schoolsWithDistance);
|
||
} catch (err) {
|
||
console.error('[EducationalProgramsPage] error:', err);
|
||
setError('Failed to load schools.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchData();
|
||
}, [cipCodes, userState, userZip]);
|
||
|
||
// Sort schools in useMemo
|
||
const filteredAndSortedSchools = useMemo(() => {
|
||
|
||
if (!schools) return [];
|
||
let result = [...schools];
|
||
|
||
// In-state
|
||
if (inStateOnly && userState) {
|
||
const userAbbr = userState.trim().toUpperCase();
|
||
result = result.filter((sch) => {
|
||
const schoolAbbr = sch.State ? sch.State.trim().toUpperCase() : '';
|
||
return schoolAbbr === userAbbr;
|
||
});
|
||
}
|
||
|
||
// Max tuition
|
||
result = result.filter((sch) => {
|
||
const cost = sch['In_state cost']
|
||
? parseFloat(sch['In_state cost'])
|
||
: 999999;
|
||
return cost <= maxTuition;
|
||
});
|
||
|
||
// Max distance
|
||
result = result.filter((sch) => {
|
||
if (sch.distance === null) return true;
|
||
return parseFloat(sch.distance) <= maxDistance;
|
||
});
|
||
|
||
// Sort
|
||
if (sortBy === 'distance') {
|
||
result.sort((a, b) => {
|
||
const distA = a.distance ? parseFloat(a.distance) : Infinity;
|
||
const distB = b.distance ? parseFloat(b.distance) : Infinity;
|
||
return distA - distB;
|
||
});
|
||
} else {
|
||
// Sort by in-state tuition
|
||
result.sort((a, b) => {
|
||
const tA = a['In_state cost'] ? parseFloat(a['In_state cost']) : Infinity;
|
||
const tB = b['In_state cost'] ? parseFloat(b['In_state cost']) : Infinity;
|
||
return tA - tB;
|
||
});
|
||
}
|
||
|
||
return result;
|
||
}, [schools, inStateOnly, userState, maxTuition, maxDistance, sortBy]);
|
||
|
||
// Render a single KSA row
|
||
function renderKsaRow(k, idx, careerTitle) {
|
||
const elementName = k.elementName;
|
||
const impStars = renderImportance(k.importanceValue);
|
||
const lvlBars = k.levelValue !== null ? renderLevel(k.levelValue) : 'n/a';
|
||
const isAbility = k.ksa_type === 'Ability';
|
||
const links = !isAbility ? getSearchLinks(elementName, careerTitle) : null;
|
||
const definition = ONET_DEFINITIONS[elementName] || 'No definition available';
|
||
|
||
|
||
return (
|
||
<tr key={idx} className="border-b text-sm">
|
||
<td className="p-2 font-medium text-gray-800">
|
||
{elementName}{' '}
|
||
<span
|
||
title={definition}
|
||
className="top-0 left-0 -translate-y-3/4 translate-x-1/8 ml-.5 inline-flex h-2.5 w-2.5 items-center justify-center
|
||
rounded-full bg-blue-500 text-[0.6rem] font-bold text-white cursor-help"
|
||
>
|
||
i
|
||
</span>
|
||
</td>
|
||
<td className="p-2 text-center text-gray-800">{impStars}</td>
|
||
<td className="p-2 text-center text-gray-800">{lvlBars}</td>
|
||
{!isAbility && (
|
||
<td className="p-2">
|
||
{links?.map((link, i) => (
|
||
<div key={i}>
|
||
<a
|
||
href={link.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-blue-600 underline"
|
||
>
|
||
{link.title}
|
||
</a>
|
||
</div>
|
||
))}
|
||
</td>
|
||
)}
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
// Knowledge / Skills / Abilities in 3 columns
|
||
function renderKsaSection() {
|
||
if (loadingKsa) return <p>Loading KSA data...</p>;
|
||
if (ksaError) return <p className="text-red-600">{ksaError}</p>;
|
||
if (!socCode) return <p>Please select a career to see KSA data.</p>;
|
||
if (!ksaForCareer.length) {
|
||
return <p>No Knowledge, Skills, and Abilities data found for {careerTitle}</p>;
|
||
}
|
||
|
||
const knowledge = ksaForCareer.filter((k) => k.ksa_type === 'Knowledge');
|
||
const skillRows = ksaForCareer.filter((k) => k.ksa_type === 'Skill');
|
||
const abilities = ksaForCareer.filter((k) => k.ksa_type === 'Ability');
|
||
|
||
return (
|
||
<div className="mb-6">
|
||
<h2 className="text-xl font-semibold mb-4">
|
||
Knowledge, Skills, and Abilities needed for:{' '}
|
||
{careerTitle || 'Unknown Career'}
|
||
</h2>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
{/* Knowledge */}
|
||
<div>
|
||
<h3 className="font-semibold text-lg mb-2">Knowledge</h3>
|
||
{knowledge.length ? (
|
||
<table className="w-full border text-left">
|
||
<thead className="bg-gray-100 text-xs uppercase text-gray-600">
|
||
<tr>
|
||
<th className="p-2">Knowledge Domain</th>
|
||
<th className="p-2 text-center">Importance</th>
|
||
<th className="p-2 text-center">Level</th>
|
||
<th className="p-2">Courses</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{knowledge.map((k, idx) => renderKsaRow(k, idx, careerTitle))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<p className="text-sm text-gray-600">No knowledge entries.</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Skills */}
|
||
<div>
|
||
<h3 className="font-semibold text-lg mb-2">Skills</h3>
|
||
{skillRows.length ? (
|
||
<table className="w-full border text-left">
|
||
<thead className="bg-gray-100 text-xs uppercase text-gray-600">
|
||
<tr>
|
||
<th className="p-2">Skill</th>
|
||
<th className="p-2 text-center">Importance</th>
|
||
<th className="p-2 text-center">Level</th>
|
||
<th className="p-2">Courses</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{skillRows.map((sk, idx) => renderKsaRow(sk, idx, careerTitle))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<p className="text-sm text-gray-600">No skill entries.</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Abilities */}
|
||
<div>
|
||
<h3 className="font-semibold text-lg mb-2">Abilities</h3>
|
||
{abilities.length ? (
|
||
<table className="w-full border text-left">
|
||
<thead className="bg-gray-100 text-xs uppercase text-gray-600">
|
||
<tr>
|
||
<th className="p-2">Ability</th>
|
||
<th className="p-2 text-center">Importance</th>
|
||
<th className="p-2 text-center">Level</th>
|
||
<th className="p-2 text-center">
|
||
{/* Info icon for "Why no courses?" */}
|
||
<span className="relative inline-block">
|
||
<span>Why no courses?</span>
|
||
<span
|
||
className="top-0 left-0 -translate-y-3/4 translate-x-1/8 ml-.5 inline-flex h-2.5 w-2.5 items-center justify-center
|
||
rounded-full bg-blue-500 text-[10px] font-italics text-white cursor-help"
|
||
title="Abilities are more innate in nature, and difficult to offer courses for them."
|
||
>
|
||
i
|
||
</span>
|
||
</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{abilities.map((ab, idx) => renderKsaRow(ab, idx, careerTitle))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<p className="text-sm text-gray-600">No ability entries.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
} // <-- MAKE SURE WE DO NOT HAVE EXTRA BRACKETS HERE
|
||
|
||
// If no CIP codes => fallback
|
||
if (!cipCodes.length) {
|
||
return (
|
||
<div className="p-4">
|
||
<h2 className="text-2xl font-bold mb-4">Educational Programs</h2>
|
||
<p className="mb-4 text-gray-600">
|
||
You have not selected a career yet. Please search for one below:
|
||
</p>
|
||
<CareerSearch onCareerSelected={handleCareerSelected} />
|
||
<p className="mt-2 text-sm text-gray-500">
|
||
After you pick a career, we’ll display matching educational programs.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (loading) {
|
||
return <div className="p-4">Loading skills and educational programs</div>;
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="p-4 text-red-600">
|
||
<p>Error: {error}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="p-4">
|
||
{/* KSA Section */}
|
||
{renderKsaSection()}
|
||
|
||
{/* School List */}
|
||
<h2 className="text-xl font-semibold mb-4">
|
||
Schools for: {careerTitle || 'Unknown Career'}
|
||
</h2>
|
||
|
||
{/* Filter Bar */}
|
||
<div className="mb-4 flex flex-wrap items-center space-x-4">
|
||
<label className="text-sm text-gray-600">
|
||
Sort:
|
||
<select
|
||
className="ml-2 rounded border px-2 py-1 text-sm"
|
||
value={sortBy}
|
||
onChange={(e) => setSortBy(e.target.value)}
|
||
>
|
||
<option value="tuition">Tuition</option>
|
||
<option value="distance">Distance</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label className="text-sm text-gray-600">
|
||
Tuition (max):
|
||
<input
|
||
type="number"
|
||
className="ml-2 w-20 rounded border px-2 py-1 text-sm"
|
||
value={maxTuition}
|
||
onChange={(e) => setMaxTuition(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
|
||
<label className="text-sm text-gray-600">
|
||
Distance (max):
|
||
<input
|
||
type="number"
|
||
className="ml-2 w-20 rounded border px-2 py-1 text-sm"
|
||
value={maxDistance}
|
||
onChange={(e) => setMaxDistance(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
|
||
{userState && (
|
||
<label className="inline-flex items-center space-x-2 text-sm text-gray-600">
|
||
<input
|
||
type="checkbox"
|
||
checked={inStateOnly}
|
||
onChange={(e) => setInStateOnly(e.target.checked)}
|
||
/>
|
||
<span>In-State Only</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(250px,1fr))]">
|
||
{filteredAndSortedSchools.map((school, idx) => {
|
||
// 1) Ensure the website has a protocol:
|
||
const displayWebsite = ensureHttp(school['Website']);
|
||
|
||
return (
|
||
<div key={idx} className="rounded border p-3 text-sm">
|
||
<strong>
|
||
{school['Website'] ? (
|
||
<a
|
||
href={displayWebsite}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-blue-600 hover:underline"
|
||
>
|
||
{school['INSTNM'] || 'Unnamed School'}
|
||
</a>
|
||
) : (
|
||
school['INSTNM'] || 'Unnamed School'
|
||
)}
|
||
</strong>
|
||
<p>Degree Type: {school['CREDDESC'] || 'N/A'}</p>
|
||
<p>In-State Tuition: ${school['In_state cost'] || 'N/A'}</p>
|
||
<p>Out-of-State Tuition: ${school['Out_state cost'] || 'N/A'}</p>
|
||
<p>Distance: {school.distance !== null ? `${school.distance} mi` : 'N/A'}</p>
|
||
|
||
<button
|
||
onClick={() => handleSelectSchool(school)}
|
||
className="mt-3 rounded bg-green-600 px-3 py-1 text-white hover:bg-blue-700"
|
||
>
|
||
Select School
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default EducationalProgramsPage;
|