This commit is contained in:
parent
fe8102385e
commit
e6d567d839
@ -1 +1 @@
|
||||
3eefb2cd6c785e5815d042d108f67a87c6819a4d-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
b632ad41cfb05900be9a667c396e66a4dfb26320-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
|
@ -1,8 +1,12 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import authFetch from "../utils/authFetch.js";
|
||||
|
||||
const isoToday = new Date().toISOString().slice(0,10); // top-level helper
|
||||
|
||||
|
||||
|
||||
|
||||
async function ensureCoachThread() {
|
||||
// try to list an existing thread
|
||||
const r = await authFetch('/api/premium/coach/chat/threads');
|
||||
@ -21,6 +25,19 @@ async function ensureCoachThread() {
|
||||
return id;
|
||||
}
|
||||
|
||||
const isHiddenPrompt = (m) => {
|
||||
if (!m || !m.content) return false;
|
||||
const c = String(m.content);
|
||||
// Heuristics that match your hidden prompts / modes
|
||||
return (
|
||||
m.role === 'system' ||
|
||||
c.startsWith('# ⛔️') ||
|
||||
c.startsWith('MODE :') ||
|
||||
c.startsWith('MODE:') ||
|
||||
c.includes('"milestones"') && c.includes('"tasks"') && c.includes('"date"') && c.includes('"title"')
|
||||
);
|
||||
};
|
||||
|
||||
function buildInterviewPrompt(careerName, jobDescription = "") {
|
||||
return `
|
||||
You are an expert interviewer for the role **${careerName}**.
|
||||
@ -136,6 +153,7 @@ export default function CareerCoach({
|
||||
onMilestonesCreated,
|
||||
onAiRiskFetched
|
||||
}) {
|
||||
const location = useLocation();
|
||||
/* -------------- state ---------------- */
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
@ -153,6 +171,45 @@ export default function CareerCoach({
|
||||
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
// If career_name is still missing, fetch the profile once
|
||||
useEffect(() => {
|
||||
if (scenarioRow?.career_name || !careerProfileId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await authFetch(`/api/premium/career-profile/${careerProfileId}`);
|
||||
if (!r.ok || cancelled) return;
|
||||
const row = await r.json();
|
||||
if (!row?.career_name) return;
|
||||
setScenarioRow(prev => ({
|
||||
...prev,
|
||||
career_name: prev?.career_name || row.career_name,
|
||||
soc_code : prev?.soc_code || row.soc_code || ''
|
||||
}));
|
||||
} catch (_) {}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [careerProfileId, scenarioRow?.career_name, setScenarioRow]);
|
||||
|
||||
// Hydrate career_name/soc_code from nav or localStorage if missing
|
||||
useEffect(() => {
|
||||
if (scenarioRow?.career_name) return;
|
||||
let sel = null;
|
||||
const navSel =
|
||||
location.state?.selectedCareer ??
|
||||
location.state?.premiumOnboardingState?.selectedCareer ?? null;
|
||||
if (navSel) sel = navSel;
|
||||
else {
|
||||
try { sel = JSON.parse(localStorage.getItem('selectedCareer') || 'null'); } catch {}
|
||||
}
|
||||
if (!sel) return;
|
||||
setScenarioRow(prev => ({
|
||||
...prev,
|
||||
career_name: prev?.career_name || sel.title || 'this career',
|
||||
soc_code : prev?.soc_code || sel.soc_code || sel.socCode || sel.code || ''
|
||||
}));
|
||||
}, [location.state, scenarioRow?.career_name, setScenarioRow]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@ -170,17 +227,24 @@ useEffect(() => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (r3.ok && (r3.headers.get('content-type') || '').includes('application/json')) {
|
||||
const data = await r3.json();
|
||||
const msgs = Array.isArray(data.messages) ? data.messages : [];
|
||||
if (!cancelled) setMessages(msgs.length ? msgs : [generatePersonalizedIntro()]);
|
||||
} else {
|
||||
if (!cancelled) setMessages([generatePersonalizedIntro()]);
|
||||
}
|
||||
const data = await r3.json();
|
||||
const msgs = (Array.isArray(data.messages) ? data.messages : []).filter(m => !isHiddenPrompt(m));
|
||||
if (!cancelled) {
|
||||
setMessages(msgs); // no intro here
|
||||
historyLoadedRef.current = true;
|
||||
}
|
||||
} else {
|
||||
if (!cancelled) {
|
||||
setMessages([]); // no intro here
|
||||
historyLoadedRef.current = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Coach thread preload failed:", e);
|
||||
if (!cancelled) {
|
||||
setThreadId(null);
|
||||
setMessages([generatePersonalizedIntro()]);
|
||||
setMessages([]);
|
||||
historyLoadedRef.current = true;
|
||||
}
|
||||
}
|
||||
})();
|
||||
@ -191,9 +255,11 @@ useEffect(() => {
|
||||
|
||||
/* -------------- intro ---------------- */
|
||||
useEffect(() => {
|
||||
if (!scenarioRow || !historyLoadedRef.current) return;
|
||||
if (!historyLoadedRef.current) return;
|
||||
if (!scenarioRow?.career_name) return;
|
||||
if (!userProfile) return; // wait for profile (career_situation)
|
||||
setMessages(prev => (prev.length ? prev : [generatePersonalizedIntro()]));
|
||||
}, [scenarioRow?.id]);
|
||||
}, [historyLoadedRef.current, scenarioRow?.career_name, userProfile]);
|
||||
|
||||
/* ---------- helpers you already had ---------- */
|
||||
function buildStatusSituationMessage(status, situation, careerName) {
|
||||
@ -240,7 +306,9 @@ We can refine details anytime or just jump straight to what you're most interest
|
||||
|
||||
function generatePersonalizedIntro() {
|
||||
/* (unchanged body) */
|
||||
const careerName = scenarioRow?.career_name || "this career";
|
||||
const careerName =
|
||||
scenarioRow?.career_name ||
|
||||
(() => { try { return (JSON.parse(localStorage.getItem('selectedCareer')||'null')?.title) || 'this career'; } catch { return 'this career'; } })();
|
||||
const goalsText = scenarioRow?.career_goals?.trim() || null;
|
||||
const riskLevel = scenarioRow?.riskLevel;
|
||||
const riskReasoning = scenarioRow?.riskReasoning;
|
||||
@ -407,7 +475,7 @@ I'm here to support you with personalized coaching. What would you like to focus
|
||||
className="overflow-y-auto border rounded mb-4 space-y-2"
|
||||
style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }}
|
||||
>
|
||||
{messages.map((m, i) => (
|
||||
{messages.filter(m => !isHiddenPrompt(m)).map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded p-2 ${
|
||||
|
@ -4,46 +4,104 @@ import authFetch from '../utils/authFetch.js';
|
||||
const CareerPrioritiesModal = ({ userProfile, onClose }) => {
|
||||
const [responses, setResponses] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile?.career_priorities) {
|
||||
setResponses(JSON.parse(userProfile.career_priorities));
|
||||
}
|
||||
}, [userProfile]);
|
||||
|
||||
// Updated "interests" question:
|
||||
const questions = [
|
||||
{
|
||||
id: 'interests',
|
||||
const QUESTIONS = [
|
||||
{ id: 'interests',
|
||||
text: 'How important is it that your career aligns with your personal interests?',
|
||||
options: ['Very important', 'Somewhat important', 'Not as important'],
|
||||
},
|
||||
{
|
||||
id: 'meaning',
|
||||
{ id: 'meaning',
|
||||
text: 'Is it important your job helps others or makes a difference?',
|
||||
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
|
||||
},
|
||||
{
|
||||
id: 'stability',
|
||||
{ id: 'stability',
|
||||
text: 'How important is it that your career pays well?',
|
||||
options: ['Very important', 'Somewhat important', 'Not as important'],
|
||||
},
|
||||
{
|
||||
id: 'growth',
|
||||
{ id: 'growth',
|
||||
text: 'Do you want clear chances to advance and grow professionally?',
|
||||
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
|
||||
},
|
||||
{
|
||||
id: 'balance',
|
||||
{ id: 'balance',
|
||||
text: 'Do you prefer a job with flexible hours and time outside work?',
|
||||
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
|
||||
},
|
||||
{
|
||||
id: 'recognition',
|
||||
{ id: 'recognition',
|
||||
text: 'How important is it to have a career that others admire?',
|
||||
options: ['Very important', 'Somewhat important', 'Not as important'],
|
||||
},
|
||||
];
|
||||
|
||||
// Map legacy keys -> current ids
|
||||
const KEY_MAP = {
|
||||
interests: 'interests',
|
||||
impact: 'meaning',
|
||||
meaning: 'meaning',
|
||||
salary: 'stability',
|
||||
pay: 'stability',
|
||||
compensation: 'stability',
|
||||
stability: 'stability',
|
||||
advancement: 'growth',
|
||||
growth: 'growth',
|
||||
work_life_balance: 'balance',
|
||||
worklife: 'balance',
|
||||
balance: 'balance',
|
||||
prestige: 'recognition',
|
||||
recognition: 'recognition',
|
||||
};
|
||||
|
||||
// Map legacy numeric scales (1–5) to current option strings
|
||||
const numToLabel = (n) => {
|
||||
const v = Number(n);
|
||||
if (Number.isNaN(v)) return null;
|
||||
if (v >= 4) return 'Very important';
|
||||
if (v === 3) return 'Somewhat important';
|
||||
return 'Not as important'; // 1–2
|
||||
};
|
||||
|
||||
const coerceToLabel = (val) => {
|
||||
if (val == null) return null;
|
||||
if (typeof val === 'number' || /^[0-9]+$/.test(String(val))) {
|
||||
return numToLabel(val);
|
||||
}
|
||||
const s = String(val).trim();
|
||||
// Normalize a few common textual variants
|
||||
if (/^very/i.test(s)) return 'Very important';
|
||||
if (/^some/i.test(s)) return 'Somewhat important';
|
||||
if (/^not/i.test(s)) return 'Not as important';
|
||||
if (/^yes/i.test(s)) return 'Yes, very important';
|
||||
return s; // assume already one of the options
|
||||
};
|
||||
|
||||
const normalizePriorities = (raw) => {
|
||||
const out = {};
|
||||
if (!raw || typeof raw !== 'object') return out;
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
const id = KEY_MAP[k] || k;
|
||||
const label = coerceToLabel(v);
|
||||
if (label) out[id] = label;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const cp = userProfile?.career_priorities;
|
||||
if (!cp) return;
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = typeof cp === 'string' ? JSON.parse(cp) : cp;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
const normalized = normalizePriorities(parsed);
|
||||
// Only keep keys we actually ask
|
||||
const allowed = QUESTIONS.reduce((acc, q) => {
|
||||
if (normalized[q.id]) acc[q.id] = normalized[q.id];
|
||||
return acc;
|
||||
}, {});
|
||||
setResponses(allowed);
|
||||
}, [userProfile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = {
|
||||
firstName: userProfile.firstname,
|
||||
@ -68,29 +126,23 @@ const CareerPrioritiesModal = ({ userProfile, onClose }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const allAnswered = questions.every(q => responses[q.id]);
|
||||
const allAnswered = QUESTIONS.every(q => responses[q.id]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50">
|
||||
<div className="bg-white p-6 rounded-lg shadow-lg w-full max-w-2xl overflow-y-auto max-h-[90vh] z-60">
|
||||
<h2 className="text-xl font-bold mb-4">Tell us what's important to you</h2>
|
||||
{questions.map(q => (
|
||||
{QUESTIONS.map(q => (
|
||||
<div key={q.id} className="mb-4">
|
||||
<label className="block mb-2 font-medium">{q.text}</label>
|
||||
<select
|
||||
value={responses[q.id] || ''}
|
||||
onChange={(e) =>
|
||||
setResponses({ ...responses, [q.id]: e.target.value })
|
||||
}
|
||||
onChange={(e) => setResponses({ ...responses, [q.id]: e.target.value })}
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select an answer
|
||||
</option>
|
||||
<option value="" disabled>Select an answer</option>
|
||||
{q.options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@ -99,9 +151,7 @@ const CareerPrioritiesModal = ({ userProfile, onClose }) => {
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!allAnswered}
|
||||
className={`px-4 py-2 rounded ${
|
||||
allAnswered ? 'bg-blue-600 text-white' : 'bg-gray-300 cursor-not-allowed'
|
||||
}`}
|
||||
className={`px-4 py-2 rounded ${allAnswered ? 'bg-blue-600 text-white' : 'bg-gray-300 cursor-not-allowed'}`}
|
||||
>
|
||||
Save Answers
|
||||
</button>
|
||||
|
@ -52,7 +52,7 @@ ChartJS.register(
|
||||
PointElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
zoomPlugin, // 👈 ←–––– only if you kept the zoom config
|
||||
zoomPlugin,
|
||||
annotationPlugin
|
||||
);
|
||||
|
||||
@ -486,19 +486,30 @@ const zoomConfig = {
|
||||
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' }
|
||||
};
|
||||
|
||||
// Compute if any savings are negative to pick a sane baseline
|
||||
const minSavings = projectionData.reduce((min, p) => {
|
||||
const e = Number(p.emergencySavings ?? 0);
|
||||
const r = Number(p.retirementSavings ?? 0);
|
||||
const t = Number(p.totalSavings ?? 0);
|
||||
return Math.min(min, e, r, t);
|
||||
}, Infinity);
|
||||
const hasNegSavings = Number.isFinite(minSavings) && minSavings < 0;
|
||||
|
||||
const xAndYScales = {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: { unit: 'month' },
|
||||
ticks: { maxRotation: 0, autoSkip: true }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: (val) => val.toLocaleString() // comma-format big numbers
|
||||
}
|
||||
}
|
||||
};
|
||||
x: {
|
||||
type: 'time',
|
||||
time: { unit: 'month' },
|
||||
ticks: { maxRotation: 0, autoSkip: true }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: !hasNegSavings,
|
||||
// give a little room below the smallest negative so the fill doesn't sit on the axis
|
||||
min: hasNegSavings ? Math.floor(minSavings * 1.05) : undefined,
|
||||
ticks: {
|
||||
callback: (val) => val.toLocaleString()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
* ONE-TIME “MISSING FIELDS” GUARD
|
||||
@ -1261,7 +1272,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
|
||||
|
||||
const emergencyData = {
|
||||
label: 'Emergency Savings',
|
||||
data: projectionData.map((p) => p.emergencySavings),
|
||||
data: projectionData.map((p) => Number(p.emergencySavings ?? 0)),
|
||||
borderColor: 'rgba(255, 159, 64, 1)',
|
||||
backgroundColor: 'rgba(255, 159, 64, 0.2)',
|
||||
tension: 0.4,
|
||||
@ -1269,7 +1280,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
|
||||
};
|
||||
const retirementData = {
|
||||
label: 'Retirement Savings',
|
||||
data: projectionData.map((p) => p.retirementSavings),
|
||||
data: projectionData.map((p) => Number(p.retirementSavings ?? 0)),
|
||||
borderColor: 'rgba(75, 192, 192, 1)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||
tension: 0.4,
|
||||
@ -1277,7 +1288,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
|
||||
};
|
||||
const totalSavingsData = {
|
||||
label: 'Total Savings',
|
||||
data: projectionData.map((p) => p.totalSavings),
|
||||
data: projectionData.map((p) => Number(p.totalSavings ?? 0)),
|
||||
borderColor: 'rgba(54, 162, 235, 1)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||||
tension: 0.4,
|
||||
@ -1285,7 +1296,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
|
||||
};
|
||||
const loanBalanceData = {
|
||||
label: 'Loan Balance',
|
||||
data: projectionData.map((p) => p.loanBalance),
|
||||
data: projectionData.map((p) => Number(p.loanBalance ?? 0)),
|
||||
borderColor: 'rgba(255, 99, 132, 1)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
||||
tension: 0.4,
|
||||
@ -1510,7 +1521,7 @@ const handleMilestonesCreated = useCallback(
|
||||
{showMissingBanner && (
|
||||
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 rounded shadow mb-4">
|
||||
<p className="text-sm text-gray-800">
|
||||
To run your full projection, please add:
|
||||
To improve your projection, please add:
|
||||
</p>
|
||||
{!!missingKeys.length && (
|
||||
<ul className="mt-2 ml-5 list-disc text-sm text-gray-800">
|
||||
|
@ -14,6 +14,12 @@ const parseFloatOrNull = v => {
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
const fromSqlDate = (v) => {
|
||||
if (!v) return '';
|
||||
// Accept "YYYY-MM-DD", "YYYY-MM-DD HH:MM:SS", or ISO; trim to date part
|
||||
return String(v).slice(0, 10);
|
||||
};
|
||||
|
||||
function normalisePayload(draft) {
|
||||
const bools = [
|
||||
'is_in_state','is_in_district','is_online',
|
||||
@ -122,15 +128,69 @@ const onProgramInput = (e) => {
|
||||
setProgSug([...new Set(sug)].slice(0, 10));
|
||||
};
|
||||
|
||||
// Prefill school suggestions when form loads or school changes
|
||||
useEffect(() => {
|
||||
const v = (form.selected_school || '').toLowerCase().trim();
|
||||
if (!v || !cipRows.length) {
|
||||
setSchoolSug([]);
|
||||
return;
|
||||
}
|
||||
const suggestions = cipRows
|
||||
.filter(r => (r.INSTNM || '').toLowerCase().includes(v))
|
||||
.map(r => r.INSTNM);
|
||||
setSchoolSug([...new Set(suggestions)].slice(0, 10));
|
||||
}, [form.selected_school, cipRows]);
|
||||
|
||||
// Prefill program suggestions when form loads or program/school changes
|
||||
useEffect(() => {
|
||||
const sch = (form.selected_school || '').toLowerCase().trim();
|
||||
const q = (form.selected_program || '').toLowerCase().trim();
|
||||
if (!sch || !q || !cipRows.length) {
|
||||
setProgSug([]);
|
||||
return;
|
||||
}
|
||||
const sug = cipRows
|
||||
.filter(r =>
|
||||
(r.INSTNM || '').toLowerCase() === sch &&
|
||||
(r.CIPDESC || '').toLowerCase().includes(q)
|
||||
)
|
||||
.map(r => r.CIPDESC);
|
||||
setProgSug([...new Set(sug)].slice(0, 10));
|
||||
}, [form.selected_school, form.selected_program, cipRows]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (id && id !== 'new') {
|
||||
(async () => {
|
||||
const r = await authFetch(`/api/premium/college-profile?careerProfileId=${careerId}`);
|
||||
if (r.ok) setForm(await r.json());
|
||||
})();
|
||||
if (r.ok) {
|
||||
const raw = await r.json();
|
||||
const normalized = {
|
||||
...raw,
|
||||
enrollment_date : fromSqlDate(raw.enrollment_date),
|
||||
expected_graduation : fromSqlDate(raw.expected_graduation),
|
||||
is_in_state : !!raw.is_in_state,
|
||||
is_in_district : !!raw.is_in_district,
|
||||
is_online : !!raw.is_online,
|
||||
loan_deferral_until_graduation : !!raw.loan_deferral_until_graduation,
|
||||
};
|
||||
setForm(normalized);
|
||||
if (normalized.tuition !== undefined && normalized.tuition !== null) {
|
||||
setManualTuition(String(normalized.tuition));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [careerId, id]);
|
||||
|
||||
// 2) keep manualTuition aligned if form.tuition is updated elsewhere
|
||||
useEffect(() => {
|
||||
if (form.tuition !== undefined && form.tuition !== null) {
|
||||
if (manualTuition.trim() === '') {
|
||||
setManualTuition(String(form.tuition));
|
||||
}
|
||||
}, [careerId, id]);
|
||||
}
|
||||
}, [form.tuition]);
|
||||
|
||||
async function handleSave(){
|
||||
try{
|
||||
@ -245,6 +305,8 @@ const chosenTuition = (() => {
|
||||
───────────────────────────────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
if (programLengthTouched) return; // user override
|
||||
// if a program_length already exists (e.g., from API), don't overwrite it
|
||||
if (form.program_length !== '' && form.program_length != null) return; // user override
|
||||
|
||||
const chpy = parseFloat(form.credit_hours_per_year);
|
||||
if (!chpy || chpy <= 0) return;
|
||||
@ -266,7 +328,7 @@ const chpy = parseFloat(form.credit_hours_per_year);
|
||||
if (creditsNeeded <= 0) return;
|
||||
|
||||
/* 2 – years = credits / CHPY → one decimal place */
|
||||
const years = Math.ceil((creditsNeeded / chpy) * 10) / 10;
|
||||
const years = Math.round((creditsNeeded / chpy) * 100) / 100;
|
||||
|
||||
if (years !== form.program_length) {
|
||||
setForm(prev => ({ ...prev, program_length: years }));
|
||||
@ -522,6 +584,7 @@ return (
|
||||
<label className="block font-medium">Program Length (years)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
name="program_length"
|
||||
value={form.program_length}
|
||||
onChange={handleFieldChange}
|
||||
|
@ -5,6 +5,7 @@ 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) {
|
||||
@ -71,6 +72,11 @@ function ensureHttp(urlString) {
|
||||
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;
|
||||
@ -168,24 +174,55 @@ function normalizeCipList(arr) {
|
||||
}
|
||||
|
||||
// Fixed handleSelectSchool (removed extra brace)
|
||||
const handleSelectSchool = (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 selectedCareer and carry it forward
|
||||
const sel = selectedCareer
|
||||
? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode }
|
||||
: null;
|
||||
// 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;
|
||||
|
||||
navigate('/career-roadmap', {
|
||||
state: {
|
||||
premiumOnboardingState: {
|
||||
selectedCareer: sel, // SOC-bearing career object
|
||||
selectedSchool: school // school card just chosen
|
||||
}
|
||||
}
|
||||
});
|
||||
// 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 || '').replace(/\.\s*$/, '');
|
||||
const program_type = school?.CREDDESC || '';
|
||||
|
||||
// 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({
|
||||
id: draft?.id || null,
|
||||
step: draft?.step ?? 0,
|
||||
careerData: existing.careerData || {},
|
||||
financialData: existing.financialData || {},
|
||||
collegeData: {
|
||||
...(existing.collegeData || {}),
|
||||
selected_school,
|
||||
selected_program,
|
||||
program_type,
|
||||
},
|
||||
});
|
||||
|
||||
// 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: school.UNITID ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function getSearchLinks(ksaName, careerTitle) {
|
||||
@ -738,7 +775,7 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
|
||||
school['INSTNM'] || 'Unnamed School'
|
||||
)}
|
||||
</strong>
|
||||
<p> Program: {school['CIPDESC'] || 'N/A'}</p>
|
||||
<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>
|
||||
|
@ -173,30 +173,60 @@ export default function MilestoneEditModal({
|
||||
});
|
||||
|
||||
async function saveNew(){
|
||||
if(isSavingNew) return;
|
||||
if(!newMilestone.title.trim()||!newMilestone.date.trim()){
|
||||
alert('Need title & date'); return;
|
||||
}
|
||||
setIsSavingNew(true);
|
||||
const hdr = { title:newMilestone.title, description:newMilestone.description,
|
||||
date:toSqlDate(newMilestone.date), career_profile_id:careerProfileId,
|
||||
progress:newMilestone.progress, status:newMilestone.progress>=100?'completed':'planned',
|
||||
is_universal:newMilestone.isUniversal };
|
||||
const res = await authFetch('/api/premium/milestone',{method:'POST',
|
||||
headers:{'Content-Type':'application/json'},body:JSON.stringify(hdr)});
|
||||
const created = Array.isArray(await res.json())? (await res.json())[0]:await res.json();
|
||||
for(const imp of newMilestone.impacts){
|
||||
const body = {
|
||||
milestone_id:created.id, impact_type:imp.impact_type,
|
||||
direction:imp.impact_type==='salary'?'add':imp.direction,
|
||||
amount:parseFloat(imp.amount)||0, start_date:imp.start_date||null, end_date:imp.end_date||null
|
||||
if (isSavingNew) return;
|
||||
if (!newMilestone.title.trim() || !newMilestone.date.trim()) {
|
||||
alert('Need title & date'); return;
|
||||
}
|
||||
setIsSavingNew(true);
|
||||
const toDate = (v) => (v ? String(v).slice(0,10) : null);
|
||||
try {
|
||||
const hdr = {
|
||||
title: newMilestone.title,
|
||||
description: newMilestone.description,
|
||||
date: toDate(newMilestone.date),
|
||||
career_profile_id: careerProfileId,
|
||||
progress: newMilestone.progress,
|
||||
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
|
||||
is_universal: newMilestone.isUniversal,
|
||||
};
|
||||
const res = await authFetch('/api/premium/milestone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body: JSON.stringify(hdr)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const createdJson = await res.json();
|
||||
const created = Array.isArray(createdJson) ? createdJson[0] : createdJson;
|
||||
if (!created || !created.id) throw new Error('Milestone create failed — no id returned');
|
||||
|
||||
// Save any non-empty impact rows
|
||||
for (const imp of newMilestone.impacts) {
|
||||
if (!imp) continue;
|
||||
const hasAnyField = (imp.amount || imp.start_date || imp.end_date || imp.impact_type);
|
||||
if (!hasAnyField) continue;
|
||||
const ibody = {
|
||||
milestone_id: created.id,
|
||||
impact_type : imp.impact_type,
|
||||
direction : imp.impact_type === 'salary' ? 'add' : imp.direction,
|
||||
amount : parseFloat(imp.amount) || 0,
|
||||
start_date : toDate(imp.start_date),
|
||||
end_date : toDate(imp.end_date),
|
||||
};
|
||||
await authFetch('/api/premium/milestone-impacts',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
const ir = await authFetch('/api/premium/milestone-impacts', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(ibody)
|
||||
});
|
||||
if (!ir.ok) throw new Error(await ir.text());
|
||||
}
|
||||
|
||||
await fetchMilestones();
|
||||
setAddingNew(false);
|
||||
onClose(true);
|
||||
} catch (err) {
|
||||
console.error('saveNew:', err);
|
||||
alert(err.message || 'Failed to save milestone');
|
||||
} finally {
|
||||
setIsSavingNew(false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════ */
|
||||
@ -204,7 +234,7 @@ export default function MilestoneEditModal({
|
||||
/* ══════════════════════════════════════════════════════════════ */
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center overflow-y-auto bg-black/40">
|
||||
<div className="bg-white w-full max-w-3xl mx-4 my-10 rounded-md shadow-lg ring-1 ring-gray-300">
|
||||
<div className="bg-white w-full max-w-3xl mx-4 my-10 rounded-md shadow-lg ring-1 ring-gray-300 overflow-hidden">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<div>
|
||||
@ -217,7 +247,7 @@ export default function MilestoneEditModal({
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto">
|
||||
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto overflow-x-hidden">
|
||||
{/* EXISTING */}
|
||||
{milestones.map(m=>{
|
||||
const open = editingId===m.id;
|
||||
@ -272,7 +302,7 @@ export default function MilestoneEditModal({
|
||||
|
||||
{/* impacts */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<h4 className="font-medium text-sm">Financial impacts</h4>
|
||||
<Button size="xs" onClick={()=>addImpactRow(m.id)}>+ Add impact</Button>
|
||||
</div>
|
||||
@ -282,7 +312,7 @@ export default function MilestoneEditModal({
|
||||
|
||||
<div className="space-y-3">
|
||||
{d.impacts?.map((imp,idx)=>(
|
||||
<div key={idx} className="grid gap-2 md:grid-cols-[150px_120px_1fr_auto] items-end">
|
||||
<div key={idx} className="grid gap-2 items-end min-w-0 md:grid-cols-[150px_110px_100px_minmax(240px,1fr)_40px]">
|
||||
{/* type */}
|
||||
<div>
|
||||
<label className="label-xs">Type</label>
|
||||
@ -296,21 +326,21 @@ export default function MilestoneEditModal({
|
||||
</select>
|
||||
</div>
|
||||
{/* direction – hide for salary */}
|
||||
{imp.impact_type!=='salary' && (
|
||||
<div>
|
||||
<label className="label-xs">Direction</label>
|
||||
<select
|
||||
className="input"
|
||||
value={imp.direction}
|
||||
onChange={e=>updateImpact(m.id,idx,'direction',e.target.value)}>
|
||||
<option value="add">Add</option>
|
||||
<option value="subtract">Subtract</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{imp.impact_type !== 'salary' ? (
|
||||
<div>
|
||||
<label className="label-xs">Direction</label>
|
||||
<select className="input" value={imp.direction} onChange={e=>updateImpact(m.id,idx,'direction',e.target.value)}>
|
||||
<option value="add">Add</option>
|
||||
<option value="subtract">Subtract</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
// keep the grid column to prevent the next columns from collapsing
|
||||
<div className="hidden md:block" />
|
||||
)}
|
||||
{/* amount */}
|
||||
<div>
|
||||
<label className="label-xs">Amount</label>
|
||||
<div className="md:w-[100px]">
|
||||
<label className="label-xs">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
@ -319,12 +349,12 @@ export default function MilestoneEditModal({
|
||||
/>
|
||||
</div>
|
||||
{/* dates */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 min-w-0">
|
||||
<div>
|
||||
<label className="label-xs">Start</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
className="input w-full min-w-[14ch] px-3"
|
||||
value={imp.start_date}
|
||||
onChange={e=>updateImpact(m.id,idx,'start_date',e.target.value)}
|
||||
/>
|
||||
@ -334,7 +364,7 @@ export default function MilestoneEditModal({
|
||||
<label className="label-xs">End</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
className="input w-full min-w-[14ch] px-3"
|
||||
value={imp.end_date}
|
||||
onChange={e=>updateImpact(m.id,idx,'end_date',e.target.value)}
|
||||
/>
|
||||
@ -345,7 +375,7 @@ export default function MilestoneEditModal({
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-red-600"
|
||||
className="text-red-600 w-10 h-9 flex items-center justify-center"
|
||||
onClick={()=>removeImpactRow(m.id,idx)}
|
||||
>
|
||||
✕
|
||||
@ -401,7 +431,7 @@ export default function MilestoneEditModal({
|
||||
<label className="label-xs">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
className="input w-full min-w-[12ch] px-3"
|
||||
value={newMilestone.date}
|
||||
onChange={e=>setNewMilestone(n=>({...n,date:e.target.value}))}
|
||||
/>
|
||||
@ -420,69 +450,93 @@ export default function MilestoneEditModal({
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm">Financial impacts</h4>
|
||||
<Button size="xs" onClick={addBlankImpactToNew}>+ Add impact</Button>
|
||||
<Button size="xs" className="shrink-0" onClick={addBlankImpactToNew}>+ Add impact</Button>
|
||||
</div>
|
||||
<div className="space-y-3 mt-2">
|
||||
{newMilestone.impacts.map((imp,idx)=>(
|
||||
<div key={idx} className="grid md:grid-cols-[150px_120px_1fr_auto] gap-2 items-end">
|
||||
{newMilestone.impacts.map((imp, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="grid gap-2 items-end min-w-0 grid-cols-[140px_110px_100px_minmax(220px,1fr)_44px]"
|
||||
>
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="label-xs">Type</label>
|
||||
<select
|
||||
className="input"
|
||||
value={imp.impact_type}
|
||||
onChange={e=>updateNewImpact(idx,'impact_type',e.target.value)}>
|
||||
onChange={(e) => updateNewImpact(idx, 'impact_type', e.target.value)}
|
||||
>
|
||||
<option value="salary">Salary (annual)</option>
|
||||
<option value="ONE_TIME">One‑time</option>
|
||||
<option value="ONE_TIME">One-time</option>
|
||||
<option value="MONTHLY">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
{imp.impact_type!=='salary' && (
|
||||
|
||||
{/* Direction (spacer when salary) */}
|
||||
{imp.impact_type !== 'salary' ? (
|
||||
<div>
|
||||
<label className="label-xs">Direction</label>
|
||||
<select
|
||||
className="input"
|
||||
value={imp.direction}
|
||||
onChange={e=>updateNewImpact(idx,'direction',e.target.value)}>
|
||||
onChange={(e) => updateNewImpact(idx, 'direction', e.target.value)}
|
||||
>
|
||||
<option value="add">Add</option>
|
||||
<option value="subtract">Subtract</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:block" />
|
||||
)}
|
||||
<div>
|
||||
|
||||
{/* Amount (fixed width) */}
|
||||
<div className="md:w-[100px]">
|
||||
<label className="label-xs">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
value={imp.amount}
|
||||
onChange={e=>updateNewImpact(idx,'amount',e.target.value)}
|
||||
onChange={(e) => updateNewImpact(idx, 'amount', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={imp.start_date}
|
||||
onChange={e=>updateNewImpact(idx,'start_date',e.target.value)}
|
||||
/>
|
||||
{imp.impact_type==='MONTHLY' && (
|
||||
|
||||
{/* Dates (flex) */}
|
||||
<div className="grid grid-cols-2 gap-2 min-w-0">
|
||||
<div>
|
||||
<label className="label-xs">Start</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={imp.end_date}
|
||||
onChange={e=>updateNewImpact(idx,'end_date',e.target.value)}
|
||||
className="input w-full min-w-[14ch] px-3"
|
||||
value={imp.start_date}
|
||||
onChange={(e) => updateNewImpact(idx, 'start_date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{imp.impact_type === 'MONTHLY' && (
|
||||
<div>
|
||||
<label className="label-xs">End</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input w-full min-w-[14ch] px-3"
|
||||
value={imp.end_date}
|
||||
onChange={(e) => updateNewImpact(idx, 'end_date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remove */}
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-red-600"
|
||||
onClick={()=>removeNewImpact(idx)}
|
||||
className="text-red-600 w-11 h-9 flex items-center justify-center"
|
||||
onClick={() => removeNewImpact(idx)}
|
||||
aria-label="Remove impact"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* save row */}
|
||||
|
@ -79,9 +79,7 @@ function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
const nextLabel = skipFin
|
||||
? inCollege ? 'College →' : 'Finish →'
|
||||
: inCollege ? 'College →' : 'Financial →';
|
||||
const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Finish →');
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6 space-y-4">
|
||||
@ -185,9 +183,9 @@ function handleSubmit() {
|
||||
<button
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded mr-2"
|
||||
onClick={() => {
|
||||
/* open your ScenarioEditModal or a mini financial modal */
|
||||
// user explicitly wants to enter financials now
|
||||
setData(prev => ({ ...prev, skipFinancialStep: false }));
|
||||
setShowFinPrompt(false);
|
||||
/* maybe set a flag so CareerRoadmap opens the modal */
|
||||
}}
|
||||
>
|
||||
Add Financial Details
|
||||
|
@ -20,18 +20,24 @@ const [selectedUnitId, setSelectedUnitId] = useState(null);
|
||||
const [expectedGraduation, setExpectedGraduation] = useState(data.expected_graduation || '');
|
||||
const [showAidWizard, setShowAidWizard] = useState(false);
|
||||
const location = useLocation();
|
||||
const navSelectedSchoolRaw = location.state?.selectedSchool;
|
||||
const navSelectedSchool = toSchoolName(navSelectedSchoolRaw);
|
||||
const navSelectedSchoolObj =
|
||||
location.state?.selectedSchool ??
|
||||
location.state?.premiumOnboardingState?.selectedSchool;
|
||||
|
||||
function dehydrate(schObj) {
|
||||
if (!schObj || typeof schObj !== 'object') return null;
|
||||
const { INSTNM, CIPDESC, CREDDESC, ...rest } = schObj;
|
||||
return { INSTNM, CIPDESC, CREDDESC, ...rest };
|
||||
}
|
||||
const [selectedSchool, setSelectedSchool] = useState(() => {
|
||||
if (navSelectedSchoolObj && typeof navSelectedSchoolObj === 'object') {
|
||||
return { INSTNM: navSelectedSchoolObj.INSTNM,
|
||||
CIPDESC: navSelectedSchoolObj.CIPDESC || '',
|
||||
CREDDESC: navSelectedSchoolObj.CREDDESC || '' };
|
||||
}
|
||||
if (data.selected_school) {
|
||||
return { INSTNM: data.selected_school,
|
||||
CIPDESC: data.selected_program || '',
|
||||
CREDDESC: data.program_type || '' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const [selectedSchool, setSelectedSchool] = useState(() =>
|
||||
dehydrate(navSelectedSchool) || (data.selected_school ? { INSTNM: data.selected_school } : null)
|
||||
);
|
||||
|
||||
function toSchoolName(objOrStr) {
|
||||
if (!objOrStr) return '';
|
||||
|
@ -15,8 +15,8 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
retirement_contribution = 0,
|
||||
emergency_fund = 0,
|
||||
emergency_contribution = 0,
|
||||
extra_cash_emergency_pct = "",
|
||||
extra_cash_retirement_pct = "",
|
||||
extra_cash_emergency_pct = 50,
|
||||
extra_cash_retirement_pct = 50,
|
||||
} = data;
|
||||
|
||||
const [showExpensesWizard, setShowExpensesWizard] = useState(false);
|
||||
|
@ -86,9 +86,16 @@ export default function OnboardingContainer() {
|
||||
}
|
||||
|
||||
// D) pick up any navigation state (e.g., selectedSchool)
|
||||
const navSchool = location.state?.selectedSchool;
|
||||
const navSchool =
|
||||
location.state?.selectedSchool ??
|
||||
location.state?.premiumOnboardingState?.selectedSchool;
|
||||
if (navSchool) {
|
||||
setCollegeData(cd => ({ ...cd, selected_school: navSchool.INSTNM || navSchool }));
|
||||
setCollegeData(cd => ({
|
||||
...cd,
|
||||
selected_school : typeof navSchool === 'string' ? navSchool : (navSchool.INSTNM || ''),
|
||||
selected_program: typeof navSchool === 'object' ? (navSchool.CIPDESC || cd.selected_program || '') : cd.selected_program,
|
||||
program_type : typeof navSchool === 'object' ? (navSchool.CREDDESC || cd.program_type || '') : cd.program_type,
|
||||
}));
|
||||
}
|
||||
|
||||
setLoaded(true);
|
||||
|
@ -1,14 +1,9 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||||
import { Button } from './ui/button.js';
|
||||
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
|
||||
import InfoTooltip from "./ui/infoTooltip.js";
|
||||
|
||||
// Data paths
|
||||
const CIP_URL = '/api/data/cip-institution-map';
|
||||
const IPEDS_URL = '/api/data/ic2023';
|
||||
const CAREER_CLUSTERS_URL = '/api/data/career_clusters.json';
|
||||
import CareerSearch from './CareerSearch.js';
|
||||
import api from '../auth/apiClient.js'
|
||||
|
||||
export default function ScenarioEditModal({
|
||||
show,
|
||||
@ -17,11 +12,6 @@ export default function ScenarioEditModal({
|
||||
collegeProfile,
|
||||
financialProfile
|
||||
}) {
|
||||
/*********************************************************
|
||||
* 1) CIP / IPEDS data states
|
||||
*********************************************************/
|
||||
const [schoolData, setSchoolData] = useState([]);
|
||||
const [icTuitionData, setIcTuitionData] = useState([]);
|
||||
|
||||
/*********************************************************
|
||||
* 2) Suggestions & program types
|
||||
@ -29,6 +19,7 @@ export default function ScenarioEditModal({
|
||||
const [schoolSuggestions, setSchoolSuggestions] = useState([]);
|
||||
const [programSuggestions, setProgramSuggestions] = useState([]);
|
||||
const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
|
||||
const [selectedUnitId, setSelectedUnitId] = useState(null);
|
||||
|
||||
/*********************************************************
|
||||
* 3) Manual vs auto for tuition & program length
|
||||
@ -38,14 +29,6 @@ export default function ScenarioEditModal({
|
||||
const [manualProgLength, setManualProgLength] = useState('');
|
||||
const [autoProgLength, setAutoProgLength] = useState('0.00');
|
||||
|
||||
/*********************************************************
|
||||
* 4) Career auto-suggest
|
||||
*********************************************************/
|
||||
const [allCareers, setAllCareers] = useState([]);
|
||||
const [careerSearchInput, setCareerSearchInput] = useState('');
|
||||
const [careerMatches, setCareerMatches] = useState([]);
|
||||
const careerDropdownRef = useRef(null);
|
||||
|
||||
/*********************************************************
|
||||
* 5) Combined formData => scenario + college
|
||||
*********************************************************/
|
||||
@ -65,81 +48,52 @@ export default function ScenarioEditModal({
|
||||
*********************************************************/
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
setShowCollegeForm(
|
||||
['currently_enrolled', 'prospective_student']
|
||||
.includes(formData.college_enrollment_status)
|
||||
);
|
||||
const hasCollegeData =
|
||||
!!formData.selected_school ||
|
||||
!!formData.selected_program ||
|
||||
!!formData.program_type ||
|
||||
!!formData.expected_graduation ||
|
||||
!!formData.enrollment_date ||
|
||||
Number(formData.tuition) > 0 ||
|
||||
Number(formData.annual_financial_aid) > 0 ||
|
||||
Number(formData.existing_college_debt) > 0;
|
||||
|
||||
const enrolledOrProspective = ['currently_enrolled','prospective_student']
|
||||
.includes((formData.college_enrollment_status || '').toLowerCase());
|
||||
|
||||
setShowCollegeForm(enrolledOrProspective || hasCollegeData);
|
||||
}, [show]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!selectedUnitId || !formData.program_type || !formData.credit_hours_per_year) return;
|
||||
try {
|
||||
const { data } = await api.get('/api/tuition/estimate', {
|
||||
params: {
|
||||
unitId: String(selectedUnitId),
|
||||
programType: formData.program_type,
|
||||
inState: formData.is_in_state ? 1 : 0,
|
||||
inDistrict: formData.is_in_district ? 1 : 0,
|
||||
creditHoursPerYear: Number(formData.credit_hours_per_year) || 0
|
||||
}
|
||||
});
|
||||
setAutoTuition(Number.isFinite(data?.estimate) ? data.estimate : 0);
|
||||
} catch {
|
||||
setAutoTuition(0);
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
selectedUnitId,
|
||||
formData.program_type,
|
||||
formData.credit_hours_per_year,
|
||||
formData.is_in_state,
|
||||
formData.is_in_district
|
||||
]);
|
||||
|
||||
/*********************************************************
|
||||
* 6) On show => load CIP, IPEDS, CAREERS
|
||||
*********************************************************/
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
|
||||
const loadCIP = async () => {
|
||||
try {
|
||||
const res = await fetch(CIP_URL);
|
||||
const text = await res.text();
|
||||
const lines = text.split('\n');
|
||||
const parsed = lines
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
setSchoolData(parsed);
|
||||
} catch (err) {
|
||||
console.error('Failed loading CIP data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadIPEDS = async () => {
|
||||
try {
|
||||
const res = await fetch(IPEDS_URL);
|
||||
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 loading IPEDS data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCareers = async () => {
|
||||
try {
|
||||
const resp = await fetch(CAREER_CLUSTERS_URL);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed career_clusters fetch: ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
const titlesSet = new Set();
|
||||
for (const cluster of Object.keys(data)) {
|
||||
for (const sub of Object.keys(data[cluster])) {
|
||||
const arr = data[cluster][sub];
|
||||
if (Array.isArray(arr)) {
|
||||
arr.forEach((cObj) => {
|
||||
if (cObj?.title) titlesSet.add(cObj.title);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
setAllCareers([...titlesSet]);
|
||||
} catch (err) {
|
||||
console.error('Failed loading career_clusters:', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadCIP();
|
||||
loadIPEDS();
|
||||
loadCareers();
|
||||
}, [show]);
|
||||
|
||||
/*********************************************************
|
||||
* 7) Whenever the **modal is shown** *or* **scenario.id changes**
|
||||
@ -153,6 +107,12 @@ export default function ScenarioEditModal({
|
||||
const safe = v =>
|
||||
v === null || v === undefined ? '' : v;
|
||||
|
||||
const hasCollegeData =
|
||||
!!c.selected_school || !!c.selected_program || !!c.program_type ||
|
||||
!!c.expected_graduation || !!c.enrollment_date ||
|
||||
(Number(c.tuition) || 0) > 0 ||
|
||||
(Number(c.annual_financial_aid) || 0) > 0 ||
|
||||
(Number(c.existing_college_debt) || 0) > 0;
|
||||
setFormData({
|
||||
// scenario portion
|
||||
scenario_title : safe(s.scenario_title),
|
||||
@ -182,7 +142,10 @@ export default function ScenarioEditModal({
|
||||
is_in_state: safe(!!c.is_in_state),
|
||||
is_in_district: safe(!!c.is_in_district),
|
||||
is_online: safe(!!c.is_online),
|
||||
college_enrollment_status: safe(c.college_enrollment_status || 'not_enrolled'),
|
||||
college_enrollment_status: safe(
|
||||
c.college_enrollment_status
|
||||
|| (hasCollegeData ? 'prospective_student' : 'not_enrolled')
|
||||
),
|
||||
|
||||
annual_financial_aid : safe(c.annual_financial_aid),
|
||||
existing_college_debt : safe(c.existing_college_debt),
|
||||
@ -219,89 +182,35 @@ export default function ScenarioEditModal({
|
||||
setManualProgLength('');
|
||||
}
|
||||
|
||||
setCareerSearchInput(s.career_name || '');
|
||||
}, [show, scenario?.id, collegeProfile]);
|
||||
|
||||
/*********************************************************
|
||||
* 8) Auto-calc placeholders (stubbed out)
|
||||
*********************************************************/
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
// IPEDS-based logic or other auto-calculation
|
||||
}, [
|
||||
show,
|
||||
formData.selected_school,
|
||||
formData.program_type,
|
||||
formData.credit_hours_per_year,
|
||||
formData.is_in_district,
|
||||
formData.is_in_state,
|
||||
schoolData,
|
||||
icTuitionData
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
// Possibly recalc program length
|
||||
}, [
|
||||
show,
|
||||
formData.program_type,
|
||||
formData.hours_completed,
|
||||
formData.credit_hours_per_year,
|
||||
formData.credit_hours_required
|
||||
]);
|
||||
|
||||
/*********************************************************
|
||||
* 9) Career auto-suggest
|
||||
*********************************************************/
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
|
||||
// 1️⃣ trim once, reuse everywhere
|
||||
const typed = careerSearchInput.trim();
|
||||
|
||||
// Nothing typed → clear list
|
||||
if (!typed) {
|
||||
setCareerMatches([]);
|
||||
return;
|
||||
}
|
||||
|
||||
/* 2️⃣ Exact match (case-insensitive) → suppress dropdown */
|
||||
if (allCareers.some(t => t.toLowerCase() === typed.toLowerCase())) {
|
||||
setCareerMatches([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3️⃣ Otherwise show up to 15 partial matches
|
||||
const lower = typed.toLowerCase();
|
||||
const partials = allCareers
|
||||
.filter(title => title.toLowerCase().includes(lower))
|
||||
.slice(0, 15);
|
||||
|
||||
setCareerMatches(partials);
|
||||
}, [show, careerSearchInput, allCareers]);
|
||||
|
||||
|
||||
/*********************************************************
|
||||
* 9.5) Program Type from CIP
|
||||
*********************************************************/
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
if (!formData.selected_school || !formData.selected_program) {
|
||||
if (!show) return;
|
||||
if (!formData.selected_school || !formData.selected_program) {
|
||||
setAvailableProgramTypes([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/programs/types', {
|
||||
params: {
|
||||
school: formData.selected_school,
|
||||
program: formData.selected_program
|
||||
}
|
||||
});
|
||||
setAvailableProgramTypes(Array.isArray(data?.types) ? data.types : []);
|
||||
} catch {
|
||||
setAvailableProgramTypes([]);
|
||||
return;
|
||||
}
|
||||
const filtered = schoolData.filter(
|
||||
(row) =>
|
||||
row.INSTNM.toLowerCase() === formData.selected_school.toLowerCase() &&
|
||||
row.CIPDESC === formData.selected_program
|
||||
);
|
||||
const possibleTypes = [...new Set(filtered.map((r) => r.CREDDESC))];
|
||||
setAvailableProgramTypes(possibleTypes);
|
||||
})();
|
||||
}, [
|
||||
show,
|
||||
formData.selected_school,
|
||||
formData.selected_program,
|
||||
schoolData
|
||||
]);
|
||||
|
||||
/*********************************************************
|
||||
@ -314,67 +223,72 @@ useEffect(() => {
|
||||
setFormData((prev) => ({ ...prev, [name]: val }));
|
||||
}
|
||||
|
||||
function handleCareerInputChange(e) {
|
||||
const val = e.target.value;
|
||||
setCareerSearchInput(val);
|
||||
if (allCareers.includes(val)) {
|
||||
setFormData((prev) => ({ ...prev, career_name: val }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectCareer(title) {
|
||||
setCareerSearchInput(title);
|
||||
setFormData((prev) => ({ ...prev, career_name: title }));
|
||||
setCareerMatches([]);
|
||||
}
|
||||
|
||||
function handleSchoolChange(e) {
|
||||
const val = e.target.value;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
selected_school: val,
|
||||
selected_program: '',
|
||||
program_type: '',
|
||||
credit_hours_required: ''
|
||||
}));
|
||||
if (!val) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selected_school: val,
|
||||
selected_program: '',
|
||||
program_type: '',
|
||||
credit_hours_required: ''
|
||||
}));
|
||||
if (!val.trim()) {
|
||||
setSchoolSuggestions([]);
|
||||
setProgramSuggestions([]);
|
||||
setAvailableProgramTypes([]);
|
||||
setSelectedUnitId(null);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/schools/suggest', {
|
||||
params: { query: val, limit: 10 }
|
||||
});
|
||||
const opts = Array.isArray(data) ? data : [];
|
||||
setSchoolSuggestions(opts); // keep full objects: { name, unitId }
|
||||
// if the user typed an exact school name, commit now (sets UNITID)
|
||||
const exact = opts.find(o => (o.name || '').toLowerCase() === val.toLowerCase());
|
||||
if (exact) handleSchoolSelect(exact);
|
||||
} catch {
|
||||
setSchoolSuggestions([]);
|
||||
setProgramSuggestions([]);
|
||||
setAvailableProgramTypes([]);
|
||||
return;
|
||||
}
|
||||
const filtered = schoolData.filter((s) =>
|
||||
s.INSTNM.toLowerCase().includes(val.toLowerCase())
|
||||
);
|
||||
const unique = [...new Set(filtered.map((s) => s.INSTNM))];
|
||||
setSchoolSuggestions(unique.slice(0, 10));
|
||||
})();
|
||||
}
|
||||
|
||||
function handleSchoolSelect(sch) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
selected_school: sch,
|
||||
selected_program: '',
|
||||
program_type: '',
|
||||
credit_hours_required: ''
|
||||
}));
|
||||
setSchoolSuggestions([]);
|
||||
}
|
||||
const name = sch?.name || sch || '';
|
||||
const uid = sch?.unitId ?? null;
|
||||
setSelectedUnitId(uid);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selected_school: name,
|
||||
selected_program: '',
|
||||
program_type: '',
|
||||
credit_hours_required: ''
|
||||
}));
|
||||
setSchoolSuggestions([]);
|
||||
setProgramSuggestions([]);
|
||||
setAvailableProgramTypes([]);
|
||||
}
|
||||
|
||||
function handleProgramChange(e) {
|
||||
const val = e.target.value;
|
||||
setFormData((prev) => ({ ...prev, selected_program: val }));
|
||||
if (!val) {
|
||||
if (!val || !formData.selected_school) {
|
||||
setProgramSuggestions([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/programs/suggest', {
|
||||
params: { school: formData.selected_school, query: val, limit: 10 }
|
||||
});
|
||||
const list = Array.isArray(data) ? data : []; // [{ program }]
|
||||
setProgramSuggestions(list.map(p => p.program));
|
||||
} catch {
|
||||
setProgramSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const filtered = schoolData.filter(
|
||||
(row) =>
|
||||
row.INSTNM.toLowerCase() === formData.selected_school.toLowerCase() &&
|
||||
row.CIPDESC.toLowerCase().includes(val.toLowerCase())
|
||||
);
|
||||
const unique = [...new Set(filtered.map((r) => r.CIPDESC))];
|
||||
setProgramSuggestions(unique.slice(0, 10));
|
||||
})();
|
||||
}
|
||||
|
||||
function handleProgramSelect(prog) {
|
||||
@ -422,10 +336,9 @@ useEffect(() => {
|
||||
scenarioRow.planned_monthly_debt_payments ??
|
||||
financialData.monthly_debt_payments ??
|
||||
0,
|
||||
partTimeIncome:
|
||||
additionalIncome:
|
||||
scenarioRow.planned_additional_income ??
|
||||
financialData.additional_income ??
|
||||
0,
|
||||
financialData.additional_income ?? 0,
|
||||
|
||||
emergencySavings: financialData.emergency_fund ?? 0,
|
||||
retirementSavings: financialData.retirement_savings ?? 0,
|
||||
@ -618,40 +531,17 @@ if (formData.retirement_start_date) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Career Search */}
|
||||
<div className="mb-4 relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Career Search
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={careerSearchInput}
|
||||
onChange={handleCareerInputChange}
|
||||
className="border border-gray-300 rounded p-2 w-full focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
{careerMatches.length > 0 && (
|
||||
<ul
|
||||
ref={careerDropdownRef}
|
||||
className="
|
||||
absolute top-full left-0 w-full border border-gray-200 bg-white z-10
|
||||
max-h-48 overflow-auto mt-1
|
||||
"
|
||||
>
|
||||
{careerMatches.map((c, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="p-2 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSelectCareer(c)}
|
||||
>
|
||||
{c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<em>Current Career:</em> {formData.career_name || '(none)'}
|
||||
</p>
|
||||
</div>
|
||||
{/* Career Search (shared component) */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Career Search</label>
|
||||
<CareerSearch onCareerSelected={(found) => {
|
||||
if (!found) return;
|
||||
setFormData(prev => ({ ...prev, career_name: found.title || prev.career_name || '' }));
|
||||
}}/>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<em>Selected Career:</em> {formData.career_name || '(none)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mb-4">
|
||||
@ -937,13 +827,10 @@ if (formData.retirement_start_date) {
|
||||
max-h-48 overflow-auto mt-1
|
||||
"
|
||||
>
|
||||
{schoolSuggestions.map((sch, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="p-2 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSchoolSelect(sch)}
|
||||
>
|
||||
{sch}
|
||||
{schoolSuggestions.map((sch, i) => (
|
||||
<li key={i} className="p-2 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSchoolSelect(sch)}>
|
||||
{sch.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -1216,11 +1103,6 @@ if (formData.retirement_start_date) {
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
{!formData.retirement_start_date && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
Pick a Planned Retirement Date to run the simulation.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show a preview if we have simulation data */}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from './ui/button.js';
|
||||
import SituationCard from './ui/SituationCard.js';
|
||||
@ -54,11 +54,16 @@ function SignUp() {
|
||||
const [zipcode, setZipcode] = useState('');
|
||||
const [state, setState] = useState('');
|
||||
const [area, setArea] = useState('');
|
||||
const [areas, setAreas] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [loadingAreas, setLoadingAreas] = useState(false);
|
||||
const [phone, setPhone] = useState('+1');
|
||||
const [optIn, setOptIn] = useState(false);
|
||||
const [areas, setAreas] = useState([]);
|
||||
const [areasErr, setAreasErr] = useState('');
|
||||
const areasCacheRef = useRef(new Map()); // cache: stateCode -> areas[]
|
||||
const debounceRef = useRef(null); // debounce timer
|
||||
const inflightRef = useRef(null); // AbortController for in-flight
|
||||
|
||||
|
||||
const [showCareerSituations, setShowCareerSituations] = useState(false);
|
||||
const [selectedSituation, setSelectedSituation] = useState(null);
|
||||
@ -235,6 +240,60 @@ const handleSituationConfirm = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// reset UI
|
||||
setAreasErr('');
|
||||
if (!state) { setAreas([]); return; }
|
||||
|
||||
// cached? instant
|
||||
if (areasCacheRef.current.has(state)) {
|
||||
setAreas(areasCacheRef.current.get(state));
|
||||
return;
|
||||
}
|
||||
|
||||
// debounce to avoid rapid refetch on quick clicks
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
// cancel previous request if any
|
||||
if (inflightRef.current) inflightRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
inflightRef.current = controller;
|
||||
|
||||
setLoadingAreas(true);
|
||||
try {
|
||||
// client-side timeout race (6s)
|
||||
const timeout = new Promise((_, rej) =>
|
||||
setTimeout(() => rej(new Error('timeout')), 6000)
|
||||
);
|
||||
|
||||
const res = await Promise.race([
|
||||
fetch(`/api/areas?state=${encodeURIComponent(state)}`, {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
timeout,
|
||||
]);
|
||||
|
||||
if (!res || !res.ok) throw new Error('bad_response');
|
||||
const data = await res.json();
|
||||
|
||||
// normalize, uniq, sort for UX
|
||||
const list = Array.from(new Set((data.areas || []).filter(Boolean))).sort();
|
||||
areasCacheRef.current.set(state, list); // cache it
|
||||
setAreas(list);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return; // superseded by a newer request
|
||||
setAreas([]);
|
||||
setAreasErr('Could not load Areas. You can proceed without selecting one.');
|
||||
} finally {
|
||||
if (inflightRef.current === controller) inflightRef.current = null;
|
||||
setLoadingAreas(false);
|
||||
}
|
||||
}, 250); // 250ms debounce
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-4">
|
||||
@ -341,35 +400,37 @@ return (
|
||||
</select>
|
||||
|
||||
<div className="relative w-full">
|
||||
<label>
|
||||
<span
|
||||
title="Selecting an Area will allow us to provide regional salary data for your career choices."
|
||||
className="absolute top-2 right-2 translate-x-7 -translate-y-3
|
||||
inline-flex h-4 w-4 items-center justify-center
|
||||
rounded-full bg-blue-500 text-xs font-bold text-white cursor-help"
|
||||
<label>
|
||||
<span
|
||||
title="Selecting an Area will allow us to provide regional salary data for your career choices."
|
||||
className="absolute top-2 right-2 translate-x-7 -translate-y-3 inline-flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-xs font-bold text-white cursor-help"
|
||||
>
|
||||
i
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
id="area"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
value={area}
|
||||
onChange={(e) => setArea(e.target.value)}
|
||||
disabled={loadingAreas}
|
||||
>
|
||||
i
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
id="area"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
value={area}
|
||||
onChange={(e) => setArea(e.target.value)}
|
||||
disabled={loadingAreas}
|
||||
>
|
||||
<option value="">Select Area</option>
|
||||
{areas.map((a, i) => (
|
||||
<option key={i} value={a}>
|
||||
{a}
|
||||
<option value="">
|
||||
{loadingAreas ? 'Loading Areas...' : 'Select Area (optional)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{loadingAreas && (
|
||||
<span className="absolute right-3 top-2.5 text-gray-400 text-sm animate-pulse">
|
||||
Loading...
|
||||
</span>
|
||||
)}
|
||||
{areas.map((a, i) => (
|
||||
<option key={i} value={a}>{a}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{loadingAreas && (
|
||||
<span className="absolute right-3 top-2.5 text-gray-400 text-sm animate-pulse">
|
||||
Loading...
|
||||
</span>
|
||||
)}
|
||||
{areasErr && (
|
||||
<p className="mt-1 text-xs text-amber-600">{areasErr}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
|
@ -16,6 +16,8 @@ const n = (val, fallback = 0) => {
|
||||
const num = (v) =>
|
||||
v === null || v === undefined || v === '' || Number.isNaN(+v) ? 0 : +v;
|
||||
|
||||
const toDateOnly = (v) => (v && typeof v === 'string' ? v.slice(0, 10) : v);
|
||||
|
||||
/***************************************************
|
||||
* HELPER: Approx State Tax Rates
|
||||
***************************************************/
|
||||
@ -278,22 +280,32 @@ function simulateDrawdown(opts){
|
||||
// else Bachelor's = 120
|
||||
}
|
||||
const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted);
|
||||
const dynamicProgramLength = Math.ceil(
|
||||
remainingCreditHours / (creditHoursPerYear || 30)
|
||||
);
|
||||
const finalProgramLength = programLength || dynamicProgramLength;
|
||||
// If we must derive a length (years), round to the nearest month (not always up)
|
||||
const derivedYears = (creditHoursPerYear || 30) > 0
|
||||
? Math.ceil((remainingCreditHours / (creditHoursPerYear || 30)) * 12) / 12
|
||||
: 0;
|
||||
const finalProgramLength = programLength || derivedYears;
|
||||
|
||||
const enrollmentStart = enrollmentDate
|
||||
? moment(enrollmentDate).startOf('month')
|
||||
: (startDate ? moment(startDate).startOf('month') : scenarioStartClamped.clone());
|
||||
// Use the explicit enrollmentDate if provided; otherwise fall back to scenario.start_date
|
||||
const enrollmentStart = enrollmentDate
|
||||
? moment(toDateOnly(enrollmentDate), 'YYYY-MM-DD', true).startOf('month')
|
||||
: (startDate ? moment(startDate).startOf('month') : null);
|
||||
|
||||
const creditsPerYear = creditHoursPerYear || 30;
|
||||
const creditsRemaining = Math.max(0, requiredCreditHours - hoursCompleted);
|
||||
const monthsRemaining = Math.ceil((creditsRemaining / Math.max(1, creditsPerYear)) * 12);
|
||||
|
||||
const gradDateEffective = gradDate
|
||||
? moment(gradDate).startOf('month')
|
||||
: enrollmentStart.clone().add(monthsRemaining, 'months');
|
||||
// Graduation date: trust explicit, else derive from enrollment+credits,
|
||||
// else (as a last resort) from enrollment + programLength (years)
|
||||
const derivedGradFromCredits =
|
||||
enrollmentStart ? enrollmentStart.clone().add(monthsRemaining, 'months') : null;
|
||||
const derivedGradFromProgramLen =
|
||||
enrollmentStart && finalProgramLength > 0
|
||||
? enrollmentStart.clone().add(Math.ceil(finalProgramLength * 12), 'months')
|
||||
: null;
|
||||
const gradDateEffective = gradDate
|
||||
? moment(toDateOnly(gradDate), 'YYYY-MM-DD', true).startOf('month')
|
||||
: (derivedGradFromCredits || derivedGradFromProgramLen || null);
|
||||
|
||||
/***************************************************
|
||||
* 4) TUITION CALC
|
||||
@ -321,7 +333,7 @@ function simulateDrawdown(opts){
|
||||
lumpsSchedule = [...Array(12).keys()];
|
||||
break;
|
||||
}
|
||||
const totalAcademicMonths = finalProgramLength * 12;
|
||||
|
||||
const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength);
|
||||
|
||||
/***************************************************
|
||||
@ -339,9 +351,11 @@ function simulateDrawdown(opts){
|
||||
const maxMonths = simulationYears * 12;
|
||||
let loanBalance = initialLoanPrincipal;
|
||||
let loanPaidOffMonth = null;
|
||||
let prevLoanBalance = loanBalance;
|
||||
|
||||
let currentEmergencySavings = emergencySavings;
|
||||
let currentRetirementSavings = retirementSavings;
|
||||
let cashBalance = 0;
|
||||
|
||||
let projectionData = [];
|
||||
|
||||
@ -354,14 +368,6 @@ function simulateDrawdown(opts){
|
||||
// We'll keep track that we started in deferral if inCollege & deferral is true
|
||||
let wasInDeferral = inCollege && loanDeferralUntilGraduation;
|
||||
|
||||
// If there's a gradDate, let's see if we pass it:
|
||||
const graduationDateObj = gradDate ? moment(gradDate).startOf('month') : null;
|
||||
|
||||
const enrollmentDateObj = enrollmentDate
|
||||
? moment(enrollmentDate).startOf('month')
|
||||
: scenarioStartClamped.clone(); // fallback
|
||||
|
||||
|
||||
/***************************************************
|
||||
* 7) THE MONTHLY LOOP
|
||||
***************************************************/
|
||||
@ -369,6 +375,8 @@ function simulateDrawdown(opts){
|
||||
for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
|
||||
const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months');
|
||||
|
||||
prevLoanBalance = loanBalance;
|
||||
|
||||
if (!reachedRetirement && currentSimDate.isSameOrAfter(retirementStartISO)) {
|
||||
reachedRetirement = true;
|
||||
firstRetirementBalance = currentRetirementSavings; // capture once
|
||||
@ -378,19 +386,24 @@ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
|
||||
retirementSpendMonthly > 0 &&
|
||||
currentSimDate.isSameOrAfter(retirementStartISO)
|
||||
|
||||
// figure out if we are in the college window
|
||||
const stillInCollege =
|
||||
inCollege &&
|
||||
currentSimDate.isSameOrAfter(enrollmentStart) &&
|
||||
currentSimDate.isBefore(gradDateEffective);
|
||||
// College window is inclusive of enrollment month and exclusive of the graduation month
|
||||
const stillInCollege =
|
||||
inCollege &&
|
||||
!!enrollmentStart &&
|
||||
(!!gradDateEffective
|
||||
? currentSimDate.isSameOrAfter(enrollmentStart) &&
|
||||
currentSimDate.isBefore(gradDateEffective)
|
||||
: currentSimDate.isSameOrAfter(enrollmentStart));
|
||||
|
||||
const hasGraduated = currentSimDate.isSameOrAfter(gradDateEffective.clone().add(1, 'month'));
|
||||
// Flip to expected salary starting in the graduation month when we know it
|
||||
const hasGraduated =
|
||||
!!gradDateEffective && currentSimDate.isSameOrAfter(gradDateEffective);
|
||||
|
||||
/************************************************
|
||||
* 7.1 TUITION lumps
|
||||
************************************************/
|
||||
let tuitionCostThisMonth = 0;
|
||||
if (stillInCollege && lumpsPerYear > 0) {
|
||||
if (stillInCollege && lumpsPerYear > 0 && enrollmentStart && enrollmentStart.isValid()) {
|
||||
const monthsSinceEnroll = Math.max(0, currentSimDate.diff(enrollmentStart, 'months'));
|
||||
const academicYearIndex = Math.floor(monthsSinceEnroll / 12);
|
||||
const monthInAcadYear = monthsSinceEnroll % 12;
|
||||
@ -416,8 +429,8 @@ if (stillInCollege && lumpsPerYear > 0) {
|
||||
currentRetirementSavings -= withdrawal;
|
||||
baseMonthlyIncome += withdrawal;
|
||||
} else if (!stillInCollege) {
|
||||
const monthlyFromJob = (hasGraduated && expectedSalary > 0 ? expectedSalary : currentSalary) / 12;
|
||||
baseMonthlyIncome = monthlyFromJob + (additionalIncome / 12);
|
||||
const annual = hasGraduated && expectedSalary > 0 ? expectedSalary : currentSalary;
|
||||
baseMonthlyIncome = (annual / 12) + (additionalIncome / 12);
|
||||
} else {
|
||||
baseMonthlyIncome = (currentSalary / 12) + (additionalIncome / 12);
|
||||
}
|
||||
@ -516,9 +529,10 @@ if (loanDeferralUntilGraduation && wasInDeferral && !stillInCollege) {
|
||||
}
|
||||
}
|
||||
|
||||
if (loanBalance <= 0 && !loanPaidOffMonth) {
|
||||
loanPaidOffMonth = currentSimDate.format('YYYY-MM');
|
||||
}
|
||||
if (!loanPaidOffMonth && prevLoanBalance > 0 && loanBalance <= 0) {
|
||||
loanPaidOffMonth = currentSimDate.format('YYYY-MM');
|
||||
}
|
||||
|
||||
|
||||
|
||||
let leftover = netMonthlyIncome - totalMonthlyExpenses;
|
||||
@ -552,6 +566,12 @@ if (loanDeferralUntilGraduation && wasInDeferral && !stillInCollege) {
|
||||
shortfall -= canCover;
|
||||
}
|
||||
|
||||
// any remaining shortfall becomes negative cash (credit/overdraft)
|
||||
if (shortfall > 0) {
|
||||
cashBalance -= shortfall;
|
||||
shortfall = 0;
|
||||
}
|
||||
|
||||
// Surplus => leftover
|
||||
if (canSaveThisMonth && leftover > 0) {
|
||||
const totalPct = surplusEmergencyAllocation + surplusRetirementAllocation;
|
||||
@ -598,7 +618,8 @@ if (loanDeferralUntilGraduation && wasInDeferral && !stillInCollege) {
|
||||
loanBalance: +loanBalance.toFixed(2),
|
||||
|
||||
loanPaymentThisMonth: +(monthlyLoanPayment + extraPayment).toFixed(2),
|
||||
totalSavings: (currentEmergencySavings + currentRetirementSavings).toFixed(2),
|
||||
cashBalance: +cashBalance.toFixed(2),
|
||||
totalSavings: +(currentEmergencySavings + currentRetirementSavings + cashBalance).toFixed(2),
|
||||
|
||||
fedYTDgross: +fedYTDgross.toFixed(2),
|
||||
fedYTDtax: +fedYTDtax.toFixed(2),
|
||||
|
@ -23,7 +23,8 @@ export const MISSING_LABELS = {
|
||||
loan_term: 'Loan term',
|
||||
expected_graduation: 'Expected graduation date',
|
||||
program_type: 'Program type',
|
||||
academic_calendar: 'Academic calendar'
|
||||
academic_calendar: 'Academic calendar',
|
||||
expected_salary: 'Expected salary after graduation'
|
||||
};
|
||||
|
||||
/**
|
||||
@ -42,7 +43,8 @@ export default function getMissingFields(
|
||||
if (!hasValue(scenario.retirement_start_date)) missing.push('retirement_start_date');
|
||||
|
||||
// ── Financial base
|
||||
if (!hasValue(financial.current_salary)) missing.push('current_salary');
|
||||
// Intentionally do NOT require current salary.
|
||||
// Missing/0 income is allowed; we only require expected salary (below) for students.
|
||||
|
||||
if (!any(scenario.planned_monthly_expenses, financial.monthly_expenses)) {
|
||||
missing.push('monthly_expenses');
|
||||
@ -69,6 +71,12 @@ export default function getMissingFields(
|
||||
// ── College – only if they’re enrolled / prospective
|
||||
if (requireCollegeData) {
|
||||
if (!hasValue(college.tuition)) missing.push('tuition');
|
||||
// If user has no income (0 or missing), require an expected post-grad salary
|
||||
const incomeNum = Number(financial.current_salary);
|
||||
const noIncome = !hasValue(financial.current_salary) || !Number.isFinite(incomeNum) || incomeNum <= 0;
|
||||
if (noIncome && !hasValue(college.expected_salary)) {
|
||||
missing.push('expected_salary');
|
||||
}
|
||||
|
||||
const plansToBorrow =
|
||||
(Number(college.existing_college_debt) || 0) > 0 ||
|
||||
|
@ -1,30 +1,60 @@
|
||||
// src/utils/onboardingDraftApi.js
|
||||
import authFetch from './authFetch.js';
|
||||
import authFetch from './authFetch.js';
|
||||
|
||||
const API_ROOT = (import.meta?.env?.VITE_API_BASE || '').replace(/\/+$/, '');
|
||||
const DRAFT_URL = `${API_ROOT}/api/premium/onboarding/draft`;
|
||||
const API_ROOT = (import.meta?.env?.VITE_API_BASE || '').replace(/\/+$/, '');
|
||||
const DRAFT_URL = `${API_ROOT}/api/premium/onboarding/draft`;
|
||||
|
||||
export async function loadDraft() {
|
||||
const res = await authFetch(DRAFT_URL);
|
||||
if (!res) return null; // session expired
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`loadDraft ${res.status}`);
|
||||
return res.json(); // null or { id, step, data }
|
||||
}
|
||||
export async function loadDraft() {
|
||||
const res = await authFetch(DRAFT_URL);
|
||||
if (!res) return null; // session expired
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`loadDraft ${res.status}`);
|
||||
return res.json(); // null or { id, step, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* saveDraft(input)
|
||||
* Accepts either:
|
||||
* - { id, step, data } // full envelope
|
||||
* - { id, step, careerData?, financialData?, collegeData? } // partial sections
|
||||
* Merges partials with the current server draft to avoid clobbering.
|
||||
*/
|
||||
export async function saveDraft(input = {}) {
|
||||
// Normalize inputs
|
||||
let { id = null, step = 0, data } = input;
|
||||
|
||||
// If caller passed sections (careerData / financialData / collegeData) instead of a 'data' envelope,
|
||||
// merge them into the existing server draft so we don't drop other sections.
|
||||
if (data == null) {
|
||||
// Load existing draft (may be null/404 for first-time)
|
||||
let existing = null;
|
||||
try { existing = await loadDraft(); } catch (_) {}
|
||||
const existingData = (existing && existing.data) || {};
|
||||
|
||||
const has = (k) => Object.prototype.hasOwnProperty.call(input, k);
|
||||
const patch = {};
|
||||
if (has('careerData')) patch.careerData = input.careerData;
|
||||
if (has('financialData')) patch.financialData = input.financialData;
|
||||
if (has('collegeData')) patch.collegeData = input.collegeData;
|
||||
|
||||
data = { ...existingData, ...patch };
|
||||
// Prefer caller's id/step when provided; otherwise reuse existing
|
||||
if (id == null && existing?.id != null) id = existing.id;
|
||||
if (step == null && existing?.step != null) step = existing.step;
|
||||
}
|
||||
|
||||
export async function saveDraft({ id = null, step = 0, data = {} } = {}) {
|
||||
const res = await authFetch(DRAFT_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, step, data }),
|
||||
});
|
||||
if (!res) return null;
|
||||
if (!res.ok) throw new Error(`saveDraft ${res.status}`);
|
||||
return res.json(); // { id, step }
|
||||
return res.json(); // { id, step }
|
||||
}
|
||||
|
||||
export async function clearDraft() {
|
||||
const res = await authFetch(DRAFT_URL, { method: 'DELETE' });
|
||||
if (!res) return false;
|
||||
if (!res.ok) throw new Error(`clearDraft ${res.status}`);
|
||||
return true; // server returns { ok: true }
|
||||
}
|
||||
export async function clearDraft() {
|
||||
const res = await authFetch(DRAFT_URL, { method: 'DELETE' });
|
||||
if (!res) return false;
|
||||
if (!res.ok) throw new Error(`clearDraft ${res.status}`);
|
||||
return true; // server returns { ok: true }
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user