dev1/src/components/EducationalProgramsPage.js

810 lines
27 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, useContext } 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';
import ChatCtx from '../contexts/ChatCtx.js';
import api from '../auth/apiClient.js';
import { loadDraft, saveDraft } from '../utils/onboardingDraftApi.js';
// Normalize DB/GPT KSA payloads into IM/LV rows for combineIMandLV
function normalizeKsaPayloadForCombine(payload, socCode) {
if (!payload) return [];
const out = [];
const coerce = (arr = [], ksa_type) => {
arr.forEach((it) => {
const name = it.elementName || it.name || it.title || '';
// If already IM/LV-shaped, just pass through
if (it.scaleID && it.dataValue != null) {
out.push({ ...it, onetSocCode: socCode, ksa_type, elementName: name });
return;
}
// Otherwise split combined values into IM/LV rows if present
const imp = it.importanceValue ?? it.importance ?? it.importanceScore;
const lvl = it.levelValue ?? it.level ?? it.levelScore;
if (imp != null) out.push({ onetSocCode: socCode, elementName: name, ksa_type, scaleID: 'IM', dataValue: imp });
if (lvl != null) out.push({ onetSocCode: socCode, elementName: name, ksa_type, scaleID: 'LV', dataValue: lvl });
});
};
coerce(payload.knowledge, 'Knowledge');
coerce(payload.skills, 'Skill');
coerce(payload.abilities, 'Ability');
return out;
}
// 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://'.
return `https://${urlString}`;
}
function cleanCipDesc(s) {
if (!s) return 'N/A';
return String(s).trim().replace(/\.\s*$/, ''); // strip one trailing period + any spaces
}
// 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 { state } = location;
const navCareer = state?.selectedCareer || {};
const [selectedCareer, setSelectedCareer] = useState(navCareer);
const [socCode, setSocCode] = useState(navCareer.code || '');
const [cipCodes, setCipCodes] = useState(navCareer.cipCodes || []);
const [careerTitle, setCareerTitle] = useState(navCareer.title || '');
const [userState, setUserState]= useState(navCareer.userState || '');
const [userZip, setUserZip] = useState(navCareer.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 [showSearch, setShowSearch] = useState(true);
const { setChatSnapshot } = useContext(ChatCtx);
function normalizeCipPrefix(code) {
if (code === undefined || code === null) return null;
let s = String(code).trim();
if (s.includes('.')) {
// ensure two digits before first dot (e.g. "4.0201" → "04.0201")
s = s.replace(/^(\d)\./, '0$1.');
// drop non-digits (remove dot) → "04.0201" → "040201"
s = s.replace(/\D/g, '');
} else {
// already digits-only ("040201", "0402", "402", 402, etc.)
s = s.replace(/\D/g, '');
}
// force 4-digit prefix (pad if too short, trim if too long)
return s.padStart(4, '0').slice(0, 4);
}
function normalizeCipList(arr) {
const list = Array.isArray(arr) ? arr : [arr];
// unique and non-empty
return [...new Set(list.map(normalizeCipPrefix).filter(Boolean))];
}
// If user picks a new career from CareerSearch
const handleCareerSelected = (foundObj) => {
setCareerTitle(foundObj.title || '');
setSelectedCareer(foundObj);
localStorage.setItem('selectedCareer', JSON.stringify(foundObj));
const cleaned = normalizeCipList(foundObj.cip_code);
setCipCodes(cleaned);
setSocCode(foundObj.soc_code);
setShowSearch(false);
};
function handleChangeCareer() {
// Optionally remove from localStorage if the user is truly 'unselecting' it
localStorage.removeItem('selectedCareer');
setSelectedCareer(null);
setShowSearch(true);
}
// Fixed handleSelectSchool (removed extra brace)
// Replace your existing handleSelectSchool with this:
const handleSelectSchool = async (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) return;
// normalize the currently selected career for handoff (optional)
const sel = selectedCareer
? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode }
: null;
// 1) normalize college fields
const selected_school = school?.INSTNM || '';
const selected_program = (school?.CIPDESC || '');
const program_type = school?.CREDDESC || '';
const unit_id = school?.UNITID || '';
// 2) merge into the cookie-backed draft (dont clobber existing sections)
let draft = null;
try { draft = await loadDraft(); } catch (_) {}
const existing = draft?.data || {};
await saveDraft({
step: 0,
data: {
collegeData: {
selected_school,
selected_program,
program_type,
unit_id,
}
}
});
// 3) navigate (state is optional now that draft persists)
navigate('/career-roadmap', {
state: {
premiumOnboardingState: {
selectedCareer: sel,
selectedSchool: {
INSTNM: school.INSTNM,
CIPDESC: selected_program,
CREDDESC: program_type,
UNITID: unit_id
},
},
},
});
};
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 },
];
}
useEffect(() => {
if (!location.state) return; // nothing passed
const {
socCode : newSoc,
cipCodes : newCips = [],
careerTitle : newTitle = '',
selectedCareer: navCareer // optional convenience payload
} = location.state;
if (newSoc) setSocCode(newSoc);
if (newCips.length) setCipCodes(normalizeCipList(newCips));
if (newTitle) setCareerTitle(newTitle);
if (navCareer) setSelectedCareer(navCareer);
/* if *any* career info arrived we dont need the search box */
if (newSoc || navCareer) setShowSearch(false);
}, [location.state]);
// Filter: only IM >=3, then combine IM+LV
useEffect(() => {
if (!socCode) {
setKsaForCareer([]);
return;
}
// No local blob anymore → ask server3 (local/DB/GPT) for this SOC.
if (!allKsaData.length) {
fetchKsaFallback(socCode, careerTitle);
return;
}
// If you keep allKsaData around for dev, this path still works:
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)
.filter((i) => i.importanceValue != null && i.importanceValue >= 3)
.sort((a, b) => (b.importanceValue || 0) - (a.importanceValue || 0));
if (combined.length === 0) {
fetchKsaFallback(socCode, careerTitle);
} else {
setKsaForCareer(combined);
}
}, [socCode, allKsaData, careerTitle]);
// Load user profile
// Load user profile (cookie-based auth via api client)
useEffect(() => {
async function loadUserProfile() {
try {
const { data } = await api.get('/api/user-profile?fields=zipcode,area');
setUserZip(data.zipcode || '');
setUserState(data.state || '');
} catch (err) {
console.error('Error loading user profile:', err);
}
// Then handle localStorage:
const stored = localStorage.getItem('selectedCareer');
if (stored) {
const parsed = JSON.parse(stored);
setSelectedCareer(parsed);
setCareerTitle(parsed.title || '');
// Re-set CIP code logic (like in handleCareerSelected)
setCipCodes(normalizeCipList(parsed.cip_code));
setSocCode(parsed.soc_code);
setShowSearch(false);
}
}
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]);
const TOP_N = 8; // ← tweak here
const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
name : s.INSTNM,
inState : Number(s['In_state cost'] || 0),
outState : Number(s['Out_state cost'] || 0),
distance : s.distance ? Number(s.distance) : null,
degree : s.CREDDESC,
website : s.Website
}));
const snapshot = useMemo(() => ({
careerCtx : socCode ? { socCode, careerTitle, cipCodes } : null,
ksaCtx : ksaForCareer.length ? {
total : ksaForCareer.length,
topKnow : ksaForCareer.filter(k => k.ksa_type === 'Knowledge')
.slice(0,3).map(k => k.elementName),
topSkill : ksaForCareer.filter(k => k.ksa_type === 'Skill')
.slice(0,3).map(k => k.elementName)
} : null,
filterCtx : { sortBy, maxTuition, maxDistance, inStateOnly },
schoolCtx : { count : filteredAndSortedSchools.length, sample : topSchools }
}), [
socCode, careerTitle, cipCodes,
ksaForCareer, sortBy, maxTuition,
maxDistance, inStateOnly,
filteredAndSortedSchools
]);
useEffect(() => { setChatSnapshot(snapshot); },
[snapshot, setChatSnapshot]);
// 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>
);
}
// 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>
);
}
// No local KSA records for this SOC => ask server3 to resolve (local/DB/GPT)
async function fetchKsaFallback(socCode, careerTitle) {
setLoadingKsa(true);
setKsaError(null);
try {
// Ask server3. It will:
// 1) Serve local ksa_data.json if present for this SOC
// 2) Otherwise return DB ai_generated_ksa (IM/LV rows)
// 3) Otherwise call GPT, normalize to IM/LV, store in DB, and return it
const resp = await api.get(`/api/premium/ksa/${socCode}`, {
params: { careerTitle: careerTitle || '' }
});
// server3 returns either:
// { source: 'local', data: [IM/LV rows...] }
// or
// { source: 'db'|'chatgpt', data: { knowledge:[], skills:[], abilities:[] } }
const payload = resp?.data?.data ?? resp?.data;
let rows;
if (Array.isArray(payload)) {
// Already IM/LV rows
rows = payload;
} else {
// Object with knowledge/skills/abilities
rows = normalizeKsaPayloadForCombine(payload, socCode);
}
const combined = combineIMandLV(rows)
.filter(i => i.importanceValue != null && i.importanceValue >= 3)
.sort((a, b) => (b.importanceValue || 0) - (a.importanceValue || 0));
setKsaForCareer(combined);
} catch (err) {
console.error('Error fetching KSAs:', err);
setKsaError('Could not load KSAs. Please try again later.');
setKsaForCareer([]);
} finally {
setLoadingKsa(false);
}
}
return (
<div className="p-4">
{/* 1. If user is allowed to search for a career again, show CareerSearch */}
{showSearch && (
<div className="mb-4">
<h2 className="text-xl font-semibold mb-2">Find a Career</h2>
<CareerSearch onCareerSelected={handleCareerSelected} />
</div>
)}
{/* 2. If the user has already selected a career and we're not showing search */}
{selectedCareer && !showSearch && (
<div className="mb-4">
<p className="text-gray-700">
<strong>Currently selected:</strong> {selectedCareer.title}
</p>
<button
onClick={handleChangeCareer}
className="mt-2 rounded bg-green-400 px-3 py-1 hover:bg-green-300"
>
Change Career
</button>
</div>
)}
{/* If were loading or errored out, handle that up front */}
{loading && (
<div>Loading skills and educational programs...</div>
)}
{error && (
<div className="text-red-600">
Error: {error}
</div>
)}
{/* 3. Display CIP-based data only if we have CIP codes (means we have a known career) */}
{cipCodes.length > 0 ? (
<>
{/* 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>
{/* Display the sorted/filtered schools */}
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(250px,1fr))]">
{filteredAndSortedSchools.map((school, idx) => {
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>Program: {cleanCipDesc(school['CIPDESC'])}</p>
<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>
</>
) : (
/* 4. If no CIP codes, user hasn't picked a valid career or there's no data */
<div className="mt-4">
<p className="text-gray-600">
You have not selected a career (or no CIP codes found) so no programs can be shown.
</p>
</div>
)}
</div>
);
}
export default EducationalProgramsPage;