diff --git a/package-lock.json b/package-lock.json index a747312..d8b7a42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6ba4880..93fb9ae 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.js b/src/App.js index ad4a23a..9ff3241 100644 --- a/src/App.js +++ b/src/App.js @@ -430,7 +430,7 @@ function App() { } /> diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js index 111151a..daabbdf 100644 --- a/src/components/CareerExplorer.js +++ b/src/components/CareerExplorer.js @@ -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 diff --git a/src/components/CareerRoadmap.js b/src/components/CareerRoadmap.js index b3f397a..0b08432 100644 --- a/src/components/CareerRoadmap.js +++ b/src/components/CareerRoadmap.js @@ -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 (
@@ -1053,44 +1260,62 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {

*/} - {/* 5) Financial Projection */} -
-

Financial Projection

- {projectionData.length > 0 ? ( - <> - 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 && ( -

- Loan Paid Off at:{' '} - {loanPayoffMonth} -

- )} - - ) : ( -

No financial projection data found.

+ {/* --- FINANCIAL PROJECTION SECTION -------------------------------- */} +
+

Financial Projection

+ + + {projectionData.length ? ( +
+ {/* Milestone list / editor */} + + + {/* Chart */} +
+
+ 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 + }} + /> +
+ + + + {loanPayoffMonth && hasStudentLoan && ( +

+ Loan Paid Off:  + {loanPayoffMonth} +

)} +
+
+ ) : ( +

No financial projection data found.

+ )} +
{/* 6) Simulation length + Edit scenario */}
@@ -1103,7 +1328,7 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) { className="border rounded p-1 w-16" />
= DAILY_CLICK_LIMIT) { />
)} + {milestoneForModal && ( + { + setMilestoneForModal(false); // or setShowMilestoneModal(false) + if (didSave) fetchMilestones(); + }} + /> + + )} + {/* 7) AI Next Steps */} {/*
diff --git a/src/components/EducationalProgramsPage.js b/src/components/EducationalProgramsPage.js index def99c2..92cb2dd 100644 --- a/src/components/EducationalProgramsPage.js +++ b/src/components/EducationalProgramsPage.js @@ -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); } diff --git a/src/components/MilestoneEditModal.js b/src/components/MilestoneEditModal.js new file mode 100644 index 0000000..54d5a4f --- /dev/null +++ b/src/components/MilestoneEditModal.js @@ -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 ( +
+
+

Edit Milestones

+ + {milestones.map((m) => { + const hasEditOpen = editingMilestoneId === m.id; + const data = newMilestoneMap[m.id] || {}; + + return ( +
+
+
{m.title}
+ +
+

{m.description}

+

+ Date: {m.date} +

+

Progress: {m.progress}%

+ + {/* inline form */} + {hasEditOpen && ( +
+ + setNewMilestoneMap((p) => ({ + ...p, + [m.id]: { ...p[m.id], title: e.target.value } + })) + } + /> +