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 React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import authFetch from "../utils/authFetch.js";
|
import authFetch from "../utils/authFetch.js";
|
||||||
|
|
||||||
const isoToday = new Date().toISOString().slice(0,10); // top-level helper
|
const isoToday = new Date().toISOString().slice(0,10); // top-level helper
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function ensureCoachThread() {
|
async function ensureCoachThread() {
|
||||||
// try to list an existing thread
|
// try to list an existing thread
|
||||||
const r = await authFetch('/api/premium/coach/chat/threads');
|
const r = await authFetch('/api/premium/coach/chat/threads');
|
||||||
@ -21,6 +25,19 @@ async function ensureCoachThread() {
|
|||||||
return id;
|
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 = "") {
|
function buildInterviewPrompt(careerName, jobDescription = "") {
|
||||||
return `
|
return `
|
||||||
You are an expert interviewer for the role **${careerName}**.
|
You are an expert interviewer for the role **${careerName}**.
|
||||||
@ -136,6 +153,7 @@ export default function CareerCoach({
|
|||||||
onMilestonesCreated,
|
onMilestonesCreated,
|
||||||
onAiRiskFetched
|
onAiRiskFetched
|
||||||
}) {
|
}) {
|
||||||
|
const location = useLocation();
|
||||||
/* -------------- state ---------------- */
|
/* -------------- state ---------------- */
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
@ -153,6 +171,45 @@ export default function CareerCoach({
|
|||||||
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||||||
}, [messages]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@ -171,16 +228,23 @@ useEffect(() => {
|
|||||||
|
|
||||||
if (r3.ok && (r3.headers.get('content-type') || '').includes('application/json')) {
|
if (r3.ok && (r3.headers.get('content-type') || '').includes('application/json')) {
|
||||||
const data = await r3.json();
|
const data = await r3.json();
|
||||||
const msgs = Array.isArray(data.messages) ? data.messages : [];
|
const msgs = (Array.isArray(data.messages) ? data.messages : []).filter(m => !isHiddenPrompt(m));
|
||||||
if (!cancelled) setMessages(msgs.length ? msgs : [generatePersonalizedIntro()]);
|
if (!cancelled) {
|
||||||
|
setMessages(msgs); // no intro here
|
||||||
|
historyLoadedRef.current = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!cancelled) setMessages([generatePersonalizedIntro()]);
|
if (!cancelled) {
|
||||||
|
setMessages([]); // no intro here
|
||||||
|
historyLoadedRef.current = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Coach thread preload failed:", e);
|
console.error("Coach thread preload failed:", e);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setThreadId(null);
|
setThreadId(null);
|
||||||
setMessages([generatePersonalizedIntro()]);
|
setMessages([]);
|
||||||
|
historyLoadedRef.current = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@ -191,9 +255,11 @@ useEffect(() => {
|
|||||||
|
|
||||||
/* -------------- intro ---------------- */
|
/* -------------- intro ---------------- */
|
||||||
useEffect(() => {
|
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()]));
|
setMessages(prev => (prev.length ? prev : [generatePersonalizedIntro()]));
|
||||||
}, [scenarioRow?.id]);
|
}, [historyLoadedRef.current, scenarioRow?.career_name, userProfile]);
|
||||||
|
|
||||||
/* ---------- helpers you already had ---------- */
|
/* ---------- helpers you already had ---------- */
|
||||||
function buildStatusSituationMessage(status, situation, careerName) {
|
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() {
|
function generatePersonalizedIntro() {
|
||||||
/* (unchanged body) */
|
/* (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 goalsText = scenarioRow?.career_goals?.trim() || null;
|
||||||
const riskLevel = scenarioRow?.riskLevel;
|
const riskLevel = scenarioRow?.riskLevel;
|
||||||
const riskReasoning = scenarioRow?.riskReasoning;
|
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"
|
className="overflow-y-auto border rounded mb-4 space-y-2"
|
||||||
style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }}
|
style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }}
|
||||||
>
|
>
|
||||||
{messages.map((m, i) => (
|
{messages.filter(m => !isHiddenPrompt(m)).map((m, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`rounded p-2 ${
|
className={`rounded p-2 ${
|
||||||
|
@ -4,46 +4,104 @@ import authFetch from '../utils/authFetch.js';
|
|||||||
const CareerPrioritiesModal = ({ userProfile, onClose }) => {
|
const CareerPrioritiesModal = ({ userProfile, onClose }) => {
|
||||||
const [responses, setResponses] = useState({});
|
const [responses, setResponses] = useState({});
|
||||||
|
|
||||||
useEffect(() => {
|
const QUESTIONS = [
|
||||||
if (userProfile?.career_priorities) {
|
{ id: 'interests',
|
||||||
setResponses(JSON.parse(userProfile.career_priorities));
|
|
||||||
}
|
|
||||||
}, [userProfile]);
|
|
||||||
|
|
||||||
// Updated "interests" question:
|
|
||||||
const questions = [
|
|
||||||
{
|
|
||||||
id: 'interests',
|
|
||||||
text: 'How important is it that your career aligns with your personal interests?',
|
text: 'How important is it that your career aligns with your personal interests?',
|
||||||
options: ['Very important', 'Somewhat important', 'Not as important'],
|
options: ['Very important', 'Somewhat important', 'Not as important'],
|
||||||
},
|
},
|
||||||
{
|
{ id: 'meaning',
|
||||||
id: 'meaning',
|
|
||||||
text: 'Is it important your job helps others or makes a difference?',
|
text: 'Is it important your job helps others or makes a difference?',
|
||||||
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
|
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
|
||||||
},
|
},
|
||||||
{
|
{ id: 'stability',
|
||||||
id: 'stability',
|
|
||||||
text: 'How important is it that your career pays well?',
|
text: 'How important is it that your career pays well?',
|
||||||
options: ['Very important', 'Somewhat important', 'Not as important'],
|
options: ['Very important', 'Somewhat important', 'Not as important'],
|
||||||
},
|
},
|
||||||
{
|
{ id: 'growth',
|
||||||
id: 'growth',
|
|
||||||
text: 'Do you want clear chances to advance and grow professionally?',
|
text: 'Do you want clear chances to advance and grow professionally?',
|
||||||
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
|
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?',
|
text: 'Do you prefer a job with flexible hours and time outside work?',
|
||||||
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
|
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?',
|
text: 'How important is it to have a career that others admire?',
|
||||||
options: ['Very important', 'Somewhat important', 'Not as important'],
|
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 handleSave = async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
firstName: userProfile.firstname,
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50">
|
<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">
|
<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>
|
<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">
|
<div key={q.id} className="mb-4">
|
||||||
<label className="block mb-2 font-medium">{q.text}</label>
|
<label className="block mb-2 font-medium">{q.text}</label>
|
||||||
<select
|
<select
|
||||||
value={responses[q.id] || ''}
|
value={responses[q.id] || ''}
|
||||||
onChange={(e) =>
|
onChange={(e) => setResponses({ ...responses, [q.id]: e.target.value })}
|
||||||
setResponses({ ...responses, [q.id]: e.target.value })
|
|
||||||
}
|
|
||||||
className="w-full border px-3 py-2 rounded"
|
className="w-full border px-3 py-2 rounded"
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>Select an answer</option>
|
||||||
Select an answer
|
|
||||||
</option>
|
|
||||||
{q.options.map((opt) => (
|
{q.options.map((opt) => (
|
||||||
<option key={opt} value={opt}>
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
{opt}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -99,9 +151,7 @@ const CareerPrioritiesModal = ({ userProfile, onClose }) => {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!allAnswered}
|
disabled={!allAnswered}
|
||||||
className={`px-4 py-2 rounded ${
|
className={`px-4 py-2 rounded ${allAnswered ? 'bg-blue-600 text-white' : 'bg-gray-300 cursor-not-allowed'}`}
|
||||||
allAnswered ? 'bg-blue-600 text-white' : 'bg-gray-300 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Save Answers
|
Save Answers
|
||||||
</button>
|
</button>
|
||||||
|
@ -52,7 +52,7 @@ ChartJS.register(
|
|||||||
PointElement,
|
PointElement,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
zoomPlugin, // 👈 ←–––– only if you kept the zoom config
|
zoomPlugin,
|
||||||
annotationPlugin
|
annotationPlugin
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -486,6 +486,15 @@ const zoomConfig = {
|
|||||||
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' }
|
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 = {
|
const xAndYScales = {
|
||||||
x: {
|
x: {
|
||||||
type: 'time',
|
type: 'time',
|
||||||
@ -493,9 +502,11 @@ const xAndYScales = {
|
|||||||
ticks: { maxRotation: 0, autoSkip: true }
|
ticks: { maxRotation: 0, autoSkip: true }
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: true,
|
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: {
|
ticks: {
|
||||||
callback: (val) => val.toLocaleString() // comma-format big numbers
|
callback: (val) => val.toLocaleString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1261,7 +1272,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
|
|||||||
|
|
||||||
const emergencyData = {
|
const emergencyData = {
|
||||||
label: 'Emergency Savings',
|
label: 'Emergency Savings',
|
||||||
data: projectionData.map((p) => p.emergencySavings),
|
data: projectionData.map((p) => Number(p.emergencySavings ?? 0)),
|
||||||
borderColor: 'rgba(255, 159, 64, 1)',
|
borderColor: 'rgba(255, 159, 64, 1)',
|
||||||
backgroundColor: 'rgba(255, 159, 64, 0.2)',
|
backgroundColor: 'rgba(255, 159, 64, 0.2)',
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
@ -1269,7 +1280,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
|
|||||||
};
|
};
|
||||||
const retirementData = {
|
const retirementData = {
|
||||||
label: 'Retirement Savings',
|
label: 'Retirement Savings',
|
||||||
data: projectionData.map((p) => p.retirementSavings),
|
data: projectionData.map((p) => Number(p.retirementSavings ?? 0)),
|
||||||
borderColor: 'rgba(75, 192, 192, 1)',
|
borderColor: 'rgba(75, 192, 192, 1)',
|
||||||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
@ -1277,7 +1288,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
|
|||||||
};
|
};
|
||||||
const totalSavingsData = {
|
const totalSavingsData = {
|
||||||
label: 'Total Savings',
|
label: 'Total Savings',
|
||||||
data: projectionData.map((p) => p.totalSavings),
|
data: projectionData.map((p) => Number(p.totalSavings ?? 0)),
|
||||||
borderColor: 'rgba(54, 162, 235, 1)',
|
borderColor: 'rgba(54, 162, 235, 1)',
|
||||||
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
@ -1285,7 +1296,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
|
|||||||
};
|
};
|
||||||
const loanBalanceData = {
|
const loanBalanceData = {
|
||||||
label: 'Loan Balance',
|
label: 'Loan Balance',
|
||||||
data: projectionData.map((p) => p.loanBalance),
|
data: projectionData.map((p) => Number(p.loanBalance ?? 0)),
|
||||||
borderColor: 'rgba(255, 99, 132, 1)',
|
borderColor: 'rgba(255, 99, 132, 1)',
|
||||||
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
@ -1510,7 +1521,7 @@ const handleMilestonesCreated = useCallback(
|
|||||||
{showMissingBanner && (
|
{showMissingBanner && (
|
||||||
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 rounded shadow mb-4">
|
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 rounded shadow mb-4">
|
||||||
<p className="text-sm text-gray-800">
|
<p className="text-sm text-gray-800">
|
||||||
To run your full projection, please add:
|
To improve your projection, please add:
|
||||||
</p>
|
</p>
|
||||||
{!!missingKeys.length && (
|
{!!missingKeys.length && (
|
||||||
<ul className="mt-2 ml-5 list-disc text-sm text-gray-800">
|
<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;
|
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) {
|
function normalisePayload(draft) {
|
||||||
const bools = [
|
const bools = [
|
||||||
'is_in_state','is_in_district','is_online',
|
'is_in_state','is_in_district','is_online',
|
||||||
@ -122,16 +128,70 @@ const onProgramInput = (e) => {
|
|||||||
setProgSug([...new Set(sug)].slice(0, 10));
|
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(() => {
|
useEffect(() => {
|
||||||
if (id && id !== 'new') {
|
if (id && id !== 'new') {
|
||||||
(async () => {
|
(async () => {
|
||||||
const r = await authFetch(`/api/premium/college-profile?careerProfileId=${careerId}`);
|
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]);
|
}, [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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [form.tuition]);
|
||||||
|
|
||||||
async function handleSave(){
|
async function handleSave(){
|
||||||
try{
|
try{
|
||||||
const body = normalisePayload({ ...form, tuition: chosenTuition, career_profile_id: careerId });
|
const body = normalisePayload({ ...form, tuition: chosenTuition, career_profile_id: careerId });
|
||||||
@ -245,6 +305,8 @@ const chosenTuition = (() => {
|
|||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (programLengthTouched) return; // user override
|
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);
|
const chpy = parseFloat(form.credit_hours_per_year);
|
||||||
if (!chpy || chpy <= 0) return;
|
if (!chpy || chpy <= 0) return;
|
||||||
@ -266,7 +328,7 @@ const chpy = parseFloat(form.credit_hours_per_year);
|
|||||||
if (creditsNeeded <= 0) return;
|
if (creditsNeeded <= 0) return;
|
||||||
|
|
||||||
/* 2 – years = credits / CHPY → one decimal place */
|
/* 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) {
|
if (years !== form.program_length) {
|
||||||
setForm(prev => ({ ...prev, program_length: years }));
|
setForm(prev => ({ ...prev, program_length: years }));
|
||||||
@ -522,6 +584,7 @@ return (
|
|||||||
<label className="block font-medium">Program Length (years)</label>
|
<label className="block font-medium">Program Length (years)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
step="0.01"
|
||||||
name="program_length"
|
name="program_length"
|
||||||
value={form.program_length}
|
value={form.program_length}
|
||||||
onChange={handleFieldChange}
|
onChange={handleFieldChange}
|
||||||
|
@ -5,6 +5,7 @@ import { ONET_DEFINITIONS } from './definitions.js';
|
|||||||
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js';
|
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js';
|
||||||
import ChatCtx from '../contexts/ChatCtx.js';
|
import ChatCtx from '../contexts/ChatCtx.js';
|
||||||
import api from '../auth/apiClient.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
|
// Normalize DB/GPT KSA payloads into IM/LV rows for combineIMandLV
|
||||||
function normalizeKsaPayloadForCombine(payload, socCode) {
|
function normalizeKsaPayloadForCombine(payload, socCode) {
|
||||||
@ -71,6 +72,11 @@ function ensureHttp(urlString) {
|
|||||||
return `https://${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
|
// Convert numeric importance (1–5) to star or emoji compact representation
|
||||||
function renderImportance(val) {
|
function renderImportance(val) {
|
||||||
const max = 5;
|
const max = 5;
|
||||||
@ -168,23 +174,54 @@ function normalizeCipList(arr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fixed handleSelectSchool (removed extra brace)
|
// Fixed handleSelectSchool (removed extra brace)
|
||||||
const handleSelectSchool = (school) => {
|
// Replace your existing handleSelectSchool with this:
|
||||||
|
const handleSelectSchool = async (school) => {
|
||||||
const proceed = window.confirm(
|
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?'
|
'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;
|
if (!proceed) return;
|
||||||
// normalize selectedCareer and carry it forward
|
|
||||||
|
// normalize the currently selected career for handoff (optional)
|
||||||
const sel = selectedCareer
|
const sel = selectedCareer
|
||||||
? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode }
|
? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode }
|
||||||
: null;
|
: 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', {
|
navigate('/career-roadmap', {
|
||||||
state: {
|
state: {
|
||||||
premiumOnboardingState: {
|
premiumOnboardingState: {
|
||||||
selectedCareer: sel, // SOC-bearing career object
|
selectedCareer: sel,
|
||||||
selectedSchool: school // school card just chosen
|
selectedSchool: {
|
||||||
}
|
INSTNM: school.INSTNM,
|
||||||
}
|
CIPDESC: selected_program,
|
||||||
|
CREDDESC: program_type,
|
||||||
|
UNITID: school.UNITID ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -738,7 +775,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>Program: {cleanCipDesc(school['CIPDESC'])}</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>
|
||||||
|
@ -178,25 +178,55 @@ export default function MilestoneEditModal({
|
|||||||
alert('Need title & date'); return;
|
alert('Need title & date'); return;
|
||||||
}
|
}
|
||||||
setIsSavingNew(true);
|
setIsSavingNew(true);
|
||||||
const hdr = { title:newMilestone.title, description:newMilestone.description,
|
const toDate = (v) => (v ? String(v).slice(0,10) : null);
|
||||||
date:toSqlDate(newMilestone.date), career_profile_id:careerProfileId,
|
try {
|
||||||
progress:newMilestone.progress, status:newMilestone.progress>=100?'completed':'planned',
|
const hdr = {
|
||||||
is_universal:newMilestone.isUniversal };
|
title: newMilestone.title,
|
||||||
const res = await authFetch('/api/premium/milestone',{method:'POST',
|
description: newMilestone.description,
|
||||||
headers:{'Content-Type':'application/json'},body:JSON.stringify(hdr)});
|
date: toDate(newMilestone.date),
|
||||||
const created = Array.isArray(await res.json())? (await res.json())[0]:await res.json();
|
career_profile_id: careerProfileId,
|
||||||
for(const imp of newMilestone.impacts){
|
progress: newMilestone.progress,
|
||||||
const body = {
|
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
|
||||||
milestone_id:created.id, impact_type:imp.impact_type,
|
is_universal: newMilestone.isUniversal,
|
||||||
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
|
|
||||||
};
|
};
|
||||||
await authFetch('/api/premium/milestone-impacts',{
|
const res = await authFetch('/api/premium/milestone', {
|
||||||
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
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),
|
||||||
|
};
|
||||||
|
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();
|
await fetchMilestones();
|
||||||
setAddingNew(false);
|
setAddingNew(false);
|
||||||
onClose(true);
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center overflow-y-auto bg-black/40">
|
<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 */}
|
{/* header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||||
<div>
|
<div>
|
||||||
@ -217,7 +247,7 @@ export default function MilestoneEditModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* body */}
|
{/* 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 */}
|
{/* EXISTING */}
|
||||||
{milestones.map(m=>{
|
{milestones.map(m=>{
|
||||||
const open = editingId===m.id;
|
const open = editingId===m.id;
|
||||||
@ -272,7 +302,7 @@ export default function MilestoneEditModal({
|
|||||||
|
|
||||||
{/* impacts */}
|
{/* impacts */}
|
||||||
<div>
|
<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>
|
<h4 className="font-medium text-sm">Financial impacts</h4>
|
||||||
<Button size="xs" onClick={()=>addImpactRow(m.id)}>+ Add impact</Button>
|
<Button size="xs" onClick={()=>addImpactRow(m.id)}>+ Add impact</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -282,7 +312,7 @@ export default function MilestoneEditModal({
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{d.impacts?.map((imp,idx)=>(
|
{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 */}
|
{/* type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="label-xs">Type</label>
|
<label className="label-xs">Type</label>
|
||||||
@ -296,20 +326,20 @@ export default function MilestoneEditModal({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{/* direction – hide for salary */}
|
{/* direction – hide for salary */}
|
||||||
{imp.impact_type!=='salary' && (
|
{imp.impact_type !== 'salary' ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="label-xs">Direction</label>
|
<label className="label-xs">Direction</label>
|
||||||
<select
|
<select className="input" value={imp.direction} onChange={e=>updateImpact(m.id,idx,'direction',e.target.value)}>
|
||||||
className="input"
|
|
||||||
value={imp.direction}
|
|
||||||
onChange={e=>updateImpact(m.id,idx,'direction',e.target.value)}>
|
|
||||||
<option value="add">Add</option>
|
<option value="add">Add</option>
|
||||||
<option value="subtract">Subtract</option>
|
<option value="subtract">Subtract</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
// keep the grid column to prevent the next columns from collapsing
|
||||||
|
<div className="hidden md:block" />
|
||||||
)}
|
)}
|
||||||
{/* amount */}
|
{/* amount */}
|
||||||
<div>
|
<div className="md:w-[100px]">
|
||||||
<label className="label-xs">Amount</label>
|
<label className="label-xs">Amount</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -319,12 +349,12 @@ export default function MilestoneEditModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* dates */}
|
{/* dates */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2 min-w-0">
|
||||||
<div>
|
<div>
|
||||||
<label className="label-xs">Start</label>
|
<label className="label-xs">Start</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="input"
|
className="input w-full min-w-[14ch] px-3"
|
||||||
value={imp.start_date}
|
value={imp.start_date}
|
||||||
onChange={e=>updateImpact(m.id,idx,'start_date',e.target.value)}
|
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>
|
<label className="label-xs">End</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="input"
|
className="input w-full min-w-[14ch] px-3"
|
||||||
value={imp.end_date}
|
value={imp.end_date}
|
||||||
onChange={e=>updateImpact(m.id,idx,'end_date',e.target.value)}
|
onChange={e=>updateImpact(m.id,idx,'end_date',e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -345,7 +375,7 @@ export default function MilestoneEditModal({
|
|||||||
<Button
|
<Button
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-red-600"
|
className="text-red-600 w-10 h-9 flex items-center justify-center"
|
||||||
onClick={()=>removeImpactRow(m.id,idx)}
|
onClick={()=>removeImpactRow(m.id,idx)}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@ -401,7 +431,7 @@ export default function MilestoneEditModal({
|
|||||||
<label className="label-xs">Date</label>
|
<label className="label-xs">Date</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="input"
|
className="input w-full min-w-[12ch] px-3"
|
||||||
value={newMilestone.date}
|
value={newMilestone.date}
|
||||||
onChange={e=>setNewMilestone(n=>({...n,date:e.target.value}))}
|
onChange={e=>setNewMilestone(n=>({...n,date:e.target.value}))}
|
||||||
/>
|
/>
|
||||||
@ -420,69 +450,93 @@ export default function MilestoneEditModal({
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="font-medium text-sm">Financial impacts</h4>
|
<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>
|
||||||
<div className="space-y-3 mt-2">
|
<div className="space-y-3 mt-2">
|
||||||
{newMilestone.impacts.map((imp, idx) => (
|
{newMilestone.impacts.map((imp, idx) => (
|
||||||
<div key={idx} className="grid md:grid-cols-[150px_120px_1fr_auto] gap-2 items-end">
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="grid gap-2 items-end min-w-0 grid-cols-[140px_110px_100px_minmax(220px,1fr)_44px]"
|
||||||
|
>
|
||||||
|
{/* Type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="label-xs">Type</label>
|
<label className="label-xs">Type</label>
|
||||||
<select
|
<select
|
||||||
className="input"
|
className="input"
|
||||||
value={imp.impact_type}
|
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="salary">Salary (annual)</option>
|
||||||
<option value="ONE_TIME">One‑time</option>
|
<option value="ONE_TIME">One-time</option>
|
||||||
<option value="MONTHLY">Monthly</option>
|
<option value="MONTHLY">Monthly</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{imp.impact_type!=='salary' && (
|
|
||||||
|
{/* Direction (spacer when salary) */}
|
||||||
|
{imp.impact_type !== 'salary' ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="label-xs">Direction</label>
|
<label className="label-xs">Direction</label>
|
||||||
<select
|
<select
|
||||||
className="input"
|
className="input"
|
||||||
value={imp.direction}
|
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="add">Add</option>
|
||||||
<option value="subtract">Subtract</option>
|
<option value="subtract">Subtract</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="hidden md:block" />
|
||||||
)}
|
)}
|
||||||
<div>
|
|
||||||
|
{/* Amount (fixed width) */}
|
||||||
|
<div className="md:w-[100px]">
|
||||||
<label className="label-xs">Amount</label>
|
<label className="label-xs">Amount</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input"
|
className="input"
|
||||||
value={imp.amount}
|
value={imp.amount}
|
||||||
onChange={e=>updateNewImpact(idx,'amount',e.target.value)}
|
onChange={(e) => updateNewImpact(idx, 'amount', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
|
{/* Dates (flex) */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 min-w-0">
|
||||||
|
<div>
|
||||||
|
<label className="label-xs">Start</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="input"
|
className="input w-full min-w-[14ch] px-3"
|
||||||
value={imp.start_date}
|
value={imp.start_date}
|
||||||
onChange={e=>updateNewImpact(idx,'start_date',e.target.value)}
|
onChange={(e) => updateNewImpact(idx, 'start_date', e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{imp.impact_type === 'MONTHLY' && (
|
{imp.impact_type === 'MONTHLY' && (
|
||||||
|
<div>
|
||||||
|
<label className="label-xs">End</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="input"
|
className="input w-full min-w-[14ch] px-3"
|
||||||
value={imp.end_date}
|
value={imp.end_date}
|
||||||
onChange={e=>updateNewImpact(idx,'end_date',e.target.value)}
|
onChange={(e) => updateNewImpact(idx, 'end_date', e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Remove */}
|
||||||
<Button
|
<Button
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-red-600"
|
className="text-red-600 w-11 h-9 flex items-center justify-center"
|
||||||
onClick={() => removeNewImpact(idx)}
|
onClick={() => removeNewImpact(idx)}
|
||||||
|
aria-label="Remove impact"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* save row */}
|
{/* save row */}
|
||||||
|
@ -79,9 +79,7 @@ function handleSubmit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextLabel = skipFin
|
const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Finish →');
|
||||||
? inCollege ? 'College →' : 'Finish →'
|
|
||||||
: inCollege ? 'College →' : 'Financial →';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto p-6 space-y-4">
|
<div className="max-w-md mx-auto p-6 space-y-4">
|
||||||
@ -185,9 +183,9 @@ function handleSubmit() {
|
|||||||
<button
|
<button
|
||||||
className="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded mr-2"
|
className="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded mr-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
/* open your ScenarioEditModal or a mini financial modal */
|
// user explicitly wants to enter financials now
|
||||||
|
setData(prev => ({ ...prev, skipFinancialStep: false }));
|
||||||
setShowFinPrompt(false);
|
setShowFinPrompt(false);
|
||||||
/* maybe set a flag so CareerRoadmap opens the modal */
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add Financial Details
|
Add Financial Details
|
||||||
|
@ -20,18 +20,24 @@ 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();
|
||||||
const navSelectedSchoolRaw = location.state?.selectedSchool;
|
const navSelectedSchoolObj =
|
||||||
const navSelectedSchool = toSchoolName(navSelectedSchoolRaw);
|
location.state?.selectedSchool ??
|
||||||
|
location.state?.premiumOnboardingState?.selectedSchool;
|
||||||
|
|
||||||
function dehydrate(schObj) {
|
const [selectedSchool, setSelectedSchool] = useState(() => {
|
||||||
if (!schObj || typeof schObj !== 'object') return null;
|
if (navSelectedSchoolObj && typeof navSelectedSchoolObj === 'object') {
|
||||||
const { INSTNM, CIPDESC, CREDDESC, ...rest } = schObj;
|
return { INSTNM: navSelectedSchoolObj.INSTNM,
|
||||||
return { INSTNM, CIPDESC, CREDDESC, ...rest };
|
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) {
|
function toSchoolName(objOrStr) {
|
||||||
if (!objOrStr) return '';
|
if (!objOrStr) return '';
|
||||||
|
@ -15,8 +15,8 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
|||||||
retirement_contribution = 0,
|
retirement_contribution = 0,
|
||||||
emergency_fund = 0,
|
emergency_fund = 0,
|
||||||
emergency_contribution = 0,
|
emergency_contribution = 0,
|
||||||
extra_cash_emergency_pct = "",
|
extra_cash_emergency_pct = 50,
|
||||||
extra_cash_retirement_pct = "",
|
extra_cash_retirement_pct = 50,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
const [showExpensesWizard, setShowExpensesWizard] = useState(false);
|
const [showExpensesWizard, setShowExpensesWizard] = useState(false);
|
||||||
|
@ -86,9 +86,16 @@ export default function OnboardingContainer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// D) pick up any navigation state (e.g., selectedSchool)
|
// 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) {
|
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);
|
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 authFetch from '../utils/authFetch.js';
|
||||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
|
|
||||||
import InfoTooltip from "./ui/infoTooltip.js";
|
import InfoTooltip from "./ui/infoTooltip.js";
|
||||||
|
import CareerSearch from './CareerSearch.js';
|
||||||
// Data paths
|
import api from '../auth/apiClient.js'
|
||||||
const CIP_URL = '/api/data/cip-institution-map';
|
|
||||||
const IPEDS_URL = '/api/data/ic2023';
|
|
||||||
const CAREER_CLUSTERS_URL = '/api/data/career_clusters.json';
|
|
||||||
|
|
||||||
export default function ScenarioEditModal({
|
export default function ScenarioEditModal({
|
||||||
show,
|
show,
|
||||||
@ -17,11 +12,6 @@ export default function ScenarioEditModal({
|
|||||||
collegeProfile,
|
collegeProfile,
|
||||||
financialProfile
|
financialProfile
|
||||||
}) {
|
}) {
|
||||||
/*********************************************************
|
|
||||||
* 1) CIP / IPEDS data states
|
|
||||||
*********************************************************/
|
|
||||||
const [schoolData, setSchoolData] = useState([]);
|
|
||||||
const [icTuitionData, setIcTuitionData] = useState([]);
|
|
||||||
|
|
||||||
/*********************************************************
|
/*********************************************************
|
||||||
* 2) Suggestions & program types
|
* 2) Suggestions & program types
|
||||||
@ -29,6 +19,7 @@ export default function ScenarioEditModal({
|
|||||||
const [schoolSuggestions, setSchoolSuggestions] = useState([]);
|
const [schoolSuggestions, setSchoolSuggestions] = useState([]);
|
||||||
const [programSuggestions, setProgramSuggestions] = useState([]);
|
const [programSuggestions, setProgramSuggestions] = useState([]);
|
||||||
const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
|
const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
|
||||||
|
const [selectedUnitId, setSelectedUnitId] = useState(null);
|
||||||
|
|
||||||
/*********************************************************
|
/*********************************************************
|
||||||
* 3) Manual vs auto for tuition & program length
|
* 3) Manual vs auto for tuition & program length
|
||||||
@ -38,14 +29,6 @@ export default function ScenarioEditModal({
|
|||||||
const [manualProgLength, setManualProgLength] = useState('');
|
const [manualProgLength, setManualProgLength] = useState('');
|
||||||
const [autoProgLength, setAutoProgLength] = useState('0.00');
|
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
|
* 5) Combined formData => scenario + college
|
||||||
*********************************************************/
|
*********************************************************/
|
||||||
@ -65,81 +48,52 @@ export default function ScenarioEditModal({
|
|||||||
*********************************************************/
|
*********************************************************/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
setShowCollegeForm(
|
const hasCollegeData =
|
||||||
['currently_enrolled', 'prospective_student']
|
!!formData.selected_school ||
|
||||||
.includes(formData.college_enrollment_status)
|
!!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]);
|
}, [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
|
* 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**
|
* 7) Whenever the **modal is shown** *or* **scenario.id changes**
|
||||||
@ -153,6 +107,12 @@ export default function ScenarioEditModal({
|
|||||||
const safe = v =>
|
const safe = v =>
|
||||||
v === null || v === undefined ? '' : 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({
|
setFormData({
|
||||||
// scenario portion
|
// scenario portion
|
||||||
scenario_title : safe(s.scenario_title),
|
scenario_title : safe(s.scenario_title),
|
||||||
@ -182,7 +142,10 @@ export default function ScenarioEditModal({
|
|||||||
is_in_state: safe(!!c.is_in_state),
|
is_in_state: safe(!!c.is_in_state),
|
||||||
is_in_district: safe(!!c.is_in_district),
|
is_in_district: safe(!!c.is_in_district),
|
||||||
is_online: safe(!!c.is_online),
|
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),
|
annual_financial_aid : safe(c.annual_financial_aid),
|
||||||
existing_college_debt : safe(c.existing_college_debt),
|
existing_college_debt : safe(c.existing_college_debt),
|
||||||
@ -219,67 +182,8 @@ export default function ScenarioEditModal({
|
|||||||
setManualProgLength('');
|
setManualProgLength('');
|
||||||
}
|
}
|
||||||
|
|
||||||
setCareerSearchInput(s.career_name || '');
|
|
||||||
}, [show, scenario?.id, collegeProfile]);
|
}, [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
|
* 9.5) Program Type from CIP
|
||||||
@ -290,18 +194,23 @@ useEffect(() => {
|
|||||||
setAvailableProgramTypes([]);
|
setAvailableProgramTypes([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filtered = schoolData.filter(
|
(async () => {
|
||||||
(row) =>
|
try {
|
||||||
row.INSTNM.toLowerCase() === formData.selected_school.toLowerCase() &&
|
const { data } = await api.get('/api/programs/types', {
|
||||||
row.CIPDESC === formData.selected_program
|
params: {
|
||||||
);
|
school: formData.selected_school,
|
||||||
const possibleTypes = [...new Set(filtered.map((r) => r.CREDDESC))];
|
program: formData.selected_program
|
||||||
setAvailableProgramTypes(possibleTypes);
|
}
|
||||||
|
});
|
||||||
|
setAvailableProgramTypes(Array.isArray(data?.types) ? data.types : []);
|
||||||
|
} catch {
|
||||||
|
setAvailableProgramTypes([]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}, [
|
}, [
|
||||||
show,
|
show,
|
||||||
formData.selected_school,
|
formData.selected_school,
|
||||||
formData.selected_program,
|
formData.selected_program,
|
||||||
schoolData
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/*********************************************************
|
/*********************************************************
|
||||||
@ -314,67 +223,72 @@ useEffect(() => {
|
|||||||
setFormData((prev) => ({ ...prev, [name]: val }));
|
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) {
|
function handleSchoolChange(e) {
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
setFormData((prev) => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
selected_school: val,
|
selected_school: val,
|
||||||
selected_program: '',
|
selected_program: '',
|
||||||
program_type: '',
|
program_type: '',
|
||||||
credit_hours_required: ''
|
credit_hours_required: ''
|
||||||
}));
|
}));
|
||||||
if (!val) {
|
if (!val.trim()) {
|
||||||
setSchoolSuggestions([]);
|
setSchoolSuggestions([]);
|
||||||
setProgramSuggestions([]);
|
setProgramSuggestions([]);
|
||||||
setAvailableProgramTypes([]);
|
setAvailableProgramTypes([]);
|
||||||
|
setSelectedUnitId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filtered = schoolData.filter((s) =>
|
(async () => {
|
||||||
s.INSTNM.toLowerCase().includes(val.toLowerCase())
|
try {
|
||||||
);
|
const { data } = await api.get('/api/schools/suggest', {
|
||||||
const unique = [...new Set(filtered.map((s) => s.INSTNM))];
|
params: { query: val, limit: 10 }
|
||||||
setSchoolSuggestions(unique.slice(0, 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([]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSchoolSelect(sch) {
|
function handleSchoolSelect(sch) {
|
||||||
setFormData((prev) => ({
|
const name = sch?.name || sch || '';
|
||||||
|
const uid = sch?.unitId ?? null;
|
||||||
|
setSelectedUnitId(uid);
|
||||||
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
selected_school: sch,
|
selected_school: name,
|
||||||
selected_program: '',
|
selected_program: '',
|
||||||
program_type: '',
|
program_type: '',
|
||||||
credit_hours_required: ''
|
credit_hours_required: ''
|
||||||
}));
|
}));
|
||||||
setSchoolSuggestions([]);
|
setSchoolSuggestions([]);
|
||||||
|
setProgramSuggestions([]);
|
||||||
|
setAvailableProgramTypes([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleProgramChange(e) {
|
function handleProgramChange(e) {
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
setFormData((prev) => ({ ...prev, selected_program: val }));
|
setFormData((prev) => ({ ...prev, selected_program: val }));
|
||||||
if (!val) {
|
if (!val || !formData.selected_school) {
|
||||||
setProgramSuggestions([]);
|
setProgramSuggestions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filtered = schoolData.filter(
|
(async () => {
|
||||||
(row) =>
|
try {
|
||||||
row.INSTNM.toLowerCase() === formData.selected_school.toLowerCase() &&
|
const { data } = await api.get('/api/programs/suggest', {
|
||||||
row.CIPDESC.toLowerCase().includes(val.toLowerCase())
|
params: { school: formData.selected_school, query: val, limit: 10 }
|
||||||
);
|
});
|
||||||
const unique = [...new Set(filtered.map((r) => r.CIPDESC))];
|
const list = Array.isArray(data) ? data : []; // [{ program }]
|
||||||
setProgramSuggestions(unique.slice(0, 10));
|
setProgramSuggestions(list.map(p => p.program));
|
||||||
|
} catch {
|
||||||
|
setProgramSuggestions([]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleProgramSelect(prog) {
|
function handleProgramSelect(prog) {
|
||||||
@ -422,10 +336,9 @@ useEffect(() => {
|
|||||||
scenarioRow.planned_monthly_debt_payments ??
|
scenarioRow.planned_monthly_debt_payments ??
|
||||||
financialData.monthly_debt_payments ??
|
financialData.monthly_debt_payments ??
|
||||||
0,
|
0,
|
||||||
partTimeIncome:
|
additionalIncome:
|
||||||
scenarioRow.planned_additional_income ??
|
scenarioRow.planned_additional_income ??
|
||||||
financialData.additional_income ??
|
financialData.additional_income ?? 0,
|
||||||
0,
|
|
||||||
|
|
||||||
emergencySavings: financialData.emergency_fund ?? 0,
|
emergencySavings: financialData.emergency_fund ?? 0,
|
||||||
retirementSavings: financialData.retirement_savings ?? 0,
|
retirementSavings: financialData.retirement_savings ?? 0,
|
||||||
@ -618,38 +531,15 @@ if (formData.retirement_start_date) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Career Search */}
|
{/* Career Search (shared component) */}
|
||||||
<div className="mb-4 relative">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">Career Search</label>
|
||||||
Career Search
|
<CareerSearch onCareerSelected={(found) => {
|
||||||
</label>
|
if (!found) return;
|
||||||
<input
|
setFormData(prev => ({ ...prev, career_name: found.title || prev.career_name || '' }));
|
||||||
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">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
<em>Current Career:</em> {formData.career_name || '(none)'}
|
<em>Selected Career:</em> {formData.career_name || '(none)'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -938,12 +828,9 @@ if (formData.retirement_start_date) {
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
{schoolSuggestions.map((sch, i) => (
|
{schoolSuggestions.map((sch, i) => (
|
||||||
<li
|
<li key={i} className="p-2 cursor-pointer hover:bg-gray-100"
|
||||||
key={i}
|
onClick={() => handleSchoolSelect(sch)}>
|
||||||
className="p-2 cursor-pointer hover:bg-gray-100"
|
{sch.name}
|
||||||
onClick={() => handleSchoolSelect(sch)}
|
|
||||||
>
|
|
||||||
{sch}
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -1216,11 +1103,6 @@ if (formData.retirement_start_date) {
|
|||||||
<Button variant="primary" onClick={handleSave}>
|
<Button variant="primary" onClick={handleSave}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Show a preview if we have simulation data */}
|
{/* 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 { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
import SituationCard from './ui/SituationCard.js';
|
import SituationCard from './ui/SituationCard.js';
|
||||||
@ -54,11 +54,16 @@ function SignUp() {
|
|||||||
const [zipcode, setZipcode] = useState('');
|
const [zipcode, setZipcode] = useState('');
|
||||||
const [state, setState] = useState('');
|
const [state, setState] = useState('');
|
||||||
const [area, setArea] = useState('');
|
const [area, setArea] = useState('');
|
||||||
const [areas, setAreas] = useState([]);
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loadingAreas, setLoadingAreas] = useState(false);
|
const [loadingAreas, setLoadingAreas] = useState(false);
|
||||||
const [phone, setPhone] = useState('+1');
|
const [phone, setPhone] = useState('+1');
|
||||||
const [optIn, setOptIn] = useState(false);
|
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 [showCareerSituations, setShowCareerSituations] = useState(false);
|
||||||
const [selectedSituation, setSelectedSituation] = useState(null);
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-4">
|
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-4">
|
||||||
@ -344,9 +403,7 @@ return (
|
|||||||
<label>
|
<label>
|
||||||
<span
|
<span
|
||||||
title="Selecting an Area will allow us to provide regional salary data for your career choices."
|
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
|
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"
|
||||||
inline-flex h-4 w-4 items-center justify-center
|
|
||||||
rounded-full bg-blue-500 text-xs font-bold text-white cursor-help"
|
|
||||||
>
|
>
|
||||||
i
|
i
|
||||||
</span>
|
</span>
|
||||||
@ -358,18 +415,22 @@ return (
|
|||||||
onChange={(e) => setArea(e.target.value)}
|
onChange={(e) => setArea(e.target.value)}
|
||||||
disabled={loadingAreas}
|
disabled={loadingAreas}
|
||||||
>
|
>
|
||||||
<option value="">Select Area</option>
|
<option value="">
|
||||||
{areas.map((a, i) => (
|
{loadingAreas ? 'Loading Areas...' : 'Select Area (optional)'}
|
||||||
<option key={i} value={a}>
|
|
||||||
{a}
|
|
||||||
</option>
|
</option>
|
||||||
|
{areas.map((a, i) => (
|
||||||
|
<option key={i} value={a}>{a}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{loadingAreas && (
|
{loadingAreas && (
|
||||||
<span className="absolute right-3 top-2.5 text-gray-400 text-sm animate-pulse">
|
<span className="absolute right-3 top-2.5 text-gray-400 text-sm animate-pulse">
|
||||||
Loading...
|
Loading...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{areasErr && (
|
||||||
|
<p className="mt-1 text-xs text-amber-600">{areasErr}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full">
|
<Button type="submit" className="w-full">
|
||||||
|
@ -16,6 +16,8 @@ const n = (val, fallback = 0) => {
|
|||||||
const num = (v) =>
|
const num = (v) =>
|
||||||
v === null || v === undefined || v === '' || Number.isNaN(+v) ? 0 : +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
|
* HELPER: Approx State Tax Rates
|
||||||
***************************************************/
|
***************************************************/
|
||||||
@ -278,22 +280,32 @@ function simulateDrawdown(opts){
|
|||||||
// else Bachelor's = 120
|
// else Bachelor's = 120
|
||||||
}
|
}
|
||||||
const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted);
|
const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted);
|
||||||
const dynamicProgramLength = Math.ceil(
|
// If we must derive a length (years), round to the nearest month (not always up)
|
||||||
remainingCreditHours / (creditHoursPerYear || 30)
|
const derivedYears = (creditHoursPerYear || 30) > 0
|
||||||
);
|
? Math.ceil((remainingCreditHours / (creditHoursPerYear || 30)) * 12) / 12
|
||||||
const finalProgramLength = programLength || dynamicProgramLength;
|
: 0;
|
||||||
|
const finalProgramLength = programLength || derivedYears;
|
||||||
|
|
||||||
|
// Use the explicit enrollmentDate if provided; otherwise fall back to scenario.start_date
|
||||||
const enrollmentStart = enrollmentDate
|
const enrollmentStart = enrollmentDate
|
||||||
? moment(enrollmentDate).startOf('month')
|
? moment(toDateOnly(enrollmentDate), 'YYYY-MM-DD', true).startOf('month')
|
||||||
: (startDate ? moment(startDate).startOf('month') : scenarioStartClamped.clone());
|
: (startDate ? moment(startDate).startOf('month') : null);
|
||||||
|
|
||||||
const creditsPerYear = creditHoursPerYear || 30;
|
const creditsPerYear = creditHoursPerYear || 30;
|
||||||
const creditsRemaining = Math.max(0, requiredCreditHours - hoursCompleted);
|
const creditsRemaining = Math.max(0, requiredCreditHours - hoursCompleted);
|
||||||
const monthsRemaining = Math.ceil((creditsRemaining / Math.max(1, creditsPerYear)) * 12);
|
const monthsRemaining = Math.ceil((creditsRemaining / Math.max(1, creditsPerYear)) * 12);
|
||||||
|
|
||||||
|
// 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
|
const gradDateEffective = gradDate
|
||||||
? moment(gradDate).startOf('month')
|
? moment(toDateOnly(gradDate), 'YYYY-MM-DD', true).startOf('month')
|
||||||
: enrollmentStart.clone().add(monthsRemaining, 'months');
|
: (derivedGradFromCredits || derivedGradFromProgramLen || null);
|
||||||
|
|
||||||
/***************************************************
|
/***************************************************
|
||||||
* 4) TUITION CALC
|
* 4) TUITION CALC
|
||||||
@ -321,7 +333,7 @@ function simulateDrawdown(opts){
|
|||||||
lumpsSchedule = [...Array(12).keys()];
|
lumpsSchedule = [...Array(12).keys()];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const totalAcademicMonths = finalProgramLength * 12;
|
|
||||||
const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength);
|
const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength);
|
||||||
|
|
||||||
/***************************************************
|
/***************************************************
|
||||||
@ -339,9 +351,11 @@ function simulateDrawdown(opts){
|
|||||||
const maxMonths = simulationYears * 12;
|
const maxMonths = simulationYears * 12;
|
||||||
let loanBalance = initialLoanPrincipal;
|
let loanBalance = initialLoanPrincipal;
|
||||||
let loanPaidOffMonth = null;
|
let loanPaidOffMonth = null;
|
||||||
|
let prevLoanBalance = loanBalance;
|
||||||
|
|
||||||
let currentEmergencySavings = emergencySavings;
|
let currentEmergencySavings = emergencySavings;
|
||||||
let currentRetirementSavings = retirementSavings;
|
let currentRetirementSavings = retirementSavings;
|
||||||
|
let cashBalance = 0;
|
||||||
|
|
||||||
let projectionData = [];
|
let projectionData = [];
|
||||||
|
|
||||||
@ -354,14 +368,6 @@ function simulateDrawdown(opts){
|
|||||||
// We'll keep track that we started in deferral if inCollege & deferral is true
|
// We'll keep track that we started in deferral if inCollege & deferral is true
|
||||||
let wasInDeferral = inCollege && loanDeferralUntilGraduation;
|
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
|
* 7) THE MONTHLY LOOP
|
||||||
***************************************************/
|
***************************************************/
|
||||||
@ -369,6 +375,8 @@ function simulateDrawdown(opts){
|
|||||||
for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
|
for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
|
||||||
const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months');
|
const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months');
|
||||||
|
|
||||||
|
prevLoanBalance = loanBalance;
|
||||||
|
|
||||||
if (!reachedRetirement && currentSimDate.isSameOrAfter(retirementStartISO)) {
|
if (!reachedRetirement && currentSimDate.isSameOrAfter(retirementStartISO)) {
|
||||||
reachedRetirement = true;
|
reachedRetirement = true;
|
||||||
firstRetirementBalance = currentRetirementSavings; // capture once
|
firstRetirementBalance = currentRetirementSavings; // capture once
|
||||||
@ -378,19 +386,24 @@ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
|
|||||||
retirementSpendMonthly > 0 &&
|
retirementSpendMonthly > 0 &&
|
||||||
currentSimDate.isSameOrAfter(retirementStartISO)
|
currentSimDate.isSameOrAfter(retirementStartISO)
|
||||||
|
|
||||||
// figure out if we are in the college window
|
// College window is inclusive of enrollment month and exclusive of the graduation month
|
||||||
const stillInCollege =
|
const stillInCollege =
|
||||||
inCollege &&
|
inCollege &&
|
||||||
currentSimDate.isSameOrAfter(enrollmentStart) &&
|
!!enrollmentStart &&
|
||||||
currentSimDate.isBefore(gradDateEffective);
|
(!!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
|
* 7.1 TUITION lumps
|
||||||
************************************************/
|
************************************************/
|
||||||
let tuitionCostThisMonth = 0;
|
let tuitionCostThisMonth = 0;
|
||||||
if (stillInCollege && lumpsPerYear > 0) {
|
if (stillInCollege && lumpsPerYear > 0 && enrollmentStart && enrollmentStart.isValid()) {
|
||||||
const monthsSinceEnroll = Math.max(0, currentSimDate.diff(enrollmentStart, 'months'));
|
const monthsSinceEnroll = Math.max(0, currentSimDate.diff(enrollmentStart, 'months'));
|
||||||
const academicYearIndex = Math.floor(monthsSinceEnroll / 12);
|
const academicYearIndex = Math.floor(monthsSinceEnroll / 12);
|
||||||
const monthInAcadYear = monthsSinceEnroll % 12;
|
const monthInAcadYear = monthsSinceEnroll % 12;
|
||||||
@ -416,8 +429,8 @@ if (stillInCollege && lumpsPerYear > 0) {
|
|||||||
currentRetirementSavings -= withdrawal;
|
currentRetirementSavings -= withdrawal;
|
||||||
baseMonthlyIncome += withdrawal;
|
baseMonthlyIncome += withdrawal;
|
||||||
} else if (!stillInCollege) {
|
} else if (!stillInCollege) {
|
||||||
const monthlyFromJob = (hasGraduated && expectedSalary > 0 ? expectedSalary : currentSalary) / 12;
|
const annual = hasGraduated && expectedSalary > 0 ? expectedSalary : currentSalary;
|
||||||
baseMonthlyIncome = monthlyFromJob + (additionalIncome / 12);
|
baseMonthlyIncome = (annual / 12) + (additionalIncome / 12);
|
||||||
} else {
|
} else {
|
||||||
baseMonthlyIncome = (currentSalary / 12) + (additionalIncome / 12);
|
baseMonthlyIncome = (currentSalary / 12) + (additionalIncome / 12);
|
||||||
}
|
}
|
||||||
@ -516,11 +529,12 @@ if (loanDeferralUntilGraduation && wasInDeferral && !stillInCollege) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loanBalance <= 0 && !loanPaidOffMonth) {
|
if (!loanPaidOffMonth && prevLoanBalance > 0 && loanBalance <= 0) {
|
||||||
loanPaidOffMonth = currentSimDate.format('YYYY-MM');
|
loanPaidOffMonth = currentSimDate.format('YYYY-MM');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let leftover = netMonthlyIncome - totalMonthlyExpenses;
|
let leftover = netMonthlyIncome - totalMonthlyExpenses;
|
||||||
|
|
||||||
const canSaveThisMonth = leftover > 0;
|
const canSaveThisMonth = leftover > 0;
|
||||||
@ -552,6 +566,12 @@ if (loanDeferralUntilGraduation && wasInDeferral && !stillInCollege) {
|
|||||||
shortfall -= canCover;
|
shortfall -= canCover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// any remaining shortfall becomes negative cash (credit/overdraft)
|
||||||
|
if (shortfall > 0) {
|
||||||
|
cashBalance -= shortfall;
|
||||||
|
shortfall = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Surplus => leftover
|
// Surplus => leftover
|
||||||
if (canSaveThisMonth && leftover > 0) {
|
if (canSaveThisMonth && leftover > 0) {
|
||||||
const totalPct = surplusEmergencyAllocation + surplusRetirementAllocation;
|
const totalPct = surplusEmergencyAllocation + surplusRetirementAllocation;
|
||||||
@ -598,7 +618,8 @@ if (loanDeferralUntilGraduation && wasInDeferral && !stillInCollege) {
|
|||||||
loanBalance: +loanBalance.toFixed(2),
|
loanBalance: +loanBalance.toFixed(2),
|
||||||
|
|
||||||
loanPaymentThisMonth: +(monthlyLoanPayment + extraPayment).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),
|
fedYTDgross: +fedYTDgross.toFixed(2),
|
||||||
fedYTDtax: +fedYTDtax.toFixed(2),
|
fedYTDtax: +fedYTDtax.toFixed(2),
|
||||||
|
@ -23,7 +23,8 @@ export const MISSING_LABELS = {
|
|||||||
loan_term: 'Loan term',
|
loan_term: 'Loan term',
|
||||||
expected_graduation: 'Expected graduation date',
|
expected_graduation: 'Expected graduation date',
|
||||||
program_type: 'Program type',
|
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');
|
if (!hasValue(scenario.retirement_start_date)) missing.push('retirement_start_date');
|
||||||
|
|
||||||
// ── Financial base
|
// ── 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)) {
|
if (!any(scenario.planned_monthly_expenses, financial.monthly_expenses)) {
|
||||||
missing.push('monthly_expenses');
|
missing.push('monthly_expenses');
|
||||||
@ -69,6 +71,12 @@ export default function getMissingFields(
|
|||||||
// ── College – only if they’re enrolled / prospective
|
// ── College – only if they’re enrolled / prospective
|
||||||
if (requireCollegeData) {
|
if (requireCollegeData) {
|
||||||
if (!hasValue(college.tuition)) missing.push('tuition');
|
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 =
|
const plansToBorrow =
|
||||||
(Number(college.existing_college_debt) || 0) > 0 ||
|
(Number(college.existing_college_debt) || 0) > 0 ||
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
// 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 API_ROOT = (import.meta?.env?.VITE_API_BASE || '').replace(/\/+$/, '');
|
||||||
@ -12,9 +11,40 @@ export async function loadDraft() {
|
|||||||
return res.json(); // null or { id, step, data }
|
return res.json(); // null or { id, step, data }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveDraft({ id = null, step = 0, 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;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await authFetch(DRAFT_URL, {
|
const res = await authFetch(DRAFT_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, step, data }),
|
body: JSON.stringify({ id, step, data }),
|
||||||
});
|
});
|
||||||
if (!res) return null;
|
if (!res) return null;
|
||||||
|
Loading…
Reference in New Issue
Block a user