dev1/src/components/EducationalProgramsPage.js
2025-05-22 17:18:57 +00:00

557 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { 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 (15) 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 (07) 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(
'Youre 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, well 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;