Fetch schools in EducationalProgramsPage.js
This commit is contained in:
parent
146061a9b9
commit
77cd3b6845
@ -391,13 +391,21 @@ app.get('/api/cip/:socCode', (req, res) => {
|
||||
* Single schools / tuition / etc. routes
|
||||
**************************************************/
|
||||
app.get('/api/schools', (req, res) => {
|
||||
const { cipCode, state } = req.query;
|
||||
console.log('Query Params:', { cipCode });
|
||||
if (!cipCode || !state) {
|
||||
return res.status(400).json({ error: 'CIP Code is required' });
|
||||
// 1) Read `cipCodes` from query (comma-separated string)
|
||||
const { cipCodes } = req.query;
|
||||
|
||||
if (!cipCodes ) {
|
||||
return res.status(400).json({ error: 'cipCodes (comma-separated) and state are required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const matchedCIP = cipCode.replace('.', '').slice(0, 4);
|
||||
// 2) Convert `cipCodes` to array => e.g. "1101,1103,1104" => ["1101","1103","1104"]
|
||||
const cipArray = cipCodes.split(',').map((c) => c.trim()).filter(Boolean);
|
||||
if (cipArray.length === 0) {
|
||||
return res.status(400).json({ error: 'No valid CIP codes were provided.' });
|
||||
}
|
||||
|
||||
// 3) Load your raw schools data
|
||||
let schoolsData = [];
|
||||
try {
|
||||
const rawData = fs.readFileSync(institutionFilePath, 'utf8');
|
||||
@ -406,41 +414,82 @@ app.get('/api/schools', (req, res) => {
|
||||
console.error('Error parsing institution data:', err.message);
|
||||
return res.status(500).json({ error: 'Failed to load schools data.' });
|
||||
}
|
||||
|
||||
// 4) Filter any school whose CIP code matches ANY of the CIP codes in the array
|
||||
// Convert the school's CIP code the same way you do in your old logic (remove dot, slice, etc.)
|
||||
const filtered = schoolsData.filter((s) => {
|
||||
const scip = s['CIPCODE']?.toString().replace('.', '').slice(0, 4);
|
||||
return scip.startsWith(matchedCIP);
|
||||
return cipArray.some((cip) => scip.startsWith(cip));
|
||||
});
|
||||
console.log('Filtered schools:', filtered.length);
|
||||
res.json(filtered);
|
||||
|
||||
// 5) (Optional) Deduplicate if you suspect overlaps among CIP codes.
|
||||
// E.g. by a “UNITID” or unique property:
|
||||
const uniqueMap = new Map();
|
||||
for (const school of filtered) {
|
||||
const key = school.UNITID || school.INSTNM; // pick your unique field
|
||||
if (!uniqueMap.has(key)) {
|
||||
uniqueMap.set(key, school);
|
||||
}
|
||||
}
|
||||
const deduped = Array.from(uniqueMap.values());
|
||||
|
||||
console.log('Unique schools found:', deduped.length);
|
||||
res.json(deduped);
|
||||
} catch (err) {
|
||||
console.error('Error reading Institution data:', err.message);
|
||||
res.status(500).json({ error: 'Failed to load schools data.' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// tuition
|
||||
app.get('/api/tuition', (req, res) => {
|
||||
const { cipCode, state } = req.query;
|
||||
console.log(`Received CIP: ${cipCode}, State: ${state}`);
|
||||
if (!cipCode || !state) {
|
||||
return res.status(400).json({ error: 'CIP Code and State are required.' });
|
||||
const { cipCodes, state } = req.query;
|
||||
if (!cipCodes || !state) {
|
||||
return res.status(400).json({ error: 'cipCodes and state are required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(institutionFilePath, 'utf8');
|
||||
const schoolsData = JSON.parse(raw);
|
||||
|
||||
const cipArray = cipCodes.split(',').map((c) => c.trim()).filter(Boolean);
|
||||
if (!cipArray.length) {
|
||||
return res.status(400).json({ error: 'No valid CIP codes.' });
|
||||
}
|
||||
|
||||
// Filter logic
|
||||
const filtered = schoolsData.filter((school) => {
|
||||
const cval = school['CIPCODE']?.toString().replace(/[^0-9]/g, '');
|
||||
const cval = school['CIPCODE']?.toString().replace(/\./g, '').slice(0, 4);
|
||||
const sVal = school['State']?.toUpperCase().trim();
|
||||
return cval.startsWith(cipCode) && sVal === state.toUpperCase().trim();
|
||||
|
||||
// Check if cval starts with ANY CIP in cipArray
|
||||
const matchesCip = cipArray.some((cip) => cval.startsWith(cip));
|
||||
const matchesState = sVal === state.toUpperCase().trim();
|
||||
|
||||
return matchesCip && matchesState;
|
||||
});
|
||||
console.log('Filtered Tuition Data Count:', filtered.length);
|
||||
res.json(filtered);
|
||||
|
||||
// Optionally deduplicate by UNITID
|
||||
const uniqueMap = new Map();
|
||||
for (const school of filtered) {
|
||||
const key = school.UNITID || school.INSTNM; // or something else unique
|
||||
if (!uniqueMap.has(key)) {
|
||||
uniqueMap.set(key, school);
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = Array.from(uniqueMap.values());
|
||||
console.log('Filtered Tuition Data Count:', deduped.length);
|
||||
|
||||
res.json(deduped);
|
||||
} catch (err) {
|
||||
console.error('Error reading tuition data:', err.message);
|
||||
res.status(500).json({ error: 'Failed to load tuition data.' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**************************************************
|
||||
* SINGLE route for projections from economicproj.json
|
||||
**************************************************/
|
||||
|
@ -8,42 +8,43 @@ const CareerSearch = ({ onCareerSelected }) => {
|
||||
useEffect(() => {
|
||||
const fetchCareerData = async () => {
|
||||
try {
|
||||
const response = await fetch('/career_clusters.json');
|
||||
const response = await fetch('/careers_with_ratings.json');
|
||||
const data = await response.json();
|
||||
|
||||
// Create a Map keyed by title, storing one object per unique title
|
||||
// Create a Map keyed by career title, so we only keep one object per unique title
|
||||
const uniqueByTitle = new Map();
|
||||
|
||||
const clusters = Object.keys(data);
|
||||
for (let i = 0; i < clusters.length; i++) {
|
||||
const clusterKey = clusters[i];
|
||||
const subdivisions = Object.keys(data[clusterKey]);
|
||||
|
||||
for (let j = 0; j < subdivisions.length; j++) {
|
||||
const subKey = subdivisions[j];
|
||||
const careersList = data[clusterKey][subKey] || [];
|
||||
|
||||
for (let k = 0; k < careersList.length; k++) {
|
||||
const c = careersList[k];
|
||||
if (c.title && c.soc_code && c.cip_code !== undefined) {
|
||||
// data is presumably an array like:
|
||||
// [
|
||||
// { soc_code: "15-1241.00", title: "Computer Network Architects", cip_codes: [...], ... },
|
||||
// { soc_code: "15-1299.07", title: "Blockchain Engineers", cip_codes: [...], ... },
|
||||
// ...
|
||||
// ]
|
||||
for (const c of data) {
|
||||
// Make sure we have a valid title, soc_code, and cip_codes
|
||||
if (c.title && c.soc_code && c.cip_codes) {
|
||||
// Only store the first unique title found
|
||||
if (!uniqueByTitle.has(c.title)) {
|
||||
uniqueByTitle.set(c.title, {
|
||||
title: c.title,
|
||||
soc_code: c.soc_code,
|
||||
cip_code: c.cip_code
|
||||
// NOTE: We store the array of CIPs in `cip_code`.
|
||||
cip_code: c.cip_codes,
|
||||
limited_data: c.limited_data,
|
||||
ratings: c.ratings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the map into an array
|
||||
const dedupedArr = [...uniqueByTitle.values()];
|
||||
setCareerObjects(dedupedArr);
|
||||
} catch (error) {
|
||||
console.error('Error loading or parsing career_clusters.json:', error);
|
||||
console.error('Error loading or parsing careers_with_ratings.json:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCareerData();
|
||||
}, []);
|
||||
|
||||
|
@ -1,75 +1,93 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// Your existing search component
|
||||
import CareerSearch from './CareerSearch.js';
|
||||
|
||||
// The existing utility calls
|
||||
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js';
|
||||
|
||||
// A simple Button component (if you don’t already import from elsewhere)
|
||||
function Button({ onClick, children, ...props }) {
|
||||
return (
|
||||
<button
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-500"
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EducationalProgramsPage
|
||||
* - If we have a CIP code (from location.state or otherwise), we fetch + display schools.
|
||||
* - If no CIP code is provided, user sees a CareerSearch to pick a career => sets CIP code.
|
||||
* - Then the user can filter & sort (tuition, distance, optional in-state only).
|
||||
*/
|
||||
function EducationalProgramsPage() {
|
||||
// 1) Get CIP code from React Router location.state (if available)
|
||||
// If no CIP code in route state, default to an empty string
|
||||
// 1) Read an array of CIP codes from route state (if provided),
|
||||
// or default to an empty array.
|
||||
const location = useLocation();
|
||||
const [cipCode, setCipCode] = useState(location.state?.cipCode || '');
|
||||
const [cipCodes, setCipCodes] = useState(location.state?.cipCodes || []);
|
||||
|
||||
// Optionally, you can also read userState / userZip from location.state or from user’s profile
|
||||
// userState / userZip from route state or user profile
|
||||
const [userState, setUserState] = useState(location.state?.userState || '');
|
||||
const [userZip, setUserZip] = useState(location.state?.userZip || '');
|
||||
|
||||
// ============ Data + UI state ============
|
||||
// For UI
|
||||
const [schools, setSchools] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Filter states
|
||||
const [sortBy, setSortBy] = useState('tuition'); // 'tuition' or 'distance'
|
||||
const [maxTuition, setMaxTuition] = useState(99999);
|
||||
const [maxDistance, setMaxDistance] = useState(99999);
|
||||
// Optional “in-state only” toggle
|
||||
// Filters
|
||||
const [sortBy, setSortBy] = useState('tuition'); // or 'distance'
|
||||
const [maxTuition, setMaxTuition] = useState(20000);
|
||||
const [maxDistance, setMaxDistance] = useState(100);
|
||||
const [inStateOnly, setInStateOnly] = useState(false);
|
||||
const [careerTitle, setCareerTitle] = useState(location.state?.careerTitle || '');
|
||||
|
||||
// ============ Handle Career Search -> CIP code ============
|
||||
// ============== If user picks a career from CareerSearch ==============
|
||||
const handleCareerSelected = (foundObj) => {
|
||||
// foundObj = { title, soc_code, cip_code } from CareerSearch
|
||||
if (foundObj?.cip_code) {
|
||||
setCipCode(foundObj.cip_code);
|
||||
setCareerTitle(foundObj.title || '');
|
||||
let rawCips = [];
|
||||
if (Array.isArray(foundObj.cip_code)) {
|
||||
// e.g. [11.0101, 11.0301, 11.0802, ...]
|
||||
rawCips = foundObj.cip_code;
|
||||
} else {
|
||||
// single CIP code scenario
|
||||
rawCips = [foundObj.cip_code];
|
||||
}
|
||||
|
||||
// Clean each CIP code (remove the dot, slice to 4 digits)
|
||||
// e.g. "11.0101" => "1101"
|
||||
const cleanedCips = rawCips.map((code) => {
|
||||
const codeStr = code.toString();
|
||||
return codeStr.replace('.', '').slice(0,4);
|
||||
});
|
||||
|
||||
// Now store them in state
|
||||
setCipCodes(cleanedCips);
|
||||
};
|
||||
|
||||
// ============ Fetch + Compute Distance once we have a CIP code ============
|
||||
// pseudo-code snippet
|
||||
useEffect(() => {
|
||||
// If no CIP code is set yet, do nothing.
|
||||
if (!cipCode) return;
|
||||
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();
|
||||
// data.zipcode => "30102"
|
||||
setUserZip(data.zipcode || '');
|
||||
setUserState(data.state || '');
|
||||
} catch (err) {
|
||||
console.error('Error loading user profile:', err);
|
||||
}
|
||||
}
|
||||
loadUserProfile();
|
||||
}, []);
|
||||
|
||||
// ============== Fetch schools once we have CIP codes ==============
|
||||
useEffect(() => {
|
||||
if (!cipCodes.length) return; // no CIP codes => show CareerSearch fallback
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1) Fetch schools by CIP code (and userState if your API still uses it)
|
||||
const fetchedSchools = await fetchSchools(cipCode, userState);
|
||||
// 1) Call fetchSchools with an array of CIP codes
|
||||
// so we can do the "comma-separated" request in the utility.
|
||||
const fetchedSchools = await fetchSchools(cipCodes);
|
||||
|
||||
// 2) Optionally geocode user ZIP to compute distances
|
||||
// 2) Optionally geocode user ZIP for distance
|
||||
let userLat = null;
|
||||
let userLng = null;
|
||||
if (userZip) {
|
||||
@ -82,7 +100,7 @@ function EducationalProgramsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Compute distance for each school (if lat/lng is available)
|
||||
// 3) Compute distance
|
||||
const schoolsWithDistance = fetchedSchools.map((sch) => {
|
||||
const lat2 = sch.LATITUDE ? parseFloat(sch.LATITUDE) : null;
|
||||
const lon2 = sch.LONGITUD ? parseFloat(sch.LONGITUD) : null;
|
||||
@ -90,14 +108,13 @@ function EducationalProgramsPage() {
|
||||
if (userLat && userLng && lat2 && lon2) {
|
||||
const distMiles = haversineDistance(userLat, userLng, lat2, lon2);
|
||||
return { ...sch, distance: distMiles.toFixed(1) };
|
||||
} else {
|
||||
return { ...sch, distance: null };
|
||||
}
|
||||
return { ...sch, distance: null };
|
||||
});
|
||||
|
||||
setSchools(schoolsWithDistance);
|
||||
} catch (err) {
|
||||
console.error('Error fetching/processing schools:', err);
|
||||
console.error('[EducationalProgramsPage] error:', err);
|
||||
setError('Failed to load schools.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -105,59 +122,63 @@ function EducationalProgramsPage() {
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [cipCode, userState, userZip]);
|
||||
}, [cipCodes, userState, userZip]);
|
||||
|
||||
// ============ Filter + Sort ============
|
||||
// ============== Filter & Sort in useMemo ==============
|
||||
const filteredAndSortedSchools = useMemo(() => {
|
||||
if (!schools) return [];
|
||||
let result = [...schools];
|
||||
|
||||
// 1) (Optional) In-state only
|
||||
// 1) In-state
|
||||
if (inStateOnly && userState) {
|
||||
result = result.filter((sch) => sch.STABBR === userState);
|
||||
const userAbbr = userState.trim().toUpperCase();
|
||||
result = result.filter((sch) => {
|
||||
const schoolAbbr = sch.State ? sch.State.trim().toUpperCase() : '';
|
||||
return schoolAbbr === userAbbr;
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Filter by max tuition
|
||||
// We’ll use “In_state cost” if your data references that, or you can adapt.
|
||||
|
||||
// 2) Max tuition
|
||||
result = result.filter((sch) => {
|
||||
const inStateCost = sch['In_state cost']
|
||||
const cost = sch['In_state cost']
|
||||
? parseFloat(sch['In_state cost'])
|
||||
: 999999;
|
||||
return inStateCost <= maxTuition;
|
||||
return cost <= maxTuition;
|
||||
});
|
||||
|
||||
// 3) Filter by max distance
|
||||
// 3) Max distance
|
||||
result = result.filter((sch) => {
|
||||
if (sch.distance === null) {
|
||||
// If distance is unknown, decide if you want to include or exclude it
|
||||
return true; // let’s include unknown
|
||||
}
|
||||
if (sch.distance === null) return true; // keep unknown
|
||||
return parseFloat(sch.distance) <= maxDistance;
|
||||
});
|
||||
|
||||
// 4) Sort
|
||||
if (sortBy === 'distance') {
|
||||
result.sort((a, b) => {
|
||||
const distA = a.distance !== null ? parseFloat(a.distance) : Infinity;
|
||||
const distB = b.distance !== null ? parseFloat(b.distance) : Infinity;
|
||||
const distA = a.distance ? parseFloat(a.distance) : Infinity;
|
||||
const distB = b.distance ? parseFloat(b.distance) : Infinity;
|
||||
return distA - distB;
|
||||
});
|
||||
} else {
|
||||
// sort by tuition
|
||||
// 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;
|
||||
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, sortBy, maxTuition, maxDistance, inStateOnly, userState]);
|
||||
}, [schools, inStateOnly, userState, maxTuition, maxDistance, sortBy]);
|
||||
|
||||
// ============ Render UI ============
|
||||
|
||||
// 1) If we have NO CIP code yet, show the fallback “CareerSearch”
|
||||
if (!cipCode) {
|
||||
// ============== Render ==============
|
||||
// Show the fallback (CareerSearch) if we have no CIP codes
|
||||
if (!cipCodes.length) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-2xl font-bold mb-4">Educational Programs</h2>
|
||||
@ -172,7 +193,7 @@ function EducationalProgramsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// 2) If we DO have a CIP code, show the filterable school list
|
||||
// If CIP codes exist but we’re loading
|
||||
if (loading) {
|
||||
return <div className="p-4">Loading schools...</div>;
|
||||
}
|
||||
@ -187,9 +208,9 @@ function EducationalProgramsPage() {
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-2xl font-bold mb-4">
|
||||
Schools Offering Programs for CIP: {cipCode}
|
||||
</h2>
|
||||
<h2>Schools for: {careerTitle || 'Unknown Career'}</h2>
|
||||
|
||||
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="mb-4 flex flex-wrap items-center space-x-4">
|
||||
@ -228,7 +249,6 @@ function EducationalProgramsPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Optional: In-State Only Toggle */}
|
||||
{userState && (
|
||||
<label className="inline-flex items-center space-x-2 text-sm text-gray-600">
|
||||
<input
|
||||
|
@ -47,19 +47,26 @@ export function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
return R * c;
|
||||
}
|
||||
|
||||
// Fetch schools
|
||||
export const fetchSchools = async (cipCode) => {
|
||||
export async function fetchSchools(cipCodes) {
|
||||
try {
|
||||
const apiUrl = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
// 1) If `cipCodes` is a single string => wrap in array
|
||||
let codesArray = Array.isArray(cipCodes) ? cipCodes : [cipCodes];
|
||||
|
||||
// 2) Turn that array into a comma-separated string
|
||||
// e.g. ["1101","1409"] => "1101,1409"
|
||||
const cipParam = codesArray.join(',');
|
||||
|
||||
// 3) Call your endpoint with `?cipCodes=1101,1409&state=NY`
|
||||
const response = await axios.get(`${apiUrl}/schools`, {
|
||||
params: {
|
||||
cipCode
|
||||
cipCodes: cipParam,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data; // Return filtered data
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching schools:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user