selectedCareer in state for navigation acceptance in Roadmap, etc.

This commit is contained in:
Josh 2025-06-13 20:45:31 +00:00
parent 8232fd697e
commit 2728378041
11 changed files with 1103 additions and 172 deletions

51
package-lock.json generated
View File

@ -16,7 +16,9 @@
"axios": "^1.7.9",
"bcrypt": "^5.1.1",
"chart.js": "^4.4.7",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-annotation": "^3.1.0",
"chartjs-plugin-zoom": "^2.2.0",
"class-variance-authority": "^0.7.1",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
@ -4166,6 +4168,12 @@
"@types/node": "*"
}
},
"node_modules/@types/hammerjs": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
"license": "MIT"
},
"node_modules/@types/html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@ -6384,6 +6392,16 @@
"pnpm": ">=8"
}
},
"node_modules/chartjs-adapter-date-fns": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
"license": "MIT",
"peerDependencies": {
"chart.js": ">=2.8.0",
"date-fns": ">=2.0.0"
}
},
"node_modules/chartjs-plugin-annotation": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
@ -6393,6 +6411,19 @@
"chart.js": ">=4.0.0"
}
},
"node_modules/chartjs-plugin-zoom": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz",
"integrity": "sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==",
"license": "MIT",
"dependencies": {
"@types/hammerjs": "^2.0.45",
"hammerjs": "^2.0.8"
},
"peerDependencies": {
"chart.js": ">=3.2.0"
}
},
"node_modules/check-types": {
"version": "11.2.3",
"resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz",
@ -7449,6 +7480,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@ -10001,6 +10043,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/hammerjs": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
"integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/handle-thing": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",

View File

@ -11,7 +11,9 @@
"axios": "^1.7.9",
"bcrypt": "^5.1.1",
"chart.js": "^4.4.7",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-annotation": "^3.1.0",
"chartjs-plugin-zoom": "^2.2.0",
"class-variance-authority": "^0.7.1",
"classnames": "^2.5.1",
"clsx": "^2.1.1",

View File

@ -430,7 +430,7 @@ function App() {
}
/>
<Route
path="/career-roadmap"
path="/career-roadmap/:careerId?"
element={
<PremiumRoute user={user}>
<CareerRoadmap />

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useMemo, useCallback, useContext } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import CareerSuggestions from './CareerSuggestions.js';
@ -664,35 +664,48 @@ function CareerExplorer() {
// ------------------------------------------------------
// "Select for Education" => navigate with CIP codes
// ------------------------------------------------------
const handleSelectForEducation = (career) => {
// 1) Confirm
const confirmed = window.confirm(
`Are you sure you want to move on to Educational Programs for ${career.title}?`
);
if (!confirmed) return;
localStorage.setItem("selectedCareer", JSON.stringify(career));
// 2) Look up CIP codes from masterCareerRatings by SOC code
const matching = masterCareerRatings.find((r) => r.soc_code === career.code);
if (!matching) {
alert(`No CIP codes found for ${career.title}.`);
return;
}
// CareerExplorer.js
const handleSelectForEducation = (career) => {
if (!career) return;
// 3) Clean CIP codes
const rawCips = matching.cip_codes || [];
const cleanedCips = cleanCipCodes(rawCips);
// ─── 1. Ask first ─────────────────────────────────────────────
const ok = window.confirm(
`Are you sure you want to move on to Educational Programs for “${career.title}”?`
);
if (!ok) return;
// 4) Navigate
navigate('/educational-programs', {
state: {
socCode: career.code,
cipCodes: cleanedCips,
careerTitle: career.title,
userZip: userZipcode,
userState: userState,
},
});
// ─── 2. Make sure we have a full SOC code ─────────────────────
const fullSoc = career.soc_code || career.code || '';
if (!fullSoc) {
alert('Sorry this career is missing a valid SOC code.');
return;
}
// ─── 3. Find & clean CIP codes (may be empty) ─────────────────
const match = masterCareerRatings.find(r => r.soc_code === fullSoc);
const rawCips = match?.cip_codes ?? []; // original array
const cleanedCips = cleanCipCodes(rawCips); // “0402”, “1409”, …
// ─── 4. Persist ONE tidy object for later pages ───────────────
const careerForStorage = {
...career,
soc_code : fullSoc,
cip_code : rawCips // keep the raw list; page cleans them again if needed
};
localStorage.setItem('selectedCareer', JSON.stringify(careerForStorage));
// ─── 5. Off we go ─────────────────────────────────────────────
navigate('/educational-programs', {
state: {
socCode : fullSoc,
cipCodes : cleanedCips, // can be [], page handles it
careerTitle : career.title,
userZip : userZipcode,
userState : userState
}
});
};
// ------------------------------------------------------
// Filter logic for jobZone, Fit

View File

@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Line, Bar } from 'react-chartjs-2';
import { format } from 'date-fns'; // ⬅ install if not already
import zoomPlugin from 'chartjs-plugin-zoom';
import axios from 'axios';
import {
Chart as ChartJS,
@ -11,16 +13,22 @@ import {
Filler,
PointElement,
Tooltip,
TimeScale,
Legend
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import MilestonePanel from './MilestonePanel.js';
import MilestoneEditModal from './MilestoneEditModal.js';
import buildChartMarkers from '../utils/buildChartMarkers.js';
import getMissingFields from '../utils/MissingFields.js';
import 'chartjs-adapter-date-fns';
import authFetch from '../utils/authFetch.js';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
import { getFullStateName } from '../utils/stateUtils.js';
import CareerCoach from "./CareerCoach.js";
import { Button } from './ui/button.js';
import { Pencil } from 'lucide-react';
import ScenarioEditModal from './ScenarioEditModal.js';
import parseAIJson from "../utils/parseAIJson.js"; // your shared parser
@ -37,13 +45,74 @@ ChartJS.register(
BarElement,
CategoryScale,
LinearScale,
TimeScale,
Filler,
PointElement,
Tooltip,
Legend,
zoomPlugin, // 👈 ←–––– only if you kept the zoom config
annotationPlugin
);
/* ----------------------------------------------------------- *
* Helpers for remember last career logic
* ----------------------------------------------------------- */
// (A) getAllCareerProfiles one small wrapper around the endpoint
async function getAllCareerProfiles() {
const res = await authFetch('/api/premium/career-profile/all');
if (!res.ok) throw new Error('career-profile/all failed');
const json = await res.json();
return json.careerProfiles || [];
}
// (B) createCareerProfileFromSearch called when user chose a SOC with
// no existing career-profile row. Feel free to add more fields.
async function createCareerProfileFromSearch(selCareer) {
const careerName = (selCareer.title || '').trim();
if (!careerName) {
throw new Error('createCareerProfileFromSearch: selCareer.title is required');
}
/* -----------------------------------------------------------
* 1) Do we already have that title?
* --------------------------------------------------------- */
const all = await getAllCareerProfiles(); // wrapper uses authFetch
const existing = all.find(
p => (p.career_name || '').trim().toLowerCase() === careerName.toLowerCase()
);
if (existing) return existing; // ✅ reuse the row / id
/* -----------------------------------------------------------
* 2) Otherwise create it and refetch the full row
* --------------------------------------------------------- */
const payload = {
career_name : careerName,
scenario_title: careerName,
start_date : new Date().toISOString().slice(0, 10)
};
const post = await authFetch('/api/premium/career-profile', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(payload)
});
if (!post.ok) {
throw new Error(`career-profile create failed (${post.status})`);
}
const { career_profile_id: newId } = await post.json();
if (!newId) throw new Error('server did not return career_profile_id');
const get = await authFetch(`/api/premium/career-profile/${newId}`);
if (get.ok) return await get.json(); // full row with every column
// Extremely rare fallback
return { id: newId, career_name: careerName, scenario_title: careerName };
}
// --------------
// Helper Functions
// --------------
@ -59,6 +128,7 @@ function getRelativePosition(userSal, p10, p90) {
return (userSal - p10) / (p90 - p10);
}
// A simple gauge for the users salary vs. percentiles
function SalaryGauge({ userSalary, percentileRow, prefix = 'regional' }) {
if (!percentileRow) return null;
@ -239,6 +309,7 @@ function getYearsInCareer(startDateString) {
export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const { careerId } = useParams();
const location = useLocation();
const apiURL = process.env.REACT_APP_API_URL;
@ -266,6 +337,8 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const [scenarioMilestones, setScenarioMilestones] = useState([]);
const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
const [milestoneForModal, setMilestoneForModal] = useState(null);
const [hasPrompted, setHasPrompted] = useState(false);
// Config
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
@ -288,6 +361,81 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
loanPayoffMonth: initLoanMonth = null
} = location.state || {};
const milestoneGroups = useMemo(() => {
if (!scenarioMilestones.length) return [];
const buckets = {};
scenarioMilestones.forEach(m => {
if (!m.date) return;
const monthKey = m.date.slice(0, 7); // “2026-04”
(buckets[monthKey] = buckets[monthKey] || []).push(m);
});
return Object.entries(buckets)
.map(([month, items]) => ({
month,
monthLabel: format(new Date(`${month}-01`), 'MMM yyyy'),
items
}))
.sort((a, b) => (a.month > b.month ? 1 : -1));
}, [scenarioMilestones]);
/* ---------- build thin orange milestone markers + loan-payoff line ---------- */
const markerAnnotations = useMemo(
() => buildChartMarkers(milestoneGroups),
[milestoneGroups]
);
const loanPayoffLine = useMemo(() => {
const hasStudentLoan = projectionData.some((p) => p.loanBalance > 0);
if (!hasStudentLoan || !loanPayoffMonth) return {}; // <-- guard added
return {
loanPaidOff: {
type: 'line',
xMin: loanPayoffMonth,
xMax: loanPayoffMonth,
borderColor: 'rgba(255,206,86,1)',
borderWidth: 2,
borderDash: [6, 6],
label: {
display: true,
content: 'Loan Paid Off',
position: 'end',
backgroundColor: 'rgba(255,206,86,0.8)',
color: '#000',
font: { size: 12 },
yAdjust: -10
}
}
};
}, [loanPayoffMonth]);
const allAnnotations = useMemo(
() => ({ ...markerAnnotations, ...loanPayoffLine }),
[markerAnnotations, loanPayoffLine]
);
/* -------- shared chart config -------- */
const zoomConfig = {
pan: { enabled: true, mode: 'x' },
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' }
};
const xAndYScales = {
x: {
type: 'time',
time: { unit: 'month' },
ticks: { maxRotation: 0, autoSkip: true }
},
y: {
beginAtZero: true,
ticks: {
callback: (val) => val.toLocaleString() // comma-format big numbers
}
}
};
// 1) Fetch user + financial
useEffect(() => {
async function fetchUser() {
@ -308,12 +456,24 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
}
fetchUser();
fetchFin();
}, [apiURL]);
}, []);
const userSalary = parseFloatOrZero(financialProfile?.current_salary, 0);
const userArea = userProfile?.area || 'U.S.';
const userState = getFullStateName(userProfile?.state || '') || 'United States';
useEffect(() => {
if (careerId) {
setCareerProfileId(careerId);
localStorage.setItem('lastSelectedCareerProfileId', careerId);
} else {
// first visit with no id → try LS fallback
const stored = localStorage.getItem('lastSelectedCareerProfileId');
if (stored) setCareerProfileId(stored);
}
}, [careerId]);
useEffect(() => {
let timer;
if (buttonDisabled) {
@ -339,6 +499,24 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
}
}, []);
useEffect(() => {
// Wait until all three profiles have loaded at least once
if (!scenarioRow || !financialProfile || collegeProfile === null) return;
if (hasPrompted) return; // dont pop it again
const missing = getMissingFields({
scenario : scenarioRow,
financial: financialProfile,
college : collegeProfile
});
if (missing.length > 0) {
setShowEditModal(true); // open modal
setHasPrompted(true); // flag so its one-time
}
}, [scenarioRow, financialProfile, collegeProfile, hasPrompted]);
useEffect(() => {
if (recommendations.length > 0) {
localStorage.setItem('aiRecommendations', JSON.stringify(recommendations));
@ -361,50 +539,92 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
}, []);
// 3) fetch users career-profiles
useEffect(() => {
async function fetchProfiles() {
const r = await authFetch(`${apiURL}/premium/career-profile/all`);
if (!r || !r.ok) return;
const d = await r.json();
setExistingCareerProfiles(d.careerProfiles);
// utilities you already have in this file
// • getAllCareerProfiles()
// • createCareerProfileFromSearch()
const fromPopout = location.state?.selectedCareer;
if (fromPopout) {
setSelectedCareer(fromPopout);
setCareerProfileId(fromPopout.career_profile_id);
} else {
const stored = localStorage.getItem('lastSelectedCareerProfileId');
if (stored) {
const match = d.careerProfiles.find((p) => p.id === stored);
if (match) {
setSelectedCareer(match);
setCareerProfileId(stored);
return;
}
}
// fallback => latest
const lr = await authFetch(`${apiURL}/premium/career-profile/latest`);
if (lr && lr.ok) {
const ld = await lr.json();
if (ld?.id) {
setSelectedCareer(ld);
setCareerProfileId(ld.id);
}
}
useEffect(() => {
let cancelled = false;
(async function init () {
/* 1 ▸ get every row the user owns */
const r = await authFetch(`${apiURL}/premium/career-profile/all`);
if (!r?.ok || cancelled) return;
const { careerProfiles=[] } = await r.json();
setExistingCareerProfiles(careerProfiles);
/* 2 ▸ what does the UI say the user just picked? */
const chosen =
location.state?.selectedCareer ??
JSON.parse(localStorage.getItem('selectedCareer') || '{}');
/* 2A ▸ they clicked a career elsewhere in the app */
if (chosen.code) {
let row = careerProfiles.find(p => p.soc_code === chosen.code);
if (!row) {
try { row = await createCareerProfileFromSearch(chosen); }
catch { /* swallow API will have logged */ }
}
if (row && !cancelled) {
setCareerProfileId(row.id);
setSelectedCareer(row);
localStorage.setItem('lastSelectedCareerProfileId', row.id);
}
/* clear the one-shot navigate state */
if (!cancelled) window.history.replaceState({}, '', location.pathname);
return;
}
/* 2B ▸ deep-link /career-roadmap/:id */
if (careerId) {
const row = careerProfiles.find(p => String(p.id) === String(careerId));
if (row && !cancelled) {
setCareerProfileId(row.id);
setSelectedCareer(row);
localStorage.setItem('lastSelectedCareerProfileId', row.id);
}
return;
}
/* 2C ▸ last profile the user touched */
const stored = localStorage.getItem('lastSelectedCareerProfileId');
if (stored) {
const row = careerProfiles.find(p => String(p.id) === stored);
if (row && !cancelled) {
setCareerProfileId(row.id);
setSelectedCareer(row);
return;
}
}
fetchProfiles();
}, [apiURL, location.state]);
/* 2D ▸ otherwise: newest profile, if any */
if (careerProfiles.length && !cancelled) {
const latest = careerProfiles.at(-1); // ASC order → last = newest
setCareerProfileId(latest.id);
setSelectedCareer(latest);
localStorage.setItem('lastSelectedCareerProfileId', latest.id);
}
})();
return () => { cancelled = true; };
/* fires only when the navigation key changes or when :id changes */
}, [location.key, careerId]);
// 4) scenarioRow + college
useEffect(() => {
if (!careerProfileId) {
setScenarioRow(null);
setCollegeProfile(null);
setScenarioMilestones([]);
return;
}
localStorage.setItem('lastSelectedCareerProfileId', careerProfileId);
/** ---------------------------------------------------------------
* bail out IMMEDIATELY until we have a *real* id
* (the rest of the body never even runs)
* ------------------------------------------------------------- */
if (!careerProfileId) return; // ← nothing gets fetched
setScenarioRow(null); // clear stale data
setCollegeProfile(null);
setScenarioMilestones([]);
localStorage.setItem('lastSelectedCareerProfileId', careerProfileId);
async function fetchScenario() {
const s = await authFetch(`${apiURL}/premium/career-profile/${careerProfileId}`);
@ -416,7 +636,7 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
}
fetchScenario();
fetchCollege();
}, [careerProfileId, apiURL]);
}, [careerProfileId]);
// 5) from scenarioRow => find the full SOC => strip
useEffect(() => {
@ -552,7 +772,7 @@ try {
setSalaryData(null);
}
})();
}, [strippedSocCode, userArea, apiURL]);
}, [strippedSocCode, userArea]);
// 7) Econ
@ -578,7 +798,7 @@ try {
setEconomicProjections(null);
}
})();
}, [strippedSocCode, userState, apiURL]);
}, [strippedSocCode, userState]);
// 8) Build financial projection
async function buildProjection() {
@ -745,33 +965,9 @@ try {
useEffect(() => {
if (!financialProfile || !scenarioRow || !collegeProfile) return;
buildProjection();
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, apiURL, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]);
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]);
// Build chart datasets / annotations
const milestoneAnnotationLines = {};
scenarioMilestones.forEach((m) => {
if (!m.date) return;
const d = new Date(m.date);
if (isNaN(d)) return;
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const short = `${yyyy}-${mm}`;
if (!projectionData.some((p) => p.month === short)) return;
milestoneAnnotationLines[`milestone_${m.id}`] = {
type: 'line',
xMin: short,
xMax: short,
borderColor: 'orange',
borderWidth: 2,
label: {
display: true,
content: m.title || 'Milestone',
color: 'orange',
position: 'end'
}
};
});
const [clickCount, setClickCount] = useState(() => {
const storedCount = localStorage.getItem('aiClickCount');
@ -786,30 +982,8 @@ try {
});
const DAILY_CLICK_LIMIT = 10; // example limit per day
const hasStudentLoan = projectionData.some((p) => p.loanBalance > 0);
const annotationConfig = {};
if (loanPayoffMonth && hasStudentLoan) {
annotationConfig.loanPaidOffLine = {
type: 'line',
xMin: loanPayoffMonth,
xMax: loanPayoffMonth,
borderColor: 'rgba(255, 206, 86, 1)',
borderWidth: 2,
borderDash: [6, 6],
label: {
display: true,
content: 'Loan Paid Off',
position: 'end',
backgroundColor: 'rgba(255, 206, 86, 0.8)',
color: '#000',
font: { size: 12 },
rotation: 0,
yAdjust: -10
}
};
}
const allAnnotations = { ...milestoneAnnotationLines, ...annotationConfig };
const emergencyData = {
label: 'Emergency Savings',
data: projectionData.map((p) => p.emergencySavings),
@ -847,6 +1021,12 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
}
};
const hasStudentLoan = useMemo(
() => projectionData.some(p => (p.loanBalance ?? 0) > 0),
[projectionData]
);
const chartDatasets = [emergencyData, retirementData];
if (hasStudentLoan) chartDatasets.push(loanBalanceData);
chartDatasets.push(totalSavingsData);
@ -859,10 +1039,6 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day
alert('You have reached the daily limit for suggestions.');
return;
}
if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
alert('You have reached your daily limit of AI-generated recommendations. Please check back tomorrow.');
return;
}
setAiLoading(true);
setSelectedIds([]);
@ -918,6 +1094,37 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
}
const buttonLabel = recommendations.length > 0 ? 'New Suggestions' : 'What Should I Do Next?';
const chartRef = useRef(null);
const onEditMilestone = useCallback((m) => {
setMilestoneForModal(m); // open modal
}, []);
const fetchMilestones = useCallback(async () => {
if (!careerProfileId) {
setScenarioMilestones([]);
return;
}
try {
const res = await authFetch(
`${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`
);
if (!res.ok) return;
const data = await res.json();
const allMilestones = data.milestones || [];
setScenarioMilestones(allMilestones);
/* impacts (optional only if you still need them in CR) */
// ... fetch impacts here if CareerRoadmap charts rely on them ...
} catch (err) {
console.error('Error fetching milestones', err);
}
}, [careerProfileId, apiURL]);
return (
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-4">
@ -1053,44 +1260,62 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
</p>
</div>*/}
{/* 5) Financial Projection */}
<div className="bg-white p-4 rounded shadow">
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
{projectionData.length > 0 ? (
<>
<Line
data={{
labels: projectionData.map((p) => p.month),
datasets: chartDatasets
}}
options={{
responsive: true,
plugins: {
legend: { position: 'bottom' },
tooltip: { mode: 'index', intersect: false },
annotation: { annotations: allAnnotations }
},
scales: {
y: {
beginAtZero: false,
ticks: {
callback: (val) => `$${val.toLocaleString()}`
}
}
}
}}
/>
{loanPayoffMonth && hasStudentLoan && (
<p className="font-semibold text-sm mt-2">
Loan Paid Off at:{' '}
<span className="text-yellow-600">{loanPayoffMonth}</span>
</p>
)}
</>
) : (
<p className="text-sm text-gray-500">No financial projection data found.</p>
{/* --- FINANCIAL PROJECTION SECTION -------------------------------- */}
<div className="bg-white p-4 rounded shadow">
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
{projectionData.length ? (
<div className="flex flex-col md:flex-row gap-4">
{/* Milestone list / editor */}
<MilestonePanel
className="md:w-56"
groups={milestoneGroups}
onEdit={onEditMilestone} /* <-- use your existing handler */
/>
{/* Chart */}
<div className="flex-1">
<div style={{ height: 360, width: '100%' }}>
<Line
ref={chartRef}
data={{
labels: projectionData.map(p => p.month),
datasets: chartDatasets
}}
options={{
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' },
tooltip: { mode: 'index', intersect: false },
annotation: { annotations: allAnnotations }, // ✅ new
zoom: zoomConfig
},
scales: xAndYScales // unchanged
}}
/>
</div>
<Button
onClick={() => chartRef.current?.resetZoom()}
className="mt-2 text-xs text-blue-600 hover:underline"
>
Reset Zoom
</Button>
{loanPayoffMonth && hasStudentLoan && (
<p className="font-semibold text-sm mt-2">
Loan Paid Off:&nbsp;
<span className="text-yellow-600">{loanPayoffMonth}</span>
</p>
)}
</div>
</div>
) : (
<p className="text-sm text-gray-500">No financial projection data found.</p>
)}
</div>
{/* 6) Simulation length + Edit scenario */}
<div className="mt-4 space-x-2">
@ -1103,7 +1328,7 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
className="border rounded p-1 w-16"
/>
<Button onClick={() => setShowEditModal(true)} className="ml-2">
Edit
Edit Simulation Inputs
</Button>
</div>
<ScenarioEditModal
@ -1168,6 +1393,19 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
/>
</div>
)}
{milestoneForModal && (
<MilestoneEditModal
careerProfileId={careerProfileId} // number
milestones={scenarioMilestones} // array
fetchMilestones={fetchMilestones} // helper to refresh list
onClose={(didSave) => {
setMilestoneForModal(false); // or setShowMilestoneModal(false)
if (didSave) fetchMilestones();
}}
/>
)}
{/* 7) AI Next Steps */}
{/* <div className="bg-white p-4 rounded shadow mt-4">

View File

@ -63,11 +63,14 @@ function renderLevel(val) {
function EducationalProgramsPage() {
const location = useLocation();
const navigate = useNavigate();
const [socCode, setsocCode] = useState(location.state?.socCode || '');
const [cipCodes, setCipCodes] = useState(location.state?.cipCodes || []);
const [userState, setUserState] = useState(location.state?.userState || '');
const [userZip, setUserZip] = useState(location.state?.userZip || '');
const { state } = useLocation();
const navCareer = state?.selectedCareer || {};
const [selectedCareer, setSelectedCareer] = useState(navCareer);
const [socCode, setSocCode] = useState(navCareer.code || '');
const [cipCodes, setCipCodes] = useState(navCareer.cipCodes || []);
const [careerTitle, setCareerTitle] = useState(navCareer.title || '');
const [userState, setUserState]= useState(navCareer.userState || '');
const [userZip, setUserZip] = useState(navCareer.userZip || '');
const [allKsaData, setAllKsaData] = useState([]);
const [ksaForCareer, setKsaForCareer] = useState([]);
@ -83,8 +86,7 @@ function EducationalProgramsPage() {
const [maxTuition, setMaxTuition] = useState(20000);
const [maxDistance, setMaxDistance] = useState(100);
const [inStateOnly, setInStateOnly] = useState(false);
const [careerTitle, setCareerTitle] = useState(location.state?.careerTitle || '');
const [selectedCareer, setSelectedCareer] = useState(location.state?.foundObj || '');
const [showSearch, setShowSearch] = useState(true);
// If user picks a new career from CareerSearch
@ -99,7 +101,7 @@ function EducationalProgramsPage() {
return codeStr.replace('.', '').slice(0, 4);
});
setCipCodes(cleanedCips);
setsocCode(foundObj.soc_code);
setSocCode(foundObj.soc_code);
setShowSearch(false);
};
@ -137,6 +139,23 @@ function EducationalProgramsPage() {
];
}
useEffect(() => {
if (!location.state) return; // nothing passed
const {
socCode : newSoc,
cipCodes : newCips = [],
careerTitle : newTitle = '',
selectedCareer: navCareer // optional convenience payload
} = location.state;
if (newSoc) setSocCode(newSoc);
if (newCips.length) setCipCodes(newCips);
if (newTitle) setCareerTitle(newTitle);
if (navCareer) setSelectedCareer(navCareer);
/* if *any* career info arrived we dont need the search box */
if (newSoc || navCareer) setShowSearch(false);
}, [location.state]);
// Load KSA data once
useEffect(() => {
async function loadKsaData() {
@ -232,7 +251,7 @@ useEffect(() => {
const cleanedCips = rawCips.map((code) => code.toString().replace('.', '').slice(0, 4));
setCipCodes(cleanedCips);
setsocCode(parsed.soc_code);
setSocCode(parsed.soc_code);
setShowSearch(false);
}

View File

@ -0,0 +1,542 @@
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "./ui/button.js";
import authFetch from "../utils/authFetch.js";
import parseFloatOrZero from "../utils/ParseFloatorZero.js";
import MilestoneCopyWizard from "./MilestoneCopyWizard.js";
/**
* Fullscreen overlay for creating / editing milestones + impacts + tasks.
* Extracted from ScenarioContainer so it can be shared with CareerRoadmap.
*
* Props
*
* careerProfileId number (required)
* milestones array of milestone objects to edit
* fetchMilestones async fn to refresh parent after a save/delete
* onClose(bool) close overlay. param = true if data changed
*/
export default function MilestoneEditModal({
careerProfileId,
milestones: incomingMils = [],
fetchMilestones,
onClose
}) {
/*
Local state mirrors ScenarioContainer
*/
const [milestones, setMilestones] = useState(incomingMils);
const [editingMilestoneId, setEditingMilestoneId] = useState(null);
const [newMilestoneMap, setNewMilestoneMap] = useState({});
const [impactsToDeleteMap, setImpactsToDeleteMap] = useState({});
const [addingNewMilestone, setAddingNewMilestone] = useState(false);
const [newMilestoneData, setNewMilestoneData] = useState({
title: "",
description: "",
date: "",
progress: 0,
newSalary: "",
impacts: [],
isUniversal: 0
});
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
/* keep milestones in sync with prop */
useEffect(() => {
setMilestones(incomingMils);
}, [incomingMils]);
/*
Inlineedit helpers (trimmed copy of ScenarioContainer logic)
*/
const loadMilestoneImpacts = useCallback(async (m) => {
try {
const impRes = await authFetch(
`/api/premium/milestone-impacts?milestone_id=${m.id}`
);
if (!impRes.ok) throw new Error("impact fetch failed");
const data = await impRes.json();
const impacts = (data.impacts || []).map((imp) => ({
id: imp.id,
impact_type: imp.impact_type || "ONE_TIME",
direction: imp.direction || "subtract",
amount: imp.amount || 0,
start_date: imp.start_date || "",
end_date: imp.end_date || ""
}));
setNewMilestoneMap((prev) => ({
...prev,
[m.id]: {
title: m.title || "",
description: m.description || "",
date: m.date || "",
progress: m.progress || 0,
newSalary: m.new_salary || "",
impacts,
isUniversal: m.is_universal ? 1 : 0
}
}));
setEditingMilestoneId(m.id);
setImpactsToDeleteMap((prev) => ({ ...prev, [m.id]: [] }));
} catch (err) {
console.error("loadImpacts", err);
}
}, []);
const handleEditMilestoneInline = (m) => {
if (editingMilestoneId === m.id) {
setEditingMilestoneId(null);
} else {
loadMilestoneImpacts(m);
}
};
const updateInlineImpact = (milestoneId, idx, field, value) => {
setNewMilestoneMap((prev) => {
const copy = { ...prev };
const item = copy[milestoneId];
if (!item) return prev;
const impactsClone = [...item.impacts];
impactsClone[idx] = { ...impactsClone[idx], [field]: value };
copy[milestoneId] = { ...item, impacts: impactsClone };
return copy;
});
};
const addInlineImpact = (milestoneId) => {
setNewMilestoneMap((prev) => {
const itm = prev[milestoneId];
if (!itm) return prev;
const impactsClone = [...itm.impacts, {
impact_type: "ONE_TIME",
direction: "subtract",
amount: 0,
start_date: "",
end_date: ""
}];
return { ...prev, [milestoneId]: { ...itm, impacts: impactsClone } };
});
};
const removeInlineImpact = (mid, idx) => {
setNewMilestoneMap((prev) => {
const itm = prev[mid];
if (!itm) return prev;
const impactsClone = [...itm.impacts];
const [removed] = impactsClone.splice(idx, 1);
setImpactsToDeleteMap((p) => ({
...p,
[mid]: [...(p[mid] || []), removed.id].filter(Boolean)
}));
return { ...prev, [mid]: { ...itm, impacts: impactsClone } };
});
};
const saveInlineMilestone = async (m) => {
const data = newMilestoneMap[m.id];
if (!data) return;
const payload = {
milestone_type: "Financial",
title: data.title,
description: data.description,
date: data.date,
career_profile_id: careerProfileId,
progress: data.progress,
status: data.progress >= 100 ? "completed" : "planned",
new_salary: data.newSalary ? parseFloat(data.newSalary) : null,
is_universal: data.isUniversal || 0
};
try {
const res = await authFetch(`/api/premium/milestones/${m.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
const saved = await res.json();
/* impacts */
const toDelete = impactsToDeleteMap[m.id] || [];
for (const delId of toDelete) {
await authFetch(`/api/premium/milestone-impacts/${delId}`, {
method: "DELETE"
});
}
for (const imp of data.impacts) {
const impPayload = {
milestone_id: saved.id,
impact_type: imp.impact_type,
direction: imp.direction,
amount: parseFloat(imp.amount) || 0,
start_date: imp.start_date || null,
end_date: imp.end_date || null
};
if (imp.id) {
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(impPayload)
});
} else {
await authFetch("/api/premium/milestone-impacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(impPayload)
});
}
}
await fetchMilestones();
setEditingMilestoneId(null);
onClose(true);
} catch (err) {
alert("Failed to save milestone");
console.error(err);
}
};
/* brandnew milestone helpers (trimmed) */
const addNewImpactToNewMilestone = () => {
setNewMilestoneData((p) => ({
...p,
impacts: [
...p.impacts,
{
impact_type: "ONE_TIME",
direction: "subtract",
amount: 0,
start_date: "",
end_date: ""
}
]
}));
};
const saveNewMilestone = async () => {
if (!newMilestoneData.title.trim() || !newMilestoneData.date.trim()) {
alert("Need title and date");
return;
}
const payload = {
title: newMilestoneData.title,
description: newMilestoneData.description,
date: newMilestoneData.date,
career_profile_id: careerProfileId,
progress: newMilestoneData.progress,
status: newMilestoneData.progress >= 100 ? "completed" : "planned",
is_universal: newMilestoneData.isUniversal || 0
};
try {
const res = await authFetch("/api/premium/milestone", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
const created = Array.isArray(await res.json()) ? (await res.json())[0] : await res.json();
// impacts
for (const imp of newMilestoneData.impacts) {
const impPayload = {
milestone_id: created.id,
impact_type: imp.impact_type,
direction: 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", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(impPayload)
});
}
await fetchMilestones();
onClose(true);
} catch (err) {
alert("Failed to save milestone");
}
};
/*
Render
*/
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
zIndex: 9999,
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
overflowY: "auto"
}}
>
<div
style={{
background: "#fff",
width: "800px",
padding: "1rem",
margin: "2rem auto",
borderRadius: "4px"
}}
>
<h3>Edit Milestones</h3>
{milestones.map((m) => {
const hasEditOpen = editingMilestoneId === m.id;
const data = newMilestoneMap[m.id] || {};
return (
<div key={m.id} style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "1rem" }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<h5 style={{ margin: 0 }}>{m.title}</h5>
<Button onClick={() => handleEditMilestoneInline(m)}>
{hasEditOpen ? "Cancel" : "Edit"}
</Button>
</div>
<p>{m.description}</p>
<p>
<strong>Date:</strong> {m.date}
</p>
<p>Progress: {m.progress}%</p>
{/* inline form */}
{hasEditOpen && (
<div style={{ border: "1px solid #aaa", marginTop: "1rem", padding: "0.5rem" }}>
<input
type="text"
placeholder="Title"
value={data.title}
style={{ display: "block", marginBottom: "0.5rem" }}
onChange={(e) =>
setNewMilestoneMap((p) => ({
...p,
[m.id]: { ...p[m.id], title: e.target.value }
}))
}
/>
<textarea
placeholder="Description"
value={data.description}
style={{ display: "block", width: "100%", marginBottom: "0.5rem" }}
onChange={(e) =>
setNewMilestoneMap((p) => ({
...p,
[m.id]: { ...p[m.id], description: e.target.value }
}))
}
/>
<label>Date:</label>
<input
type="date"
value={data.date || ""}
style={{ display: "block", marginBottom: "0.5rem" }}
onChange={(e) =>
setNewMilestoneMap((p) => ({
...p,
[m.id]: { ...p[m.id], date: e.target.value }
}))
}
/>
{/* impacts */}
<div style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}>
<h6>Impacts</h6>
{data.impacts?.map((imp, idx) => (
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
<label>Type:</label>
<select
value={imp.impact_type}
onChange={(e) => updateInlineImpact(m.id, idx, "impact_type", e.target.value)}
>
<option value="ONE_TIME">One-Time</option>
<option value="MONTHLY">Monthly</option>
</select>
<label>Direction:</label>
<select
value={imp.direction}
onChange={(e) => updateInlineImpact(m.id, idx, "direction", e.target.value)}
>
<option value="add">Add</option>
<option value="subtract">Subtract</option>
</select>
<label>Amount:</label>
<input
type="number"
value={imp.amount}
onChange={(e) => updateInlineImpact(m.id, idx, "amount", e.target.value)}
/>
<label>Start:</label>
<input
type="date"
value={imp.start_date || ""}
onChange={(e) => updateInlineImpact(m.id, idx, "start_date", e.target.value)}
/>
{imp.impact_type === "MONTHLY" && (
<>
<label>End:</label>
<input
type="date"
value={imp.end_date || ""}
onChange={(e) => updateInlineImpact(m.id, idx, "end_date", e.target.value)}
/>
</>
)}
<Button onClick={() => removeInlineImpact(m.id, idx)} style={{ marginLeft: "0.5rem", color: "red" }}>
Remove
</Button>
</div>
))}
<Button onClick={() => addInlineImpact(m.id)}>+ Impact</Button>
</div>
<Button onClick={() => saveInlineMilestone(m)}>Save</Button>
</div>
)}
</div>
);
})}
{/* addnew toggle */}
<Button onClick={() => setAddingNewMilestone((p) => !p)}>
{addingNewMilestone ? "Cancel New Milestone" : "Add Milestone"}
</Button>
{addingNewMilestone && (
<div style={{ border: "1px solid #aaa", padding: "0.5rem", marginTop: "0.5rem" }}>
<input
type="text"
placeholder="Title"
value={newMilestoneData.title}
style={{ display: "block", marginBottom: "0.5rem" }}
onChange={(e) => setNewMilestoneData((p) => ({ ...p, title: e.target.value }))}
/>
<textarea
placeholder="Description"
value={newMilestoneData.description}
style={{ display: "block", width: "100%", marginBottom: "0.5rem" }}
onChange={(e) => setNewMilestoneData((p) => ({ ...p, description: e.target.value }))}
/>
<label>Date:</label>
<input
type="date"
value={newMilestoneData.date || ""}
style={{ display: "block", marginBottom: "0.5rem" }}
onChange={(e) => setNewMilestoneData((p) => ({ ...p, date: e.target.value }))}
/>
<div style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}>
<h6>Impacts</h6>
{newMilestoneData.impacts.map((imp, idx) => (
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
<label>Type:</label>
<select
value={imp.impact_type}
onChange={(e) => {
const val = e.target.value;
setNewMilestoneData((prev) => {
const copy = [...prev.impacts];
copy[idx] = { ...copy[idx], impact_type: val };
return { ...prev, impacts: copy };
});
}}
>
<option value="ONE_TIME">One-Time</option>
<option value="MONTHLY">Monthly</option>
</select>
<label>Direction:</label>
<select
value={imp.direction}
onChange={(e) => {
const val = e.target.value;
setNewMilestoneData((prev) => {
const copy = [...prev.impacts];
copy[idx] = { ...copy[idx], direction: val };
return { ...prev, impacts: copy };
});
}}
>
<option value="add">Add</option>
<option value="subtract">Subtract</option>
</select>
<label>Amount:</label>
<input
type="number"
value={imp.amount}
onChange={(e) => {
const val = e.target.value;
setNewMilestoneData((prev) => {
const copy = [...prev.impacts];
copy[idx] = { ...copy[idx], amount: val };
return { ...prev, impacts: copy };
});
}}
/>
<label>Start:</label>
<input
type="date"
value={imp.start_date || ""}
onChange={(e) => {
const val = e.target.value;
setNewMilestoneData((prev) => {
const copy = [...prev.impacts];
copy[idx] = { ...copy[idx], start_date: val };
return { ...prev, impacts: copy };
});
}}
/>
{imp.impact_type === "MONTHLY" && (
<>
<label>End:</label>
<input
type="date"
value={imp.end_date || ""}
onChange={(e) => {
const val = e.target.value;
setNewMilestoneData((prev) => {
const copy = [...prev.impacts];
copy[idx] = { ...copy[idx], end_date: val };
return { ...prev, impacts: copy };
});
}}
/>
</>
)}
<Button
onClick={() => {
setNewMilestoneData((prev) => {
const cpy = [...prev.impacts];
cpy.splice(idx, 1);
return { ...prev, impacts: cpy };
});
}}
style={{ color: "red", marginLeft: "0.5rem" }}
>
Remove
</Button>
</div>
))}
<Button onClick={addNewImpactToNewMilestone}>+ Impact</Button>
</div>
<Button onClick={saveNewMilestone}>Add Milestone</Button>
</div>
)}
{/* Copy Wizard */}
{copyWizardMilestone && (
<MilestoneCopyWizard
milestone={copyWizardMilestone}
onClose={(didCopy) => {
setCopyWizardMilestone(null);
if (didCopy) fetchMilestones();
}}
/>
)}
<div style={{ marginTop: "1rem", textAlign: "right" }}>
<Button onClick={() => onClose(false)}>Close</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
import { Pencil } from 'lucide-react';
import { Button } from './ui/button.js';
/* MilestonePanel.jsx ---------------------------------- */
export default function MilestonePanel({ groups, onEdit }) {
return (
<aside className="w-full md:w-56 pr-4">
{groups.map(g => (
<details key={g.month} className="mb-2">
<summary className="cursor-pointer font-semibold">
{g.monthLabel} <span className="text-xs">({g.items.length})</span>
</summary>
<ul className="mt-1 space-y-2">
{g.items.map(m => (
<li key={m.id} className="flex items-start gap-1 text-sm">
<span className="flex-1">{m.title}</span>
<Button
onClick={() => onEdit(m)}
size="icon"
className="bg-transparent hover:bg-muted p-1 rounded-sm"
>
<Pencil className="h-4 w-4" />
</Button>
</li>
))}
</ul>
</details>
))}
</aside>
);
}

View File

@ -0,0 +1,18 @@
// utils/getMissingFields.js
export default function getMissingFields({ scenario, financial, college }) {
const missing = [];
if (!scenario?.career_name) missing.push('Target career');
if (!scenario?.start_date) missing.push('Career start date');
if (!financial?.current_salary) missing.push('Current salary');
if (!financial?.monthly_expenses) missing.push('Monthly expenses');
if (college?.college_enrollment_status === 'currently_enrolled') {
if (!college.expected_graduation) missing.push('Expected graduation');
if (!college.existing_college_debt)
missing.push('Student-loan balance');
}
return missing;
}

View File

@ -0,0 +1,15 @@
// buildChartMarkers.js
export default function buildChartMarkers(groups) {
const out = {};
groups.forEach(g => {
out[g.month] = {
type: 'line',
xMin: g.month,
xMax: g.month,
borderColor: 'orange',
borderWidth: 2,
// no label → chart stays clean
};
});
return out;
}

Binary file not shown.