selectedCareer in state for navigation acceptance in Roadmap, etc.
This commit is contained in:
parent
8232fd697e
commit
2728378041
51
package-lock.json
generated
51
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -430,7 +430,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/career-roadmap"
|
||||
path="/career-roadmap/:careerId?"
|
||||
element={
|
||||
<PremiumRoute user={user}>
|
||||
<CareerRoadmap />
|
||||
|
@ -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
|
||||
|
@ -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 user’s 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; // don’t pop it again
|
||||
|
||||
const missing = getMissingFields({
|
||||
scenario : scenarioRow,
|
||||
financial: financialProfile,
|
||||
college : collegeProfile
|
||||
});
|
||||
|
||||
if (missing.length > 0) {
|
||||
setShowEditModal(true); // open modal
|
||||
setHasPrompted(true); // flag so it’s 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 user’s 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:
|
||||
<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">
|
||||
|
@ -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 don’t 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);
|
||||
}
|
||||
|
542
src/components/MilestoneEditModal.js
Normal file
542
src/components/MilestoneEditModal.js
Normal 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";
|
||||
|
||||
/**
|
||||
* Full‑screen 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]);
|
||||
|
||||
/* ────────────────────────────────
|
||||
Inline‑edit 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);
|
||||
}
|
||||
};
|
||||
|
||||
/* brand‑new 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>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* add‑new 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>
|
||||
);
|
||||
}
|
33
src/components/MilestonePanel.js
Normal file
33
src/components/MilestonePanel.js
Normal 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>
|
||||
);
|
||||
}
|
18
src/utils/MissingFields.js
Normal file
18
src/utils/MissingFields.js
Normal 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;
|
||||
}
|
15
src/utils/buildChartMarkers.js
Normal file
15
src/utils/buildChartMarkers.js
Normal 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;
|
||||
}
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user