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",
|
"axios": "^1.7.9",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
"chartjs-plugin-annotation": "^3.1.0",
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
|
"chartjs-plugin-zoom": "^2.2.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -4166,6 +4168,12 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/html-minifier-terser": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
|
||||||
@ -6384,6 +6392,16 @@
|
|||||||
"pnpm": ">=8"
|
"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": {
|
"node_modules/chartjs-plugin-annotation": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
||||||
@ -6393,6 +6411,19 @@
|
|||||||
"chart.js": ">=4.0.0"
|
"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": {
|
"node_modules/check-types": {
|
||||||
"version": "11.2.3",
|
"version": "11.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz",
|
||||||
@ -7449,6 +7480,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||||
@ -10001,6 +10043,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/handle-thing": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
|
||||||
|
@ -11,7 +11,9 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
"chartjs-plugin-annotation": "^3.1.0",
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
|
"chartjs-plugin-zoom": "^2.2.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
@ -430,7 +430,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/career-roadmap"
|
path="/career-roadmap/:careerId?"
|
||||||
element={
|
element={
|
||||||
<PremiumRoute user={user}>
|
<PremiumRoute user={user}>
|
||||||
<CareerRoadmap />
|
<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 { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import CareerSuggestions from './CareerSuggestions.js';
|
import CareerSuggestions from './CareerSuggestions.js';
|
||||||
@ -664,35 +664,48 @@ function CareerExplorer() {
|
|||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
// "Select for Education" => navigate with CIP codes
|
// "Select for Education" => navigate with CIP codes
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
const handleSelectForEducation = (career) => {
|
// CareerExplorer.js
|
||||||
// 1) Confirm
|
const handleSelectForEducation = (career) => {
|
||||||
const confirmed = window.confirm(
|
if (!career) return;
|
||||||
`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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Clean CIP codes
|
// ─── 1. Ask first ─────────────────────────────────────────────
|
||||||
const rawCips = matching.cip_codes || [];
|
const ok = window.confirm(
|
||||||
const cleanedCips = cleanCipCodes(rawCips);
|
`Are you sure you want to move on to Educational Programs for “${career.title}”?`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
// 4) Navigate
|
// ─── 2. Make sure we have a full SOC code ─────────────────────
|
||||||
navigate('/educational-programs', {
|
const fullSoc = career.soc_code || career.code || '';
|
||||||
state: {
|
if (!fullSoc) {
|
||||||
socCode: career.code,
|
alert('Sorry – this career is missing a valid SOC code.');
|
||||||
cipCodes: cleanedCips,
|
return;
|
||||||
careerTitle: career.title,
|
}
|
||||||
userZip: userZipcode,
|
|
||||||
userState: userState,
|
// ─── 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
|
// Filter logic for jobZone, Fit
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import { Line, Bar } from 'react-chartjs-2';
|
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 axios from 'axios';
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
@ -11,16 +13,22 @@ import {
|
|||||||
Filler,
|
Filler,
|
||||||
PointElement,
|
PointElement,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
TimeScale,
|
||||||
Legend
|
Legend
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
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 authFetch from '../utils/authFetch.js';
|
||||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||||||
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
|
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
|
||||||
import { getFullStateName } from '../utils/stateUtils.js';
|
import { getFullStateName } from '../utils/stateUtils.js';
|
||||||
import CareerCoach from "./CareerCoach.js";
|
import CareerCoach from "./CareerCoach.js";
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
|
import { Pencil } from 'lucide-react';
|
||||||
import ScenarioEditModal from './ScenarioEditModal.js';
|
import ScenarioEditModal from './ScenarioEditModal.js';
|
||||||
import parseAIJson from "../utils/parseAIJson.js"; // your shared parser
|
import parseAIJson from "../utils/parseAIJson.js"; // your shared parser
|
||||||
|
|
||||||
@ -37,13 +45,74 @@ ChartJS.register(
|
|||||||
BarElement,
|
BarElement,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
|
TimeScale,
|
||||||
Filler,
|
Filler,
|
||||||
PointElement,
|
PointElement,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
|
zoomPlugin, // 👈 ←–––– only if you kept the zoom config
|
||||||
annotationPlugin
|
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
|
// Helper Functions
|
||||||
// --------------
|
// --------------
|
||||||
@ -59,6 +128,7 @@ function getRelativePosition(userSal, p10, p90) {
|
|||||||
return (userSal - p10) / (p90 - p10);
|
return (userSal - p10) / (p90 - p10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// A simple gauge for the user’s salary vs. percentiles
|
// A simple gauge for the user’s salary vs. percentiles
|
||||||
function SalaryGauge({ userSalary, percentileRow, prefix = 'regional' }) {
|
function SalaryGauge({ userSalary, percentileRow, prefix = 'regional' }) {
|
||||||
if (!percentileRow) return null;
|
if (!percentileRow) return null;
|
||||||
@ -239,6 +309,7 @@ function getYearsInCareer(startDateString) {
|
|||||||
|
|
||||||
|
|
||||||
export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
||||||
|
const { careerId } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const apiURL = process.env.REACT_APP_API_URL;
|
const apiURL = process.env.REACT_APP_API_URL;
|
||||||
|
|
||||||
@ -266,6 +337,8 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
|||||||
const [scenarioMilestones, setScenarioMilestones] = useState([]);
|
const [scenarioMilestones, setScenarioMilestones] = useState([]);
|
||||||
const [projectionData, setProjectionData] = useState([]);
|
const [projectionData, setProjectionData] = useState([]);
|
||||||
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
||||||
|
const [milestoneForModal, setMilestoneForModal] = useState(null);
|
||||||
|
const [hasPrompted, setHasPrompted] = useState(false);
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
||||||
@ -288,6 +361,81 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
|||||||
loanPayoffMonth: initLoanMonth = null
|
loanPayoffMonth: initLoanMonth = null
|
||||||
} = location.state || {};
|
} = 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
|
// 1) Fetch user + financial
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchUser() {
|
async function fetchUser() {
|
||||||
@ -308,12 +456,24 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
|||||||
}
|
}
|
||||||
fetchUser();
|
fetchUser();
|
||||||
fetchFin();
|
fetchFin();
|
||||||
}, [apiURL]);
|
}, []);
|
||||||
|
|
||||||
const userSalary = parseFloatOrZero(financialProfile?.current_salary, 0);
|
const userSalary = parseFloatOrZero(financialProfile?.current_salary, 0);
|
||||||
const userArea = userProfile?.area || 'U.S.';
|
const userArea = userProfile?.area || 'U.S.';
|
||||||
const userState = getFullStateName(userProfile?.state || '') || 'United States';
|
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(() => {
|
useEffect(() => {
|
||||||
let timer;
|
let timer;
|
||||||
if (buttonDisabled) {
|
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(() => {
|
useEffect(() => {
|
||||||
if (recommendations.length > 0) {
|
if (recommendations.length > 0) {
|
||||||
localStorage.setItem('aiRecommendations', JSON.stringify(recommendations));
|
localStorage.setItem('aiRecommendations', JSON.stringify(recommendations));
|
||||||
@ -361,50 +539,92 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 3) fetch user’s career-profiles
|
// 3) fetch user’s career-profiles
|
||||||
useEffect(() => {
|
// utilities you already have in this file
|
||||||
async function fetchProfiles() {
|
// • getAllCareerProfiles()
|
||||||
const r = await authFetch(`${apiURL}/premium/career-profile/all`);
|
// • createCareerProfileFromSearch()
|
||||||
if (!r || !r.ok) return;
|
|
||||||
const d = await r.json();
|
|
||||||
setExistingCareerProfiles(d.careerProfiles);
|
|
||||||
|
|
||||||
const fromPopout = location.state?.selectedCareer;
|
useEffect(() => {
|
||||||
if (fromPopout) {
|
let cancelled = false;
|
||||||
setSelectedCareer(fromPopout);
|
|
||||||
setCareerProfileId(fromPopout.career_profile_id);
|
(async function init () {
|
||||||
} else {
|
/* 1 ▸ get every row the user owns */
|
||||||
const stored = localStorage.getItem('lastSelectedCareerProfileId');
|
const r = await authFetch(`${apiURL}/premium/career-profile/all`);
|
||||||
if (stored) {
|
if (!r?.ok || cancelled) return;
|
||||||
const match = d.careerProfiles.find((p) => p.id === stored);
|
const { careerProfiles=[] } = await r.json();
|
||||||
if (match) {
|
setExistingCareerProfiles(careerProfiles);
|
||||||
setSelectedCareer(match);
|
|
||||||
setCareerProfileId(stored);
|
/* 2 ▸ what does the UI say the user just picked? */
|
||||||
return;
|
const chosen =
|
||||||
}
|
location.state?.selectedCareer ??
|
||||||
}
|
JSON.parse(localStorage.getItem('selectedCareer') || '{}');
|
||||||
// fallback => latest
|
|
||||||
const lr = await authFetch(`${apiURL}/premium/career-profile/latest`);
|
/* 2A ▸ they clicked a career elsewhere in the app */
|
||||||
if (lr && lr.ok) {
|
if (chosen.code) {
|
||||||
const ld = await lr.json();
|
let row = careerProfiles.find(p => p.soc_code === chosen.code);
|
||||||
if (ld?.id) {
|
if (!row) {
|
||||||
setSelectedCareer(ld);
|
try { row = await createCareerProfileFromSearch(chosen); }
|
||||||
setCareerProfileId(ld.id);
|
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
|
// 4) scenarioRow + college
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!careerProfileId) {
|
/** ---------------------------------------------------------------
|
||||||
setScenarioRow(null);
|
* bail out IMMEDIATELY until we have a *real* id
|
||||||
setCollegeProfile(null);
|
* (the rest of the body never even runs)
|
||||||
setScenarioMilestones([]);
|
* ------------------------------------------------------------- */
|
||||||
return;
|
if (!careerProfileId) return; // ← nothing gets fetched
|
||||||
}
|
|
||||||
localStorage.setItem('lastSelectedCareerProfileId', careerProfileId);
|
setScenarioRow(null); // clear stale data
|
||||||
|
setCollegeProfile(null);
|
||||||
|
setScenarioMilestones([]);
|
||||||
|
|
||||||
|
localStorage.setItem('lastSelectedCareerProfileId', careerProfileId);
|
||||||
|
|
||||||
async function fetchScenario() {
|
async function fetchScenario() {
|
||||||
const s = await authFetch(`${apiURL}/premium/career-profile/${careerProfileId}`);
|
const s = await authFetch(`${apiURL}/premium/career-profile/${careerProfileId}`);
|
||||||
@ -416,7 +636,7 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
|||||||
}
|
}
|
||||||
fetchScenario();
|
fetchScenario();
|
||||||
fetchCollege();
|
fetchCollege();
|
||||||
}, [careerProfileId, apiURL]);
|
}, [careerProfileId]);
|
||||||
|
|
||||||
// 5) from scenarioRow => find the full SOC => strip
|
// 5) from scenarioRow => find the full SOC => strip
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -552,7 +772,7 @@ try {
|
|||||||
setSalaryData(null);
|
setSalaryData(null);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [strippedSocCode, userArea, apiURL]);
|
}, [strippedSocCode, userArea]);
|
||||||
|
|
||||||
|
|
||||||
// 7) Econ
|
// 7) Econ
|
||||||
@ -578,7 +798,7 @@ try {
|
|||||||
setEconomicProjections(null);
|
setEconomicProjections(null);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [strippedSocCode, userState, apiURL]);
|
}, [strippedSocCode, userState]);
|
||||||
|
|
||||||
// 8) Build financial projection
|
// 8) Build financial projection
|
||||||
async function buildProjection() {
|
async function buildProjection() {
|
||||||
@ -745,33 +965,9 @@ try {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!financialProfile || !scenarioRow || !collegeProfile) return;
|
if (!financialProfile || !scenarioRow || !collegeProfile) return;
|
||||||
buildProjection();
|
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 [clickCount, setClickCount] = useState(() => {
|
||||||
const storedCount = localStorage.getItem('aiClickCount');
|
const storedCount = localStorage.getItem('aiClickCount');
|
||||||
@ -786,30 +982,8 @@ try {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const DAILY_CLICK_LIMIT = 10; // example limit per day
|
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 = {
|
const emergencyData = {
|
||||||
label: 'Emergency Savings',
|
label: 'Emergency Savings',
|
||||||
data: projectionData.map((p) => p.emergencySavings),
|
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];
|
const chartDatasets = [emergencyData, retirementData];
|
||||||
if (hasStudentLoan) chartDatasets.push(loanBalanceData);
|
if (hasStudentLoan) chartDatasets.push(loanBalanceData);
|
||||||
chartDatasets.push(totalSavingsData);
|
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.');
|
alert('You have reached the daily limit for suggestions.');
|
||||||
return;
|
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);
|
setAiLoading(true);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
@ -918,6 +1094,37 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buttonLabel = recommendations.length > 0 ? 'New Suggestions' : 'What Should I Do Next?';
|
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 (
|
return (
|
||||||
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-4">
|
<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>
|
</p>
|
||||||
</div>*/}
|
</div>*/}
|
||||||
|
|
||||||
{/* 5) Financial Projection */}
|
{/* --- FINANCIAL PROJECTION SECTION -------------------------------- */}
|
||||||
<div className="bg-white p-4 rounded shadow">
|
<div className="bg-white p-4 rounded shadow">
|
||||||
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
|
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
|
||||||
{projectionData.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<Line
|
{projectionData.length ? (
|
||||||
data={{
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
labels: projectionData.map((p) => p.month),
|
{/* Milestone list / editor */}
|
||||||
datasets: chartDatasets
|
<MilestonePanel
|
||||||
}}
|
className="md:w-56"
|
||||||
options={{
|
groups={milestoneGroups}
|
||||||
responsive: true,
|
onEdit={onEditMilestone} /* <-- use your existing handler */
|
||||||
plugins: {
|
/>
|
||||||
legend: { position: 'bottom' },
|
|
||||||
tooltip: { mode: 'index', intersect: false },
|
{/* Chart */}
|
||||||
annotation: { annotations: allAnnotations }
|
<div className="flex-1">
|
||||||
},
|
<div style={{ height: 360, width: '100%' }}>
|
||||||
scales: {
|
<Line
|
||||||
y: {
|
ref={chartRef}
|
||||||
beginAtZero: false,
|
data={{
|
||||||
ticks: {
|
labels: projectionData.map(p => p.month),
|
||||||
callback: (val) => `$${val.toLocaleString()}`
|
datasets: chartDatasets
|
||||||
}
|
}}
|
||||||
}
|
options={{
|
||||||
}
|
maintainAspectRatio: false,
|
||||||
}}
|
plugins: {
|
||||||
/>
|
legend: { position: 'bottom' },
|
||||||
{loanPayoffMonth && hasStudentLoan && (
|
tooltip: { mode: 'index', intersect: false },
|
||||||
<p className="font-semibold text-sm mt-2">
|
annotation: { annotations: allAnnotations }, // ✅ new
|
||||||
Loan Paid Off at:{' '}
|
zoom: zoomConfig
|
||||||
<span className="text-yellow-600">{loanPayoffMonth}</span>
|
},
|
||||||
</p>
|
scales: xAndYScales // unchanged
|
||||||
)}
|
}}
|
||||||
</>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<p className="text-sm text-gray-500">No financial projection data found.</p>
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">No financial projection data found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 6) Simulation length + Edit scenario */}
|
{/* 6) Simulation length + Edit scenario */}
|
||||||
<div className="mt-4 space-x-2">
|
<div className="mt-4 space-x-2">
|
||||||
@ -1103,7 +1328,7 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
|
|||||||
className="border rounded p-1 w-16"
|
className="border rounded p-1 w-16"
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => setShowEditModal(true)} className="ml-2">
|
<Button onClick={() => setShowEditModal(true)} className="ml-2">
|
||||||
Edit
|
Edit Simulation Inputs
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ScenarioEditModal
|
<ScenarioEditModal
|
||||||
@ -1168,6 +1393,19 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* 7) AI Next Steps */}
|
||||||
{/* <div className="bg-white p-4 rounded shadow mt-4">
|
{/* <div className="bg-white p-4 rounded shadow mt-4">
|
||||||
|
@ -63,11 +63,14 @@ function renderLevel(val) {
|
|||||||
function EducationalProgramsPage() {
|
function EducationalProgramsPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [socCode, setsocCode] = useState(location.state?.socCode || '');
|
const { state } = useLocation();
|
||||||
const [cipCodes, setCipCodes] = useState(location.state?.cipCodes || []);
|
const navCareer = state?.selectedCareer || {};
|
||||||
|
const [selectedCareer, setSelectedCareer] = useState(navCareer);
|
||||||
const [userState, setUserState] = useState(location.state?.userState || '');
|
const [socCode, setSocCode] = useState(navCareer.code || '');
|
||||||
const [userZip, setUserZip] = useState(location.state?.userZip || '');
|
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 [allKsaData, setAllKsaData] = useState([]);
|
||||||
const [ksaForCareer, setKsaForCareer] = useState([]);
|
const [ksaForCareer, setKsaForCareer] = useState([]);
|
||||||
@ -83,8 +86,7 @@ function EducationalProgramsPage() {
|
|||||||
const [maxTuition, setMaxTuition] = useState(20000);
|
const [maxTuition, setMaxTuition] = useState(20000);
|
||||||
const [maxDistance, setMaxDistance] = useState(100);
|
const [maxDistance, setMaxDistance] = useState(100);
|
||||||
const [inStateOnly, setInStateOnly] = useState(false);
|
const [inStateOnly, setInStateOnly] = useState(false);
|
||||||
const [careerTitle, setCareerTitle] = useState(location.state?.careerTitle || '');
|
|
||||||
const [selectedCareer, setSelectedCareer] = useState(location.state?.foundObj || '');
|
|
||||||
const [showSearch, setShowSearch] = useState(true);
|
const [showSearch, setShowSearch] = useState(true);
|
||||||
|
|
||||||
// If user picks a new career from CareerSearch
|
// If user picks a new career from CareerSearch
|
||||||
@ -99,7 +101,7 @@ function EducationalProgramsPage() {
|
|||||||
return codeStr.replace('.', '').slice(0, 4);
|
return codeStr.replace('.', '').slice(0, 4);
|
||||||
});
|
});
|
||||||
setCipCodes(cleanedCips);
|
setCipCodes(cleanedCips);
|
||||||
setsocCode(foundObj.soc_code);
|
setSocCode(foundObj.soc_code);
|
||||||
setShowSearch(false);
|
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
|
// Load KSA data once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadKsaData() {
|
async function loadKsaData() {
|
||||||
@ -232,7 +251,7 @@ useEffect(() => {
|
|||||||
const cleanedCips = rawCips.map((code) => code.toString().replace('.', '').slice(0, 4));
|
const cleanedCips = rawCips.map((code) => code.toString().replace('.', '').slice(0, 4));
|
||||||
setCipCodes(cleanedCips);
|
setCipCodes(cleanedCips);
|
||||||
|
|
||||||
setsocCode(parsed.soc_code);
|
setSocCode(parsed.soc_code);
|
||||||
|
|
||||||
setShowSearch(false);
|
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