809 lines
27 KiB
JavaScript
809 lines
27 KiB
JavaScript
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 (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 { 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(
|
||
'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) 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 (don’t 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 don’t 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">
|
||
<span className="inline-flex items-center gap-1 align-middle">
|
||
<span className="break-words">{elementName}</span>
|
||
<span
|
||
title={definition}
|
||
className="shrink-0 inline-flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-bold text-white cursor-help leading-none"
|
||
>
|
||
i
|
||
</span>
|
||
</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">
|
||
<div className="w-full flex items-center justify-center gap-1">
|
||
<span className="whitespace-nowrap">Why no courses?</span>
|
||
<span
|
||
className="shrink-0 inline-flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-[10px] italic text-white cursor-help leading-none"
|
||
title="Abilities are more innate in nature, and difficult to offer courses for them."
|
||
>
|
||
i
|
||
</span>
|
||
</div>
|
||
</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, 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>
|
||
);
|
||
}
|
||
|
||
// 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 we’re 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;
|