Put all frontend data calls to backend queries
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
4c5fdea80c
commit
fe8102385e
@ -1 +1 @@
|
|||||||
277110e50c0c8ee5c02fee3c1174a225d5593511-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b
|
3eefb2cd6c785e5815d042d108f67a87c6819a4d-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||||
|
@ -1 +1 @@
|
|||||||
8eca4afbc834297a74d0c140a17e370c19102dea
|
1a7fe9191922c4f8389027ed53b6a4909740a48b
|
||||||
|
@ -1 +1 @@
|
|||||||
8eca4afbc834297a74d0c140a17e370c19102dea
|
1a7fe9191922c4f8389027ed53b6a4909740a48b
|
||||||
|
@ -24,6 +24,9 @@ import sgMail from '@sendgrid/mail'; // npm i @sendgrid/mail
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import Fuse from 'fuse.js';
|
||||||
|
import { parse as csvParse } from 'csv-parse/sync';
|
||||||
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@ -55,6 +58,70 @@ const chatLimiter = rateLimit({
|
|||||||
keyGenerator: req => req.user?.id || req.ip
|
keyGenerator: req => req.user?.id || req.ip
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ── helpers ─────────────────────────────────────────────────────────
|
||||||
|
const normTitle = (s='') =>
|
||||||
|
String(s)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s*&\s*/g, ' and ')
|
||||||
|
.replace(/[–—]/g, '-') // en/em dash → hyphen
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const stripSoc = (s='') => String(s).split('.')[0]; // "15-1252.00" → "15-1252"
|
||||||
|
|
||||||
|
|
||||||
|
// 1) careers_with_ratings.json
|
||||||
|
let CAREERS = [];
|
||||||
|
let careersFuse = null;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(path.join(DATA_DIR, 'careers_with_ratings.json'), 'utf8');
|
||||||
|
CAREERS = JSON.parse(raw);
|
||||||
|
careersFuse = new Fuse(CAREERS, {
|
||||||
|
keys: ['title'],
|
||||||
|
threshold: 0.3,
|
||||||
|
ignoreLocation: true,
|
||||||
|
});
|
||||||
|
console.log(`[data] careers_with_ratings loaded: ${CAREERS.length}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[data] careers_with_ratings load failed:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const norm = (s='') =>
|
||||||
|
String(s).toLowerCase().replace(/\s*&\s*/g,' and ').replace(/[–—]/g,'-').replace(/\s+/g,' ').trim();
|
||||||
|
|
||||||
|
|
||||||
|
// 2) CIP institution mapping (line-delimited JSON or array)
|
||||||
|
let CIPMAP = [];
|
||||||
|
try {
|
||||||
|
const cand = ['cip_institution_mapping_new.json','cip_institution_mapping_fixed.json','cip_institution_mapping.json']
|
||||||
|
.map(f => path.join(DATA_DIR, f))
|
||||||
|
.find(f => fs.existsSync(f));
|
||||||
|
if (cand) {
|
||||||
|
const text = fs.readFileSync(cand, 'utf8').trim();
|
||||||
|
if (text.startsWith('[')) {
|
||||||
|
CIPMAP = JSON.parse(text);
|
||||||
|
} else {
|
||||||
|
CIPMAP = text.split('\n').map(l => { try { return JSON.parse(l); } catch { return null; } })
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
console.log(`[data] CIP map loaded: ${CIPMAP.length} rows`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[data] CIP map load failed:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) IPEDS ic2023_ay.csv -> parse once
|
||||||
|
let IPEDS = [];
|
||||||
|
try {
|
||||||
|
const csv = fs.readFileSync(path.join(DATA_DIR, 'ic2023_ay.csv'), 'utf8');
|
||||||
|
IPEDS = csvParse(csv, { columns: true, skip_empty_lines: true });
|
||||||
|
console.log(`[data] IPEDS ic2023_ay loaded: ${IPEDS.length} rows`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[data] IPEDS load failed:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Load institution data (kept for existing routes)
|
// Load institution data (kept for existing routes)
|
||||||
const institutionData = JSON.parse(fs.readFileSync(INSTITUTION_DATA_PATH, 'utf8'));
|
const institutionData = JSON.parse(fs.readFileSync(INSTITUTION_DATA_PATH, 'utf8'));
|
||||||
|
|
||||||
@ -1422,6 +1489,185 @@ app.post('/api/chat/threads/:id/stream', authenticateUser, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// GET /api/careers/search?query=<text>&limit=15
|
||||||
|
app.get('/api/careers/search', (req, res) => {
|
||||||
|
const q = String(req.query.query || '').trim();
|
||||||
|
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 15));
|
||||||
|
if (!q || !careersFuse) return res.json([]);
|
||||||
|
|
||||||
|
// exact match first (auto-commit parity)
|
||||||
|
const nq = normTitle(q);
|
||||||
|
const exact = CAREERS.find(c => normTitle(c.title) === nq);
|
||||||
|
|
||||||
|
// fuse results for partials
|
||||||
|
const hits = careersFuse.search(q, { limit: Math.max(limit, 25) }).map(h => h.item);
|
||||||
|
|
||||||
|
// de-dupe by normalized title; put exact first if present
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
if (exact) {
|
||||||
|
seen.add(normTitle(exact.title));
|
||||||
|
out.push(exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of hits) {
|
||||||
|
const key = normTitle(c.title);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
out.push(c);
|
||||||
|
seen.add(key);
|
||||||
|
if (out.length >= limit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// return minimal shape + SOC stripped; include cip_code mirror for legacy callers
|
||||||
|
res.json(out.map(c => ({
|
||||||
|
title : c.title,
|
||||||
|
soc_code : c.soc_code,
|
||||||
|
cip_codes : c.cip_codes,
|
||||||
|
cip_code : c.cip_codes, // ← backward-compat (some callers expect singular)
|
||||||
|
limited_data : c.limited_data,
|
||||||
|
ratings : c.ratings,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/careers/resolve?title=<exact>
|
||||||
|
app.get('/api/careers/resolve', (req, res) => {
|
||||||
|
const t = String(req.query.title || '').trim();
|
||||||
|
if (!t) return res.status(400).json({ error: 'title required' });
|
||||||
|
|
||||||
|
const m = CAREERS.find(c => normTitle(c.title) === normTitle(t));
|
||||||
|
if (!m) return res.status(404).json({ error: 'not_found' });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
title : m.title,
|
||||||
|
soc_code : m.soc_code,
|
||||||
|
cip_codes : m.cip_codes,
|
||||||
|
cip_code : m.cip_codes, // ← mirror for legacy
|
||||||
|
limited_data : m.limited_data,
|
||||||
|
ratings : m.ratings,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/careers/by-soc', (req, res) => {
|
||||||
|
const raw = String(req.query.soc || '').trim();
|
||||||
|
if (!raw) return res.status(400).json({ error: 'soc required' });
|
||||||
|
|
||||||
|
const base = raw; // tolerate .00
|
||||||
|
const match =
|
||||||
|
CAREERS.find(c => c.soc_code === base) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (!match) return res.status(404).json({ error: 'not_found' });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
soc_code : match.soc_code,
|
||||||
|
title : match.title,
|
||||||
|
cip_codes : match.cip_codes || [],
|
||||||
|
ratings : match.ratings || {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/schools/suggest?query=<text>&limit=10
|
||||||
|
app.get('/api/schools/suggest', (req, res) => {
|
||||||
|
const q = String(req.query.query || '').trim().toLowerCase();
|
||||||
|
const limit = Math.min(20, Math.max(1, Number(req.query.limit) || 10));
|
||||||
|
if (!q) return res.json([]);
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const r of CIPMAP) {
|
||||||
|
const name = (r.INSTNM || '').trim();
|
||||||
|
if (!name) continue;
|
||||||
|
if (name.toLowerCase().includes(q) && !seen.has(name)) {
|
||||||
|
seen.add(name);
|
||||||
|
out.push({ name, unitId: r.UNITID || null });
|
||||||
|
if (out.length >= limit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json(out);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/programs/suggest?school=<name>&query=<text>&limit=10
|
||||||
|
app.get('/api/programs/suggest', (req, res) => {
|
||||||
|
const school = String(req.query.school || '').trim().toLowerCase();
|
||||||
|
const q = String(req.query.query || '').trim().toLowerCase();
|
||||||
|
const limit = Math.min(30, Math.max(1, Number(req.query.limit) || 10));
|
||||||
|
if (!school || !q) return res.json([]);
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const r of CIPMAP) {
|
||||||
|
const sname = (r.INSTNM || '').trim().toLowerCase();
|
||||||
|
const prog = (r.CIPDESC || '').trim();
|
||||||
|
if (!prog || sname !== school) continue;
|
||||||
|
if (prog.toLowerCase().includes(q) && !seen.has(prog)) {
|
||||||
|
seen.add(prog);
|
||||||
|
out.push({ program: prog });
|
||||||
|
if (out.length >= limit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json(out);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/programs/types?school=<name>&program=<exact>
|
||||||
|
app.get('/api/programs/types', (req, res) => {
|
||||||
|
const school = String(req.query.school || '').trim().toLowerCase();
|
||||||
|
const program = String(req.query.program || '').trim();
|
||||||
|
if (!school || !program) return res.status(400).json({ error: 'school and program required' });
|
||||||
|
const types = new Set(
|
||||||
|
CIPMAP
|
||||||
|
.filter(r =>
|
||||||
|
(r.INSTNM || '').trim().toLowerCase() === school &&
|
||||||
|
(r.CIPDESC || '').trim() === program
|
||||||
|
)
|
||||||
|
.map(r => r.CREDDESC)
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
res.json({ types: [...types] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tuition/estimate?unitId=...&programType=...&inState=0|1&inDistrict=0|1&creditHoursPerYear=NN
|
||||||
|
app.get('/api/tuition/estimate', (req, res) => {
|
||||||
|
const unitId = String(req.query.unitId || '').trim();
|
||||||
|
const programType = String(req.query.programType || '').trim();
|
||||||
|
const inState = Number(req.query.inState || 0) ? 1 : 0;
|
||||||
|
const inDistrict = Number(req.query.inDistrict || 0) ? 1 : 0;
|
||||||
|
const chpy = Math.max(0, Number(req.query.creditHoursPerYear || 0));
|
||||||
|
|
||||||
|
if (!unitId) return res.status(400).json({ error: 'unitId required' });
|
||||||
|
|
||||||
|
const row = IPEDS.find(r => String(r.UNITID) === unitId);
|
||||||
|
if (!row) return res.status(404).json({ error: 'unitId not found' });
|
||||||
|
|
||||||
|
const grad = [
|
||||||
|
"Master's Degree", "Doctoral Degree", "Graduate/Professional Certificate", "First Professional Degree"
|
||||||
|
].includes(programType);
|
||||||
|
|
||||||
|
let part, full;
|
||||||
|
if (grad) {
|
||||||
|
if (inDistrict) { part = Number(row.HRCHG5 || 0); full = Number(row.TUITION5 || 0); }
|
||||||
|
else if (inState) { part = Number(row.HRCHG6 || 0); full = Number(row.TUITION6 || 0); }
|
||||||
|
else { part = Number(row.HRCHG7 || 0); full = Number(row.TUITION7 || 0); }
|
||||||
|
} else {
|
||||||
|
if (inDistrict) { part = Number(row.HRCHG1 || 0); full = Number(row.TUITION1 || 0); }
|
||||||
|
else if (inState) { part = Number(row.HRCHG2 || 0); full = Number(row.TUITION2 || 0); }
|
||||||
|
else { part = Number(row.HRCHG3 || 0); full = Number(row.TUITION3 || 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
let estimate = full;
|
||||||
|
if (chpy && chpy < 24 && part) estimate = part * chpy;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
unitId,
|
||||||
|
programType,
|
||||||
|
inState: !!inState,
|
||||||
|
inDistrict: !!inDistrict,
|
||||||
|
creditHoursPerYear: chpy,
|
||||||
|
estimate: Math.round(estimate)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**************************************************
|
/**************************************************
|
||||||
* Start the Express server
|
* Start the Express server
|
||||||
**************************************************/
|
**************************************************/
|
||||||
|
@ -59,6 +59,8 @@ http {
|
|||||||
location ^~ /api/schools { proxy_pass http://backend5001; }
|
location ^~ /api/schools { proxy_pass http://backend5001; }
|
||||||
location ^~ /api/support { proxy_pass http://backend5001; }
|
location ^~ /api/support { proxy_pass http://backend5001; }
|
||||||
location ^~ /api/data/ { proxy_pass http://backend5001; }
|
location ^~ /api/data/ { proxy_pass http://backend5001; }
|
||||||
|
location ^~ /api/careers/ { proxy_pass http://backend5001; }
|
||||||
|
location ^~ /api/programs/ { proxy_pass http://backend5001; }
|
||||||
|
|
||||||
location ^~ /api/premium/ { proxy_pass http://backend5002; }
|
location ^~ /api/premium/ { proxy_pass http://backend5002; }
|
||||||
location ^~ /api/public/ { proxy_pass http://backend5002; }
|
location ^~ /api/public/ { proxy_pass http://backend5002; }
|
||||||
|
7
package-lock.json
generated
7
package-lock.json
generated
@ -28,6 +28,7 @@
|
|||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cra-template": "1.2.0",
|
"cra-template": "1.2.0",
|
||||||
|
"csv-parse": "^6.1.0",
|
||||||
"docx": "^9.5.0",
|
"docx": "^9.5.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express-rate-limit": "^7.5.1",
|
"express-rate-limit": "^7.5.1",
|
||||||
@ -7852,6 +7853,12 @@
|
|||||||
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
|
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/csv-parse": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cra-template": "1.2.0",
|
"cra-template": "1.2.0",
|
||||||
|
"csv-parse": "^6.1.0",
|
||||||
"docx": "^9.5.0",
|
"docx": "^9.5.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express-rate-limit": "^7.5.1",
|
"express-rate-limit": "^7.5.1",
|
||||||
|
@ -63,7 +63,6 @@ function CareerExplorer() {
|
|||||||
|
|
||||||
// ---------- Component States ----------
|
// ---------- Component States ----------
|
||||||
const [userProfile, setUserProfile] = useState(null);
|
const [userProfile, setUserProfile] = useState(null);
|
||||||
const [masterCareerRatings, setMasterCareerRatings] = useState([]);
|
|
||||||
const [careerList, setCareerList] = useState([]);
|
const [careerList, setCareerList] = useState([]);
|
||||||
const [careerDetails, setCareerDetails] = useState(null);
|
const [careerDetails, setCareerDetails] = useState(null);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@ -271,6 +270,19 @@ function CareerExplorer() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stripSoc = (s = '') => s.split('.')[0];
|
||||||
|
|
||||||
|
async function fetchCareerMetaBySoc(soc) {
|
||||||
|
const base = soc || '';
|
||||||
|
if (!base) return null;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/careers/by-soc', { params: { soc: base } });
|
||||||
|
return data || null; // { soc_code, title, cip_codes, ratings }
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------
|
// --------------------------------------
|
||||||
// On mount, load suggestions from cache
|
// On mount, load suggestions from cache
|
||||||
// --------------------------------------
|
// --------------------------------------
|
||||||
@ -477,20 +489,6 @@ const handleCareerClick = useCallback(
|
|||||||
}
|
}
|
||||||
}, [pendingCareerForModal, handleCareerFromSearch]);
|
}, [pendingCareerForModal, handleCareerFromSearch]);
|
||||||
|
|
||||||
// ------------------------------------------------------
|
|
||||||
// Load careers_with_ratings for CIP arrays
|
|
||||||
// ------------------------------------------------------
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await api.get('/api/data/careers-with-ratings'); // server2 route
|
|
||||||
setMasterCareerRatings(data || []);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching career ratings:', err);
|
|
||||||
setMasterCareerRatings([]);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
// Derived data / Helpers
|
// Derived data / Helpers
|
||||||
@ -558,12 +556,6 @@ useEffect(() => {
|
|||||||
setChatSnapshot({ coreCtx, modalCtx });
|
setChatSnapshot({ coreCtx, modalCtx });
|
||||||
}, [coreCtx, modalCtx, setChatSnapshot]);
|
}, [coreCtx, modalCtx, setChatSnapshot]);
|
||||||
|
|
||||||
const getCareerRatingsBySocCode = (socCode) => {
|
|
||||||
return (
|
|
||||||
masterCareerRatings.find((c) => c.soc_code === socCode)?.ratings || {}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
// Save comparison list to backend
|
// Save comparison list to backend
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
@ -593,34 +585,34 @@ useEffect(() => {
|
|||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
// Add/Remove from comparison
|
// Add/Remove from comparison
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
const addCareerToList = (career) => {
|
const addCareerToList = async (career) => {
|
||||||
// 1) get default (pre-calculated) ratings from your JSON
|
// 1) get default (pre-calculated) ratings from backend by SOC
|
||||||
const masterRatings = getCareerRatingsBySocCode(career.code);
|
const meta = await fetchCareerMetaBySoc(career.code);
|
||||||
|
const masterRatings = meta?.ratings || {};
|
||||||
|
|
||||||
// 2) figure out interest
|
// 2) figure out interest
|
||||||
const userHasInventory =
|
const userHasInventory =
|
||||||
!career.fromManualSearch && // ← skip the shortcut if manual
|
!career.fromManualSearch &&
|
||||||
priorities.interests &&
|
priorities.interests &&
|
||||||
priorities.interests !== "I’m not sure yet";
|
priorities.interests !== "I’m not sure yet";
|
||||||
const defaultInterestValue =
|
|
||||||
userHasInventory
|
|
||||||
?
|
|
||||||
(fitRatingMap[career.fit] || masterRatings.interests || 3)
|
|
||||||
:
|
|
||||||
3;
|
|
||||||
|
|
||||||
const defaultMeaningValue = 3;
|
const defaultInterestValue = userHasInventory
|
||||||
|
? (fitRatingMap[career.fit] || masterRatings.interests || 3)
|
||||||
|
: 3;
|
||||||
|
|
||||||
|
const defaultMeaningValue = 3;
|
||||||
|
|
||||||
|
// 3) open the InterestMeaningModal (unchanged)
|
||||||
|
setModalData({
|
||||||
|
career,
|
||||||
|
masterRatings,
|
||||||
|
askForInterest: !userHasInventory,
|
||||||
|
defaultInterest: defaultInterestValue,
|
||||||
|
defaultMeaning: defaultMeaningValue,
|
||||||
|
});
|
||||||
|
setShowInterestMeaningModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
// 4) open the InterestMeaningModal instead of using prompt()
|
|
||||||
setModalData({
|
|
||||||
career,
|
|
||||||
masterRatings,
|
|
||||||
askForInterest: !userHasInventory,
|
|
||||||
defaultInterest: defaultInterestValue,
|
|
||||||
defaultMeaning: defaultMeaningValue,
|
|
||||||
});
|
|
||||||
setShowInterestMeaningModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModalSave = ({ interest, meaning }) => {
|
const handleModalSave = ({ interest, meaning }) => {
|
||||||
const { career, masterRatings, askForInterest, defaultInterest } = modalData;
|
const { career, masterRatings, askForInterest, defaultInterest } = modalData;
|
||||||
@ -674,12 +666,10 @@ useEffect(() => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripSoc = (s = '') => s.split('.')[0];
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
// "Select for Education" => navigate with CIP codes
|
// "Select for Education" => navigate with CIP codes
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
// CareerExplorer.js
|
|
||||||
// CareerExplorer.js
|
|
||||||
const handleSelectForEducation = async (career) => {
|
const handleSelectForEducation = async (career) => {
|
||||||
if (!career) return;
|
if (!career) return;
|
||||||
|
|
||||||
@ -696,34 +686,36 @@ const handleSelectForEducation = async (career) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1) try local JSON by base SOC (tolerates .00 vs none)
|
// 1) try local JSON by base SOC (tolerates .00 vs none)
|
||||||
const match = masterCareerRatings.find(r => stripSoc(r.soc_code) === baseSoc);
|
let rawCips = [];
|
||||||
let rawCips = Array.isArray(match?.cip_codes) ? match.cip_codes : [];
|
try {
|
||||||
let cleanedCips = cleanCipCodes(rawCips);
|
const meta = await fetchCareerMetaBySoc(baseSoc);
|
||||||
|
rawCips = Array.isArray(meta?.cip_codes) ? meta.cip_codes : [];
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
|
||||||
// 2) fallback: ask server2 to map SOC→CIP if local didn’t have any
|
let cleanedCips = cleanCipCodes(rawCips);
|
||||||
if (!cleanedCips.length) {
|
|
||||||
try {
|
|
||||||
const candidates = [
|
|
||||||
fullSoc, // as-is
|
|
||||||
baseSoc, // stripped
|
|
||||||
fullSoc.includes('.') ? null : `${fullSoc}.00` // add .00 if missing
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
let fromApi = null;
|
// 2) fallback: ask server2 to map SOC→CIP if none were found
|
||||||
for (const soc of candidates) {
|
if (!cleanedCips.length) {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get(`/api/cip/${soc}`);
|
const candidates = [
|
||||||
if (data?.cipCode) { fromApi = data.cipCode; break; }
|
fullSoc, // as-is
|
||||||
} catch {}
|
baseSoc, // stripped
|
||||||
}
|
fullSoc.includes('.') ? null : `${fullSoc}.00` // add .00 if missing
|
||||||
if (fromApi) {
|
].filter(Boolean);
|
||||||
rawCips = [fromApi];
|
|
||||||
cleanedCips = cleanCipCodes(rawCips);
|
let fromApi = null;
|
||||||
}
|
for (const soc of candidates) {
|
||||||
} catch {
|
try {
|
||||||
// best-effort fallback; continue
|
const { data } = await api.get(`/api/cip/${soc}`);
|
||||||
|
if (data?.cipCode) { fromApi = data.cipCode; break; }
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
if (fromApi) {
|
||||||
|
rawCips = [fromApi];
|
||||||
|
cleanedCips = cleanCipCodes(rawCips);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Persist for the next page (keep raw list if we have it)
|
// Persist for the next page (keep raw list if we have it)
|
||||||
const careerForStorage = {
|
const careerForStorage = {
|
||||||
@ -853,7 +845,7 @@ const handleSelectForEducation = async (career) => {
|
|||||||
<CareerSearch
|
<CareerSearch
|
||||||
disabled={showModal}
|
disabled={showModal}
|
||||||
onCareerSelected={(careerObj) => {
|
onCareerSelected={(careerObj) => {
|
||||||
console.log('[Dashboard] onCareerSelected =>', careerObj);
|
setLoading(true);
|
||||||
setPendingCareerForModal(careerObj);
|
setPendingCareerForModal(careerObj);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Check } from 'lucide-react'; // (unused here, keep/remove as you like)
|
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
import api from '../auth/apiClient.js';
|
import api from '../auth/apiClient.js';
|
||||||
|
|
||||||
@ -8,78 +7,105 @@ const normalize = (s = '') =>
|
|||||||
s
|
s
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\s*&\s*/g, ' and ')
|
.replace(/\s*&\s*/g, ' and ')
|
||||||
.replace(/[–—]/g, '-') // long dash → hyphen
|
.replace(/[–—]/g, '-') // long dash → hyphen
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
/* ---------- component ---------- */
|
/* ---------- component ---------- */
|
||||||
const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled = false }) => {
|
const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled = false }) => {
|
||||||
const [careerObjects, setCareerObjects] = useState([]);
|
const [suggestions, setSuggestions] = useState([]); // [{title,soc_code,cip_codes,...}]
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
const [selectedObj, setSelectedObj] = useState(null); // ✓ state
|
const [selectedObj, setSelectedObj] = useState(null);
|
||||||
const computedDisabled = externallyDisabled || !!selectedObj;
|
const [loading, setLoading] = useState(false);
|
||||||
|
const abortRef = useRef(null);
|
||||||
|
const lastMouseDownRef = useRef(0);
|
||||||
|
const prevValueRef = useRef('');
|
||||||
|
|
||||||
/* fetch & de-dupe once (now via backend) */
|
const computedDisabled = externallyDisabled || !!selectedObj;
|
||||||
|
const listId = 'career-titles';
|
||||||
|
|
||||||
|
// Debounced query to backend on input changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
if (computedDisabled) return;
|
||||||
|
|
||||||
|
const q = searchInput.trim();
|
||||||
|
// Don’t fetch on empty string (keeps UX identical to your datalist flow)
|
||||||
|
if (!q) {
|
||||||
|
setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// cancel previous in-flight
|
||||||
|
if (abortRef.current) abortRef.current.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const { data: raw = [] } = await api.get('/api/data/careers-with-ratings');
|
const { data } = await api.get('/api/careers/search', {
|
||||||
|
params: { query: q, limit: 15 },
|
||||||
|
signal: ctrl.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
// De-dupe by normalized title (keep first)
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const c of raw) {
|
for (const c of Array.isArray(data) ? data : []) {
|
||||||
if (c.title && c.soc_code && c.cip_codes) {
|
if (!c?.title || !c?.soc_code || !c?.cip_codes) continue;
|
||||||
const key = normalize(c.title);
|
const key = normalize(c.title);
|
||||||
if (!map.has(key)) {
|
if (!map.has(key)) {
|
||||||
map.set(key, {
|
map.set(key, {
|
||||||
title: c.title,
|
title: c.title,
|
||||||
soc_code: c.soc_code,
|
soc_code: c.soc_code,
|
||||||
cip_code: c.cip_codes,
|
cip_codes: c.cip_codes,
|
||||||
limited_data: c.limited_data,
|
limited_data: c.limited_data,
|
||||||
ratings: c.ratings
|
ratings: c.ratings
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCareerObjects([...map.values()]);
|
setSuggestions([...map.values()]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Career list load failed:', err);
|
if (err?.name !== 'CanceledError' && err?.code !== 'ERR_CANCELED') {
|
||||||
setCareerObjects([]);
|
console.error('Career search failed:', err);
|
||||||
|
setSuggestions([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
}, 150); // debounce ~150ms (keeps typing snappy)
|
||||||
}, []);
|
|
||||||
|
|
||||||
/* whenever input changes, auto-commit if it matches */
|
return () => {
|
||||||
useEffect(() => {
|
clearTimeout(timer);
|
||||||
const match = careerObjects.find(
|
ctrl.abort();
|
||||||
(o) => normalize(o.title) === normalize(searchInput)
|
};
|
||||||
);
|
}, [searchInput, computedDisabled]);
|
||||||
if (match && match !== selectedObj) {
|
|
||||||
setSelectedObj(match);
|
// Handle Enter → commit first startsWith match (then datalist shows exact)
|
||||||
onCareerSelected?.(match); // notify parent immediately
|
const handleKeyDown = (e) => {
|
||||||
}
|
if (computedDisabled || e.key !== 'Enter') return;
|
||||||
}, [searchInput, careerObjects, selectedObj, onCareerSelected]);
|
|
||||||
|
const n = normalize(searchInput);
|
||||||
|
const exact = suggestions.find(o => normalize(o.title) === n);
|
||||||
|
const firstP = suggestions.find(o => normalize(o.title).startsWith(n));
|
||||||
|
const first = exact || firstP || suggestions[0];
|
||||||
|
|
||||||
|
if (!first) return;
|
||||||
|
|
||||||
|
const payload = { ...first, cip_code: first.cip_codes };
|
||||||
|
setSearchInput(first.title);
|
||||||
|
setSelectedObj(payload);
|
||||||
|
onCareerSelected?.(payload);
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
/* allow “Enter” to commit first suggestion */
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
if (computedDisabled) return;
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
const first = careerObjects.find(o =>
|
|
||||||
normalize(o.title).startsWith(normalize(searchInput))
|
|
||||||
);
|
|
||||||
if (first) {
|
|
||||||
setSearchInput(first.title); // triggers auto-commit
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/* clear & edit again */
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setSelectedObj(null);
|
setSelectedObj(null);
|
||||||
setSearchInput('');
|
setSearchInput('');
|
||||||
|
setSuggestions([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const listId = 'career-titles';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block font-medium mb-1">
|
<label className="block font-medium mb-1">
|
||||||
@ -92,24 +118,61 @@ const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled
|
|||||||
list={listId}
|
list={listId}
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
required={required}
|
required={required}
|
||||||
disabled={computedDisabled} // lock when chosen
|
disabled={computedDisabled}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onMouseDown={() => { lastMouseDownRef.current = Date.now(); }}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setSearchInput(val);
|
||||||
|
if (computedDisabled) return;
|
||||||
|
|
||||||
|
const exact = suggestions.find(o => normalize(o.title) === normalize(val));
|
||||||
|
|
||||||
|
// Heuristic: datalist pick usually replaces many characters at once.
|
||||||
|
const prev = prevValueRef.current || '';
|
||||||
|
const bigJump = Math.abs(val.length - prev.length) > 1;
|
||||||
|
|
||||||
|
// Also still catch Chromium’s signal when present.
|
||||||
|
const it = e?.nativeEvent?.inputType;
|
||||||
|
const replacement = it === 'insertReplacementText';
|
||||||
|
|
||||||
|
if (exact && (bigJump || replacement)) {
|
||||||
|
const payload = { ...exact, cip_code: exact.cip_codes }; // full SOC preserved
|
||||||
|
setSelectedObj(payload);
|
||||||
|
onCareerSelected?.(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update after processing to get a clean delta next time
|
||||||
|
prevValueRef.current = val;
|
||||||
|
}}
|
||||||
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={`w-full border rounded p-2
|
onBlur={async () => {
|
||||||
${computedDisabled ? 'bg-gray-100 cursor-not-allowed opacity-60' : ''}`}
|
if (computedDisabled) return;
|
||||||
|
const exact = suggestions.find(o => normalize(o.title) === normalize(searchInput));
|
||||||
|
if (!exact) return;
|
||||||
|
const payload = { ...exact, cip_code: exact.cip_codes };
|
||||||
|
setSelectedObj(payload);
|
||||||
|
onCareerSelected?.(payload);
|
||||||
|
}}
|
||||||
|
className={`w-full border rounded p-2 ${computedDisabled ? 'bg-gray-100 cursor-not-allowed opacity-60' : ''}`}
|
||||||
placeholder="Start typing a career..."
|
placeholder="Start typing a career..."
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
{!computedDisabled && (
|
{!computedDisabled && (
|
||||||
<datalist id={listId}>
|
<datalist id={listId}>
|
||||||
{careerObjects.map((o) => (
|
{suggestions.map((o) => (
|
||||||
<option key={o.soc_code} value={o.title} />
|
<option key={`${o.soc_code}:${o.title}`} value={o.title} />
|
||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{loading && !computedDisabled && (
|
||||||
|
<div className="absolute right-2 top-2 text-xs text-gray-500">loading…</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PSA message */}
|
|
||||||
{!selectedObj && (
|
{!selectedObj && (
|
||||||
<p className="mt-2 text-sm text-blue-700">
|
<p className="mt-2 text-sm text-blue-700">
|
||||||
Please pick from the dropdown when performing search. Our database is very comprehensive but can’t
|
Please pick from the dropdown when performing search. Our database is very comprehensive but can’t
|
||||||
@ -117,7 +180,6 @@ const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* change / clear link */}
|
|
||||||
{selectedObj && !externallyDisabled && (
|
{selectedObj && !externallyDisabled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -93,7 +93,7 @@ function renderLevel(val) {
|
|||||||
function EducationalProgramsPage() {
|
function EducationalProgramsPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { state } = useLocation();
|
const { state } = location;
|
||||||
const navCareer = state?.selectedCareer || {};
|
const navCareer = state?.selectedCareer || {};
|
||||||
const [selectedCareer, setSelectedCareer] = useState(navCareer);
|
const [selectedCareer, setSelectedCareer] = useState(navCareer);
|
||||||
const [socCode, setSocCode] = useState(navCareer.code || '');
|
const [socCode, setSocCode] = useState(navCareer.code || '');
|
||||||
@ -738,6 +738,7 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
|
|||||||
school['INSTNM'] || 'Unnamed School'
|
school['INSTNM'] || 'Unnamed School'
|
||||||
)}
|
)}
|
||||||
</strong>
|
</strong>
|
||||||
|
<p> Program: {school['CIPDESC'] || 'N/A'}</p>
|
||||||
<p>Degree Type: {school['CREDDESC'] || 'N/A'}</p>
|
<p>Degree Type: {school['CREDDESC'] || 'N/A'}</p>
|
||||||
<p>In-State Tuition: ${school['In_state cost'] || '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>Out-of-State Tuition: ${school['Out_state cost'] || 'N/A'}</p>
|
||||||
|
@ -1,16 +1,14 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import Modal from '../../components/ui/modal.js';
|
import Modal from '../../components/ui/modal.js';
|
||||||
import FinancialAidWizard from '../../components/FinancialAidWizard.js';
|
import FinancialAidWizard from '../../components/FinancialAidWizard.js';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import authFetch from '../../utils/authFetch.js';
|
import api from '../../auth/apiClient.js';
|
||||||
|
|
||||||
const Req = () => <span className="text-red-600 ml-0.5">*</span>;
|
const Req = () => <span className="text-red-600 ml-0.5">*</span>;
|
||||||
|
|
||||||
function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
||||||
// CIP / iPEDS local states
|
|
||||||
const [schoolData, setSchoolData] = useState([]);
|
|
||||||
const [icTuitionData, setIcTuitionData] = useState([]);
|
|
||||||
const [schoolSuggestions, setSchoolSuggestions] = useState([]);
|
const [schoolSuggestions, setSchoolSuggestions] = useState([]);
|
||||||
|
const schoolPrevRef = useRef('');
|
||||||
const [programSuggestions, setProgramSuggestions] = useState([]);
|
const [programSuggestions, setProgramSuggestions] = useState([]);
|
||||||
const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
|
const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
|
||||||
const [schoolValid, setSchoolValid] = useState(false);
|
const [schoolValid, setSchoolValid] = useState(false);
|
||||||
@ -18,6 +16,7 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
|||||||
const [enrollmentDate, setEnrollmentDate] = useState(
|
const [enrollmentDate, setEnrollmentDate] = useState(
|
||||||
data.enrollment_date || ''
|
data.enrollment_date || ''
|
||||||
);
|
);
|
||||||
|
const [selectedUnitId, setSelectedUnitId] = useState(null);
|
||||||
const [expectedGraduation, setExpectedGraduation] = useState(data.expected_graduation || '');
|
const [expectedGraduation, setExpectedGraduation] = useState(data.expected_graduation || '');
|
||||||
const [showAidWizard, setShowAidWizard] = useState(false);
|
const [showAidWizard, setShowAidWizard] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -149,55 +148,6 @@ useEffect(() => {
|
|||||||
setManualProgramLength(e.target.value);
|
setManualProgramLength(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// CIP data
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchCipData() {
|
|
||||||
try {
|
|
||||||
// Preferred: backend returns JSON array
|
|
||||||
const res = await authFetch('/api/data/cip-institution-map');
|
|
||||||
if (!res.ok) throw new Error(`CIP map error (${res.status})`);
|
|
||||||
const ct = res.headers.get('content-type') || '';
|
|
||||||
if (ct.includes('application/json')) {
|
|
||||||
const json = await res.json();
|
|
||||||
setSchoolData(Array.isArray(json) ? json : []);
|
|
||||||
} else {
|
|
||||||
// Fallback: line-delimited JSON (if you keep that format)
|
|
||||||
const text = await res.text();
|
|
||||||
const parsed = text
|
|
||||||
.split('\n')
|
|
||||||
.map(line => { try { return JSON.parse(line); } catch { return null; } })
|
|
||||||
.filter(Boolean);
|
|
||||||
setSchoolData(parsed);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load CIP data:', err);
|
|
||||||
setSchoolData([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchCipData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// iPEDS data
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchIpedsData() {
|
|
||||||
try {
|
|
||||||
const res = await authFetch('/api/data/ic2023'); // serves CSV from backend
|
|
||||||
if (!res.ok) throw new Error(`iPEDS error (${res.status})`);
|
|
||||||
const text = await res.text();
|
|
||||||
const rows = text.split('\n').map(line => line.split(','));
|
|
||||||
const headers = rows[0] || [];
|
|
||||||
const dataRows = rows.slice(1).map(row =>
|
|
||||||
Object.fromEntries(row.map((val, idx) => [headers[idx], val]))
|
|
||||||
);
|
|
||||||
setIcTuitionData(dataRows);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load iPEDS data:', err);
|
|
||||||
setIcTuitionData([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchIpedsData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (college_enrollment_status !== 'prospective_student') return;
|
if (college_enrollment_status !== 'prospective_student') return;
|
||||||
|
|
||||||
@ -213,53 +163,55 @@ useEffect(() => {
|
|||||||
}, [college_enrollment_status, enrollmentDate, data.program_length, setData]);
|
}, [college_enrollment_status, enrollmentDate, data.program_length, setData]);
|
||||||
|
|
||||||
// School Name
|
// School Name
|
||||||
const handleSchoolChange = (eOrVal) => {
|
const handleSchoolChange = async (e) => {
|
||||||
const value =
|
const value = e.target.value || '';
|
||||||
typeof eOrVal === 'string' ? eOrVal : eOrVal?.target?.value || '';
|
setData(prev => ({ ...prev, selected_school: value, selected_program: '', program_type: '', credit_hours_required: '' }));
|
||||||
setData(prev => ({
|
if (!value.trim()) { setSchoolSuggestions([]); return; }
|
||||||
...prev,
|
|
||||||
selected_school: value,
|
|
||||||
selected_program: '',
|
|
||||||
program_type: '',
|
|
||||||
credit_hours_required: '',
|
|
||||||
}));
|
|
||||||
const filtered = schoolData.filter(s =>
|
|
||||||
s.INSTNM.toLowerCase().includes(value.toLowerCase())
|
|
||||||
);
|
|
||||||
const uniqueSchools = [...new Set(filtered.map(s => s.INSTNM))];
|
|
||||||
setSchoolSuggestions(uniqueSchools.slice(0, 10));
|
|
||||||
setProgramSuggestions([]);
|
|
||||||
setAvailableProgramTypes([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSchoolSelect = (schoolName) => {
|
const it = e?.nativeEvent?.inputType; // Chromium: 'insertReplacementText' on datalist pick
|
||||||
setData(prev => ({
|
const replacement = it === 'insertReplacementText';
|
||||||
...prev,
|
const bigJump = Math.abs(value.length - (schoolPrevRef.current || '').length) > 1;
|
||||||
selected_school: schoolName,
|
try {
|
||||||
selected_program: '',
|
const resp = await api.get('/api/schools/suggest', { params: { query: value, limit: 10 }});
|
||||||
program_type: '',
|
const opts = Array.isArray(resp.data) ? resp.data : [];
|
||||||
credit_hours_required: '',
|
setSchoolSuggestions(opts);
|
||||||
}));
|
|
||||||
|
// if user actually picked from dropdown → commit now (sets UNITID)
|
||||||
|
const exact = opts.find(o => (o.name || '').toLowerCase() === value.toLowerCase());
|
||||||
|
if (exact && (replacement || bigJump)) {
|
||||||
|
handleSchoolSelect(exact); // sets selectedUnitId + clears suggestions
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
setSchoolSuggestions([]);
|
setSchoolSuggestions([]);
|
||||||
setProgramSuggestions([]);
|
}
|
||||||
setAvailableProgramTypes([]);
|
schoolPrevRef.current = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleSchoolSelect = (schoolObj) => {
|
||||||
|
const name = schoolObj?.name || schoolObj || '';
|
||||||
|
const uid = schoolObj?.unitId || null;
|
||||||
|
setSelectedUnitId(uid);
|
||||||
|
setData(prev => ({ ...prev, selected_school: name, selected_program: '', program_type: '', credit_hours_required: '' }));
|
||||||
|
setSchoolSuggestions([]);
|
||||||
|
setProgramSuggestions([]);
|
||||||
|
setAvailableProgramTypes([]);
|
||||||
|
};
|
||||||
|
|
||||||
// Program
|
// Program
|
||||||
const handleProgramChange = (e) => {
|
const handleProgramChange = async (e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setData(prev => ({ ...prev, selected_program: value }));
|
setData(prev => ({ ...prev, selected_program: value }));
|
||||||
if (!value) {
|
if (!value || !selected_school) { setProgramSuggestions([]); return; }
|
||||||
setProgramSuggestions([]);
|
try {
|
||||||
return;
|
const { data } = await api.get('/api/programs/suggest', {
|
||||||
}
|
params: { school: selected_school, query: value, limit: 10 }
|
||||||
const filtered = schoolData.filter(
|
});
|
||||||
s => s.INSTNM.toLowerCase() === selected_school.toLowerCase() &&
|
setProgramSuggestions(Array.isArray(data) ? data : []); // [{ program }]
|
||||||
s.CIPDESC.toLowerCase().includes(value.toLowerCase())
|
} catch {
|
||||||
);
|
setProgramSuggestions([]);
|
||||||
const uniquePrograms = [...new Set(filtered.map(s => s.CIPDESC))];
|
}
|
||||||
setProgramSuggestions(uniquePrograms.slice(0, 10));
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const handleProgramSelect = (prog) => {
|
const handleProgramSelect = (prog) => {
|
||||||
setData(prev => ({ ...prev, selected_program: prog }));
|
setData(prev => ({ ...prev, selected_program: prog }));
|
||||||
@ -279,79 +231,37 @@ useEffect(() => {
|
|||||||
|
|
||||||
// once we have school+program => load possible program types
|
// once we have school+program => load possible program types
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected_program || !selected_school || !schoolData.length) return;
|
if (!selected_program || !selected_school) { setAvailableProgramTypes([]); return; }
|
||||||
const possibleTypes = schoolData
|
(async () => {
|
||||||
.filter(
|
try {
|
||||||
s => s.INSTNM.toLowerCase() === selected_school.toLowerCase() &&
|
const { data } = await api.get('/api/programs/types', { params: { school: selected_school, program: selected_program }});
|
||||||
s.CIPDESC === selected_program
|
setAvailableProgramTypes(Array.isArray(data?.types) ? data.types : []);
|
||||||
)
|
} catch {
|
||||||
.map(s => s.CREDDESC);
|
setAvailableProgramTypes([]);
|
||||||
setAvailableProgramTypes([...new Set(possibleTypes)]);
|
}
|
||||||
}, [selected_program, selected_school, schoolData]);
|
})();
|
||||||
|
}, [selected_program, selected_school]);
|
||||||
|
|
||||||
// auto-calc tuition
|
/*Auto Calc Tuition*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!icTuitionData.length) return;
|
(async () => {
|
||||||
if (!selected_school || !program_type || !credit_hours_per_year) return;
|
if (!selectedUnitId || !program_type || !credit_hours_per_year) return;
|
||||||
|
try {
|
||||||
const found = schoolData.find(
|
const { data } = await api.get('/api/tuition/estimate', {
|
||||||
s => s.INSTNM.toLowerCase() === selected_school.toLowerCase()
|
params: {
|
||||||
);
|
unitId: String(selectedUnitId),
|
||||||
if (!found) return;
|
programType: program_type,
|
||||||
|
inState: is_in_state ? 1 : 0,
|
||||||
const unitId = found.UNITID;
|
inDistrict: is_in_district ? 1 : 0,
|
||||||
if (!unitId) return;
|
creditHoursPerYear: Number(credit_hours_per_year) || 0
|
||||||
|
}
|
||||||
const match = icTuitionData.find(row => row.UNITID === unitId);
|
});
|
||||||
if (!match) return;
|
setAutoTuition(Number.isFinite(data?.estimate) ? data.estimate : 0);
|
||||||
|
} catch {
|
||||||
const isGradOrProf = [
|
setAutoTuition(0);
|
||||||
"Master's Degree",
|
}
|
||||||
"Doctoral Degree",
|
})();
|
||||||
"Graduate/Professional Certificate",
|
}, [selectedUnitId, program_type, credit_hours_per_year, is_in_state, is_in_district]);
|
||||||
"First Professional Degree"
|
|
||||||
].includes(program_type);
|
|
||||||
|
|
||||||
let partTimeRate = 0;
|
|
||||||
let fullTimeTuition = 0;
|
|
||||||
if (isGradOrProf) {
|
|
||||||
if (is_in_district) {
|
|
||||||
partTimeRate = parseFloat(match.HRCHG5 || 0);
|
|
||||||
fullTimeTuition = parseFloat(match.TUITION5 || 0);
|
|
||||||
} else if (is_in_state) {
|
|
||||||
partTimeRate = parseFloat(match.HRCHG6 || 0);
|
|
||||||
fullTimeTuition = parseFloat(match.TUITION6 || 0);
|
|
||||||
} else {
|
|
||||||
partTimeRate = parseFloat(match.HRCHG7 || 0);
|
|
||||||
fullTimeTuition = parseFloat(match.TUITION7 || 0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// undergrad
|
|
||||||
if (is_in_district) {
|
|
||||||
partTimeRate = parseFloat(match.HRCHG1 || 0);
|
|
||||||
fullTimeTuition = parseFloat(match.TUITION1 || 0);
|
|
||||||
} else if (is_in_state) {
|
|
||||||
partTimeRate = parseFloat(match.HRCHG2 || 0);
|
|
||||||
fullTimeTuition = parseFloat(match.TUITION2 || 0);
|
|
||||||
} else {
|
|
||||||
partTimeRate = parseFloat(match.HRCHG3 || 0);
|
|
||||||
fullTimeTuition = parseFloat(match.TUITION3 || 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const chpy = parseFloat(credit_hours_per_year) || 0;
|
|
||||||
let estimate = 0;
|
|
||||||
if (chpy < 24 && partTimeRate) {
|
|
||||||
estimate = partTimeRate * chpy;
|
|
||||||
} else {
|
|
||||||
estimate = fullTimeTuition;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAutoTuition(Math.round(estimate));
|
|
||||||
}, [
|
|
||||||
icTuitionData, selected_school, program_type,
|
|
||||||
credit_hours_per_year, is_in_state, is_in_district, schoolData
|
|
||||||
]);
|
|
||||||
|
|
||||||
// auto-calc program length
|
// auto-calc program length
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -527,25 +437,25 @@ const ready =
|
|||||||
name="selected_school"
|
name="selected_school"
|
||||||
value={selected_school}
|
value={selected_school}
|
||||||
onChange={handleSchoolChange}
|
onChange={handleSchoolChange}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
const ok = schoolData.some(
|
const exact = schoolSuggestions.find(o => (o.name || '').toLowerCase() === (selected_school || '').toLowerCase());
|
||||||
s => s.INSTNM.toLowerCase() === selected_school.toLowerCase()
|
if (exact) handleSchoolSelect(exact); // ensure UNITID is set
|
||||||
);
|
const ok = !!exact || !!selected_school;
|
||||||
setSchoolValid(ok);
|
setSchoolValid(ok);
|
||||||
if (!ok) alert("Please pick a school from the list.");
|
if (!ok) alert("Please pick a school from the list.");
|
||||||
}}
|
}}
|
||||||
list="school-suggestions"
|
list="school-suggestions"
|
||||||
className={`w-full border rounded p-2 ${schoolValid ? '' : 'border-red-500'}`}
|
className={`w-full border rounded p-2 ${schoolValid ? '' : 'border-red-500'}`}
|
||||||
placeholder="Start typing and choose…"
|
placeholder="Start typing and choose…"
|
||||||
/>
|
/>
|
||||||
<datalist id="school-suggestions">
|
<datalist id="school-suggestions">
|
||||||
{schoolSuggestions.map((sch, idx) => (
|
{schoolSuggestions.map((s) => (
|
||||||
<option
|
<option
|
||||||
key={idx}
|
key={`${s.unitId}:${s.name}`}
|
||||||
value={sch}
|
value={s.name}
|
||||||
onClick={() => handleSchoolSelect(sch)}
|
onClick={() => handleSchoolSelect(s)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -555,28 +465,18 @@ const ready =
|
|||||||
name="selected_program"
|
name="selected_program"
|
||||||
value={selected_program}
|
value={selected_program}
|
||||||
onChange={handleProgramChange}
|
onChange={handleProgramChange}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
const ok =
|
const ok = !!programSuggestions.find(p => (p.program || '').toLowerCase() === (selected_program || '').toLowerCase());
|
||||||
selected_school && // need a school first
|
setProgramValid(ok);
|
||||||
schoolData.some(
|
if (!ok) alert("Please pick a program from the list.");
|
||||||
s =>
|
}}
|
||||||
s.INSTNM.toLowerCase() === selected_school.toLowerCase() &&
|
|
||||||
s.CIPDESC.toLowerCase() === selected_program.toLowerCase()
|
|
||||||
);
|
|
||||||
setProgramValid(ok);
|
|
||||||
if (!ok) alert("Please pick a program from the list.");
|
|
||||||
}}
|
|
||||||
list="program-suggestions"
|
list="program-suggestions"
|
||||||
className={`w-full border rounded p-2 ${programValid ? '' : 'border-red-500'}`}
|
className={`w-full border rounded p-2 ${programValid ? '' : 'border-red-500'}`}
|
||||||
placeholder="Start typing and choose…"
|
placeholder="Start typing and choose…"
|
||||||
/>
|
/>
|
||||||
<datalist id="program-suggestions">
|
<datalist id="program-suggestions">
|
||||||
{programSuggestions.map((prog, idx) => (
|
{programSuggestions.map((p, idx) => (
|
||||||
<option
|
<option key={idx} value={p.program} onClick={() => handleProgramSelect(p.program)} />
|
||||||
key={idx}
|
|
||||||
value={prog}
|
|
||||||
onClick={() => handleProgramSelect(prog)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import api from '../auth/apiClient.js';
|
||||||
|
|
||||||
export async function clientGeocodeZip(zip) {
|
export async function clientGeocodeZip(zip) {
|
||||||
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
|
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
|
||||||
@ -34,23 +35,49 @@ export function haversineDistance(lat1, lon1, lat2, lon2) {
|
|||||||
|
|
||||||
export async function fetchSchools(cipCodes) {
|
export async function fetchSchools(cipCodes) {
|
||||||
try {
|
try {
|
||||||
|
const codes = Array.isArray(cipCodes) ? cipCodes : [cipCodes];
|
||||||
// 1) If `cipCodes` is a single string => wrap in array
|
const { data } = await api.get('/api/schools', {
|
||||||
let codesArray = Array.isArray(cipCodes) ? cipCodes : [cipCodes];
|
params: { cipCodes: codes.join(',') }
|
||||||
|
|
||||||
// 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('/api/schools', {
|
|
||||||
params: {
|
|
||||||
cipCodes: cipParam,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return response.data;
|
return Array.isArray(data) ? data : [];
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Error fetching schools:', error);
|
console.error('Error fetching schools:', err);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPrograms(school, query) {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/programs/suggest', {
|
||||||
|
params: { school, query, limit: 10 }
|
||||||
|
});
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching programs:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProgramTypes(school, program) {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/programs/types', {
|
||||||
|
params: { school, program }
|
||||||
|
});
|
||||||
|
return Array.isArray(data.types) ? data.types : [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching program types:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTuitionEstimate(unitId, programType, inState, inDistrict, creditHoursPerYear) {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/tuition/estimate', {
|
||||||
|
params: { unitId, programType, inState, inDistrict, creditHoursPerYear }
|
||||||
|
});
|
||||||
|
return data || {};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching tuition estimate:', err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user