1723 lines
58 KiB
JavaScript
1723 lines
58 KiB
JavaScript
import React, { useState, useEffect, useRef, useMemo, useCallback, useContext } 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,
|
||
LineElement,
|
||
BarElement,
|
||
CategoryScale,
|
||
LinearScale,
|
||
Filler,
|
||
PointElement,
|
||
Tooltip,
|
||
TimeScale,
|
||
Legend
|
||
} from 'chart.js';
|
||
import annotationPlugin from 'chartjs-plugin-annotation';
|
||
import MilestonePanel from './MilestonePanel.js';
|
||
import MilestoneDrawer from './MilestoneDrawer.js';
|
||
import MilestoneEditModal from './MilestoneEditModal.js';
|
||
import buildChartMarkers from '../utils/buildChartMarkers.js';
|
||
import getMissingFields from '../utils/getMissingFields.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 ChatCtx from '../contexts/ChatCtx.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
|
||
import InfoTooltip from "./ui/infoTooltip.js";
|
||
import differenceInMonths from 'date-fns/differenceInMonths';
|
||
|
||
|
||
import "../styles/legacy/MilestoneTimeline.legacy.css";
|
||
|
||
// --------------
|
||
// Register ChartJS Plugins
|
||
// --------------
|
||
ChartJS.register(
|
||
LineElement,
|
||
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
|
||
// --------------
|
||
|
||
function shouldSkipModalOnce(profileId) {
|
||
const key = `skipMissingModalFor`;
|
||
const stored = sessionStorage.getItem(key);
|
||
if (stored && stored === String(profileId)) {
|
||
sessionStorage.removeItem(key); // one-time use
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* ---------- helper: "&" ↔ "and", collapse spaces, etc. ---------- */
|
||
function normalizeTitle(str = '') {
|
||
return str
|
||
.toLowerCase()
|
||
.replace(/\s*&\s*/g, ' and ') // “foo & bar” → “foo and bar”
|
||
.replace(/[–—]/g, '-') // long dashes → plain hyphen
|
||
.replace(/\s+/g, ' ') // squeeze double-spaces
|
||
.trim();
|
||
}
|
||
|
||
function stripSocCode(fullSoc) {
|
||
if (!fullSoc) return '';
|
||
return fullSoc.split('.')[0];
|
||
}
|
||
|
||
function getRelativePosition(userSal, p10, p90) {
|
||
if (!p10 || !p90) return 0;
|
||
if (userSal < p10) return 0;
|
||
if (userSal > p90) return 1;
|
||
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;
|
||
|
||
const p10 = parseFloatOrZero(percentileRow[`${prefix}_PCT10`], 0);
|
||
const p90 = parseFloatOrZero(percentileRow[`${prefix}_PCT90`], 0);
|
||
const median = parseFloatOrZero(percentileRow[`${prefix}_MEDIAN`], 0);
|
||
|
||
if (!p10 || !p90 || p10 >= p90) {
|
||
return null;
|
||
}
|
||
|
||
const userFrac = getRelativePosition(userSalary, p10, p90) * 100;
|
||
const medianFrac = getRelativePosition(median, p10, p90) * 100;
|
||
|
||
return (
|
||
<div className="mt-2" style={{ position: 'relative', width: '100%' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8rem' }}>
|
||
<span>${p10.toLocaleString()}</span>
|
||
<span>${p90.toLocaleString()}</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
position: 'relative',
|
||
width: '100%',
|
||
height: '12px',
|
||
border: '1px solid #ccc',
|
||
marginTop: '4px',
|
||
marginBottom: '8px'
|
||
}}
|
||
>
|
||
{/* Median Marker */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
left: `${medianFrac}%`,
|
||
transform: 'translateX(-50%)',
|
||
top: 0,
|
||
bottom: 0,
|
||
width: '2px',
|
||
backgroundColor: 'black'
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: '100%',
|
||
left: '50%',
|
||
transform: 'translate(-50%, -4px)',
|
||
backgroundColor: '#fff',
|
||
padding: '2px 6px',
|
||
fontSize: '0.75rem',
|
||
border: '1px solid #ccc',
|
||
borderRadius: '4px',
|
||
whiteSpace: 'nowrap'
|
||
}}
|
||
>
|
||
Median ${median.toLocaleString()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* User Salary Marker */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
left: `${userFrac}%`,
|
||
transform: 'translateX(-50%)',
|
||
top: 0,
|
||
bottom: 0,
|
||
width: '2px',
|
||
backgroundColor: 'red'
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: '100%',
|
||
left: '50%',
|
||
transform: 'translate(-50%, -4px)',
|
||
backgroundColor: '#fff',
|
||
padding: '2px 6px',
|
||
fontSize: '0.75rem',
|
||
border: '1px solid #ccc',
|
||
borderRadius: '4px',
|
||
whiteSpace: 'nowrap'
|
||
}}
|
||
>
|
||
${userSalary.toLocaleString()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EconomicProjectionsBar({ data }) {
|
||
if (!data) return null;
|
||
const {
|
||
area,
|
||
baseYear,
|
||
projectedYear,
|
||
base,
|
||
projection,
|
||
change,
|
||
annualOpenings,
|
||
occupationName
|
||
} = data;
|
||
|
||
if (!area || !base || !projection) {
|
||
return <p className="text-sm text-gray-500">No data for {area || 'this region'}.</p>;
|
||
}
|
||
|
||
const barData = {
|
||
labels: [`${occupationName || 'Career'}: ${area}`],
|
||
datasets: [
|
||
{
|
||
label: `Jobs in ${baseYear}`,
|
||
data: [base],
|
||
backgroundColor: 'rgba(75,192,192,0.6)'
|
||
},
|
||
{
|
||
label: `Jobs in ${projectedYear}`,
|
||
data: [projection],
|
||
backgroundColor: 'rgba(255,99,132,0.6)'
|
||
}
|
||
]
|
||
};
|
||
|
||
const barOptions = {
|
||
responsive: true,
|
||
plugins: {
|
||
legend: { position: 'bottom' },
|
||
tooltip: {
|
||
callbacks: {
|
||
label: (ctx) => `${ctx.dataset.label}: ${ctx.parsed.y.toLocaleString()}`
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
ticks: {
|
||
callback: (val) => val.toLocaleString()
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="bg-white p-4 rounded shadow w-full md:w-1/2">
|
||
<h4 className="text-lg font-semibold mb-2">{area}</h4>
|
||
<Bar data={barData} options={barOptions} />
|
||
<div className="mt-3 text-sm">
|
||
<p>
|
||
<strong>Change:</strong> {change?.toLocaleString() ?? 0} jobs
|
||
</p>
|
||
<p>
|
||
<strong>Annual Openings:</strong> {annualOpenings?.toLocaleString() ?? 0}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getYearsInCareer(startDateString) {
|
||
if (!startDateString) return null;
|
||
const start = new Date(startDateString);
|
||
if (isNaN(start)) return null;
|
||
|
||
const now = new Date();
|
||
const diffMs = now - start;
|
||
const diffYears = diffMs / (1000 * 60 * 60 * 24 * 365.25);
|
||
if (diffYears < 1) {
|
||
return '<1';
|
||
}
|
||
return Math.floor(diffYears).toString();
|
||
}
|
||
|
||
|
||
export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
||
const { careerId } = useParams();
|
||
const location = useLocation();
|
||
|
||
const [interestStrategy, setInterestStrategy] = useState('FLAT'); // 'NONE' | 'FLAT' | 'MONTE_CARLO'
|
||
const [flatAnnualRate, setFlatAnnualRate] = useState(0.06);
|
||
const [randomRangeMin, setRandomRangeMin] = useState(-0.02);
|
||
const [randomRangeMax, setRandomRangeMax] = useState(0.02);
|
||
|
||
// Basic states
|
||
const [userProfile, setUserProfile] = useState(null);
|
||
const [financialProfile, setFinancialProfile] = useState(null);
|
||
const [masterCareerRatings, setMasterCareerRatings] = useState([]);
|
||
const [existingCareerProfiles, setExistingCareerProfiles] = useState([]);
|
||
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
|
||
const [careerProfileId, setCareerProfileId] = useState(null);
|
||
const [scenarioRow, setScenarioRow] = useState(null);
|
||
const [collegeProfile, setCollegeProfile] = useState(null);
|
||
|
||
const [fullSocCode, setFullSocCode] = useState(null); // new line
|
||
const [strippedSocCode, setStrippedSocCode] = useState(null);
|
||
const [salaryData, setSalaryData] = useState(null);
|
||
const [economicProjections, setEconomicProjections] = useState(null);
|
||
const [salaryLoading, setSalaryLoading] = useState(false);
|
||
const [econLoading, setEconLoading] = useState(false);
|
||
|
||
// Milestones & Projection
|
||
const [scenarioMilestones, setScenarioMilestones] = useState([]);
|
||
const [projectionData, setProjectionData] = useState([]);
|
||
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
||
const [milestoneForModal, setMilestoneForModal] = useState(null);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [focusMid , setFocusMid ] = useState(null);
|
||
const [drawerMilestone, setDrawerMilestone] = useState(null);
|
||
const [impactsById, setImpactsById] = useState({}); // id → [impacts]
|
||
const [addingNewMilestone, setAddingNewMilestone] = useState(false);
|
||
const [showMissingBanner, setShowMissingBanner] = useState(false);
|
||
|
||
|
||
// Config
|
||
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
||
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
|
||
|
||
const [showEditModal, setShowEditModal] = useState(false);
|
||
|
||
// AI
|
||
const [aiLoading, setAiLoading] = useState(false);
|
||
const [recommendations, setRecommendations] = useState([]); // parsed array
|
||
const [selectedIds, setSelectedIds] = useState([]); // which rec IDs are checked
|
||
|
||
const [lastClickTime, setLastClickTime] = useState(null);
|
||
const RATE_LIMIT_SECONDS = 15; // adjust as needed
|
||
const [buttonDisabled, setButtonDisabled] = useState(false);
|
||
const [aiRisk, setAiRisk] = useState(null);
|
||
|
||
const { setChatSnapshot } = useContext(ChatCtx);
|
||
|
||
|
||
|
||
const reloadScenarioAndCollege = useCallback(async () => {
|
||
if (!careerProfileId) return;
|
||
const s = await authFetch(
|
||
`api/premium/career-profile/${careerProfileId}`
|
||
);
|
||
if (s.ok) {
|
||
const row = await s.json();
|
||
if (!row.college_enrollment_status)
|
||
row.college_enrollment_status = "not_enrolled";
|
||
setScenarioRow(row);
|
||
}
|
||
|
||
const c = await authFetch(
|
||
`api/premium/college-profile?careerProfileId=${careerProfileId}`
|
||
);
|
||
if (c.ok) setCollegeProfile(await c.json());
|
||
}, [careerProfileId]);
|
||
|
||
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
|
||
}
|
||
}
|
||
};
|
||
|
||
/* ──────────────────────────────────────────────────────────────
|
||
* ONE-TIME “MISSING FIELDS” GUARD
|
||
* modalGuard.current = { checked: bool, skip: bool }
|
||
* • checked → we already ran the test for this profile
|
||
* • skip → suppress first check (set by onboarding OR
|
||
* by sessionStorage flag for this profile)
|
||
* ────────────────────────────────────────────────────────────── */
|
||
const modalGuard = useRef({ checked: false, skip: false });
|
||
|
||
/* -------------------------------------------------------------
|
||
* 0) If we landed here via onboarding, skip the very first check
|
||
* ------------------------------------------------------------*/
|
||
useEffect(() => {
|
||
if (location.state?.fromOnboarding) {
|
||
modalGuard.current.skip = true; // suppress once
|
||
window.history.replaceState({}, '', location.pathname);
|
||
}
|
||
}, [location.state, location.pathname]);
|
||
|
||
/* -------------------------------------------------------------
|
||
* 1) Fetch user + financial on first mount
|
||
* ------------------------------------------------------------*/
|
||
useEffect(() => {
|
||
(async () => {
|
||
const up = await authFetch('/api/user-profile');
|
||
if (up.ok) setUserProfile(await up.json());
|
||
|
||
const fp = await authFetch('/api/premium/financial-profile');
|
||
if (fp.status === 404) {
|
||
// user skipped onboarding – treat as empty object
|
||
setFinancialProfile({});
|
||
} else if (fp.ok) {
|
||
setFinancialProfile(await fp.json());
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
/* quick derived helpers */
|
||
const userSalary = parseFloatOrZero(financialProfile?.current_salary);
|
||
const userArea = userProfile?.area || 'U.S.';
|
||
const userState = getFullStateName(userProfile?.state || '') || 'United States';
|
||
|
||
/* -------------------------------------------------------------
|
||
* 2) Determine the active careerProfileId once
|
||
* ------------------------------------------------------------*/
|
||
useEffect(() => {
|
||
let id = careerId;
|
||
if (!id) id = localStorage.getItem('lastSelectedCareerProfileId');
|
||
|
||
if (id) {
|
||
setCareerProfileId(id);
|
||
localStorage.setItem('lastSelectedCareerProfileId', id);
|
||
// one-shot modal skip from sessionStorage
|
||
modalGuard.current.skip ||= shouldSkipModalOnce(id);
|
||
}
|
||
}, [careerId]);
|
||
|
||
useEffect(() => {
|
||
let timer;
|
||
if (buttonDisabled) {
|
||
timer = setTimeout(() => setButtonDisabled(false), RATE_LIMIT_SECONDS * 1000);
|
||
}
|
||
return () => clearTimeout(timer);
|
||
}, [buttonDisabled]);
|
||
|
||
/* ------------------------------------------------------------------
|
||
* 1) Restore AI recommendations (unchanged behaviour)
|
||
* -----------------------------------------------------------------*/
|
||
useEffect(() => {
|
||
const json = localStorage.getItem('aiRecommendations');
|
||
if (!json) return;
|
||
|
||
try {
|
||
const arr = JSON.parse(json).map((m) => ({
|
||
...m,
|
||
id: m.id || crypto.randomUUID()
|
||
}));
|
||
setRecommendations(arr);
|
||
} catch (err) {
|
||
console.error('Error parsing stored AI recs', err);
|
||
}
|
||
}, []);
|
||
|
||
/* ------------------------------------------------------------------
|
||
* 2) Whenever the careerProfileId changes, clear the modal check flag
|
||
* -----------------------------------------------------------------*/
|
||
useEffect(() => {
|
||
modalGuard.current.checked = false;
|
||
}, [careerProfileId]);
|
||
|
||
/* ------------------------------------------------------------------
|
||
* 3) Missing-fields modal – single authoritative effect
|
||
* -----------------------------------------------------------------*/
|
||
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null;
|
||
|
||
useEffect(() => {
|
||
if (!dataReady || !careerProfileId) return; // wait for all rows
|
||
|
||
/* run once per profile‑id ------------------------------------------------ */
|
||
if (modalGuard.current.checked) return;
|
||
modalGuard.current.checked = true;
|
||
|
||
/* derive once, local to this effect -------------------------------------- */
|
||
const status = (scenarioRow?.college_enrollment_status || '').toLowerCase();
|
||
const requireCollege = ['currently_enrolled','prospective_student','deferred']
|
||
.includes(status);
|
||
|
||
const missing = getMissingFields(
|
||
{ scenario: scenarioRow, financial: financialProfile, college: collegeProfile },
|
||
{ requireCollegeData: requireCollege }
|
||
);
|
||
|
||
if (missing.length) {
|
||
/* if we arrived *directly* from onboarding we silently skip the banner
|
||
once, but we still want the Edit‑Scenario modal to open */
|
||
if (modalGuard.current.skip) {
|
||
setShowEditModal(true);
|
||
} else {
|
||
setShowMissingBanner(true);
|
||
}
|
||
}
|
||
}, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]);
|
||
|
||
|
||
|
||
useEffect(() => {
|
||
if (
|
||
financialProfile &&
|
||
scenarioRow &&
|
||
collegeProfile
|
||
) {
|
||
buildProjection(scenarioMilestones); // uses the latest scenarioMilestones
|
||
}
|
||
}, [
|
||
financialProfile,
|
||
scenarioRow,
|
||
collegeProfile,
|
||
scenarioMilestones,
|
||
simulationYears,
|
||
interestStrategy,
|
||
flatAnnualRate,
|
||
randomRangeMin,
|
||
randomRangeMax
|
||
]);
|
||
|
||
/**
|
||
* Snapshot for the Support-bot: only UI state, no domain data
|
||
*/
|
||
const uiSnap = useMemo(() => ({
|
||
page : 'CareerRoadmap',
|
||
|
||
panels: {
|
||
careerCoachLoaded : !!scenarioRow?.career_name,
|
||
salaryBenchmarks : !!salaryData,
|
||
econProjections : !!economicProjections,
|
||
financialProjection : !!projectionData.length,
|
||
milestonesPanel : !!scenarioMilestones.length,
|
||
editScenarioModalUp : showEditModal,
|
||
drawerOpen : drawerOpen
|
||
},
|
||
|
||
counts: {
|
||
milestonesTotal : scenarioMilestones.length,
|
||
milestonesDone : scenarioMilestones.filter(m => m.completed).length,
|
||
yearsSimulated : simulationYears
|
||
}
|
||
}), [
|
||
selectedCareer,
|
||
salaryData, economicProjections,
|
||
projectionData.length,
|
||
scenarioMilestones, showEditModal, drawerOpen,
|
||
simulationYears
|
||
]);
|
||
|
||
/* push the snapshot to the chat context */
|
||
useEffect(() => setChatSnapshot(uiSnap), [uiSnap, setChatSnapshot]);
|
||
|
||
|
||
useEffect(() => {
|
||
if (recommendations.length > 0) {
|
||
localStorage.setItem('aiRecommendations', JSON.stringify(recommendations));
|
||
} else {
|
||
// if it's empty, we can remove from localStorage if you want
|
||
localStorage.removeItem('aiRecommendations');
|
||
}
|
||
}, [recommendations]);
|
||
|
||
|
||
// 2) load local JSON => masterCareerRatings
|
||
useEffect(() => {
|
||
fetch('/careers_with_ratings.json')
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error('Failed to load local career data');
|
||
return res.json();
|
||
})
|
||
.then((data) => setMasterCareerRatings(data))
|
||
.catch((err) => console.error('Error loading local career data =>', err));
|
||
}, []);
|
||
|
||
// 3) fetch user’s career-profiles
|
||
// utilities you already have in this file
|
||
// • getAllCareerProfiles()
|
||
// • createCareerProfileFromSearch()
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
(async function init () {
|
||
/* 1 ▸ get every row the user owns */
|
||
const r = await authFetch('api/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;
|
||
}
|
||
}
|
||
|
||
/* 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) refresh scenario + college whenever the active profile-id changes
|
||
* -----------------------------------------------------------------*/
|
||
useEffect(() => {
|
||
if (!careerProfileId) return; // nothing to fetch
|
||
|
||
// clear any stale UI traces while the new fetch runs
|
||
setScenarioRow(null);
|
||
setCollegeProfile(null);
|
||
setScenarioMilestones([]);
|
||
|
||
// remember for other tabs / future visits
|
||
localStorage.setItem('lastSelectedCareerProfileId', careerProfileId);
|
||
|
||
// fetch both rows in parallel (defined via useCallback)
|
||
reloadScenarioAndCollege();
|
||
}, [careerProfileId, reloadScenarioAndCollege]);
|
||
|
||
const refetchScenario = useCallback(async () => {
|
||
if (!careerProfileId) return;
|
||
const r = await authFetch('api/premium/career-profile/${careerProfileId}');
|
||
if (r.ok) setScenarioRow(await r.json());
|
||
}, [careerProfileId]);
|
||
|
||
// 5) from scenarioRow => find the full SOC => strip
|
||
useEffect(() => {
|
||
if (!scenarioRow?.career_name || !masterCareerRatings.length) {
|
||
setStrippedSocCode(null);
|
||
setFullSocCode(null);
|
||
return;
|
||
}
|
||
const target = normalizeTitle(scenarioRow.career_name);
|
||
const found = masterCareerRatings.find(
|
||
(obj) => normalizeTitle(obj.title || '') === target
|
||
);
|
||
if (!found) {
|
||
console.warn('No matching SOC =>', scenarioRow.career_name);
|
||
setStrippedSocCode(null);
|
||
setFullSocCode(null);
|
||
return;
|
||
}
|
||
setStrippedSocCode(stripSocCode(found.soc_code));
|
||
setFullSocCode(found.soc_code);
|
||
}, [scenarioRow, masterCareerRatings]);
|
||
|
||
useEffect(() => {
|
||
if (!fullSocCode || !scenarioRow || scenarioRow.riskLevel) return;
|
||
(async () => {
|
||
const risk = await fetchAiRisk(
|
||
fullSocCode,
|
||
scenarioRow?.career_name,
|
||
scenarioRow?.job_description || "",
|
||
scenarioRow?.tasks || []
|
||
);
|
||
setAiRisk(risk);
|
||
if (risk && scenarioRow) {
|
||
const updated = {
|
||
...scenarioRow,
|
||
riskLevel: risk.riskLevel,
|
||
riskReasoning: risk.reasoning
|
||
};
|
||
setScenarioRow(updated);
|
||
}
|
||
})();
|
||
}, [fullSocCode, scenarioRow]);
|
||
|
||
async function fetchAiRisk(socCode, careerName, description, tasks) {
|
||
let aiRisk = null;
|
||
|
||
try {
|
||
// 1) Check server2 for existing entry
|
||
const localRiskRes = await axios.get(`api/ai-risk/${socCode}`);
|
||
aiRisk = localRiskRes.data; // { socCode, riskLevel, ... }
|
||
} catch (err) {
|
||
// 2) If 404 => call server3
|
||
if (err.response && err.response.status === 404) {
|
||
try {
|
||
// Call GPT via server3
|
||
const aiRes = await axios.post('api/public/ai-risk-analysis', {
|
||
socCode,
|
||
careerName,
|
||
jobDescription: description,
|
||
tasks
|
||
});
|
||
|
||
const { riskLevel, reasoning } = aiRes.data;
|
||
|
||
// Prepare the upsert payload
|
||
const storePayload = {
|
||
socCode,
|
||
careerName,
|
||
riskLevel,
|
||
reasoning
|
||
};
|
||
|
||
// Only set jobDescription if non-empty
|
||
if (
|
||
aiRes.data.jobDescription &&
|
||
aiRes.data.jobDescription.trim().length > 0
|
||
) {
|
||
storePayload.jobDescription = aiRes.data.jobDescription;
|
||
}
|
||
|
||
// Only set tasks if it's a non-empty array
|
||
if (
|
||
Array.isArray(aiRes.data.tasks) &&
|
||
aiRes.data.tasks.length > 0
|
||
) {
|
||
storePayload.tasks = aiRes.data.tasks;
|
||
}
|
||
|
||
// 3) Store in server2
|
||
await axios.post('api/ai-risk', storePayload);
|
||
|
||
// Construct final object for usage here
|
||
aiRisk = {
|
||
socCode,
|
||
careerName,
|
||
jobDescription: description,
|
||
tasks,
|
||
riskLevel,
|
||
reasoning
|
||
};
|
||
} catch (err2) {
|
||
console.error("Error calling server3 or storing AI risk:", err2);
|
||
// fallback
|
||
}
|
||
} else {
|
||
console.error("Error fetching AI risk from server2 =>", err);
|
||
}
|
||
}
|
||
|
||
return aiRisk;
|
||
}
|
||
|
||
/* 6) Salary ------------------------------------------------------- */
|
||
useEffect(() => {
|
||
// show blank state instantly whenever the SOC or area changes
|
||
setSalaryData(null);
|
||
setSalaryLoading(true);
|
||
|
||
if (!strippedSocCode) {
|
||
setSalaryLoading(false);
|
||
return;
|
||
}
|
||
const ctrl = new AbortController();
|
||
(async () => {
|
||
try {
|
||
const qs = new URLSearchParams({ socCode: strippedSocCode, area: userArea });
|
||
const res = await fetch(`api/salary?${qs}`, { signal: ctrl.signal });
|
||
|
||
if (res.ok) {
|
||
setSalaryData(await res.json());
|
||
setSalaryLoading(false);
|
||
} else {
|
||
console.error('[Salary fetch]', res.status);
|
||
}
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') console.error('[Salary fetch error]', e);
|
||
setSalaryLoading(false);
|
||
}
|
||
})();
|
||
|
||
// cancel if strippedSocCode / userArea changes before the fetch ends
|
||
return () => ctrl.abort();
|
||
}, [strippedSocCode, userArea]);
|
||
|
||
/* 7) Economic Projections ---------------------------------------- */
|
||
useEffect(() => {
|
||
setEconomicProjections(null);
|
||
setEconLoading(true);
|
||
if (!strippedSocCode || !userState) {
|
||
setEconLoading(false);
|
||
return;
|
||
}
|
||
|
||
const ctrl = new AbortController();
|
||
(async () => {
|
||
try {
|
||
const qs = new URLSearchParams({ state: userState });
|
||
const res = await authFetch(
|
||
`api/projections/${strippedSocCode}?${qs}`,
|
||
{ signal: ctrl.signal }
|
||
);
|
||
|
||
if (res.ok) {
|
||
setEconomicProjections(await res.json());
|
||
setEconLoading(false);
|
||
} else {
|
||
console.error('[Econ fetch]', res.status);
|
||
}
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') console.error('[Econ fetch error]', e);
|
||
setEconLoading(false);
|
||
}
|
||
})();
|
||
|
||
return () => ctrl.abort();
|
||
}, [strippedSocCode, userState]);
|
||
|
||
// 8) Build financial projection
|
||
async function buildProjection(milestones) {
|
||
const allMilestones = milestones || [];
|
||
try {
|
||
setScenarioMilestones(allMilestones);
|
||
|
||
// fetch impacts
|
||
const imPromises = allMilestones.map((m) =>
|
||
authFetch(`api/premium/milestone-impacts?milestone_id=${m.id}`)
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((dd) => dd?.impacts || [])
|
||
.catch((e) => {
|
||
console.warn('Error fetching impacts =>', e);
|
||
return [];
|
||
})
|
||
);
|
||
const impactsForEach = await Promise.all(imPromises);
|
||
const allImpacts = allMilestones
|
||
.map((m, i) => ({ ...m, impacts: impactsForEach[i] || [] }))
|
||
.flatMap((m) => m.impacts);
|
||
|
||
/* NEW – build a quick lookup table and expose it */
|
||
const map = {};
|
||
allImpacts.forEach((imp) => {
|
||
(map[imp.milestone_id] = map[imp.milestone_id] || []).push(imp);
|
||
});
|
||
setImpactsById(map); // <-- saves for the modal
|
||
|
||
const f = financialProfile;
|
||
const financialBase = {
|
||
currentSalary: parseFloatOrZero(f.current_salary, 0),
|
||
additionalIncome: parseFloatOrZero(f.additional_income, 0),
|
||
monthlyExpenses: parseFloatOrZero(f.monthly_expenses, 0),
|
||
monthlyDebtPayments: parseFloatOrZero(f.monthly_debt_payments, 0),
|
||
retirementSavings: parseFloatOrZero(f.retirement_savings, 0),
|
||
emergencySavings: parseFloatOrZero(f.emergency_fund, 0),
|
||
retirementContribution: parseFloatOrZero(f.retirement_contribution, 0),
|
||
emergencyContribution: parseFloatOrZero(f.emergency_contribution, 0),
|
||
extraCashEmergencyPct: parseFloatOrZero(f.extra_cash_emergency_pct, 50),
|
||
extraCashRetirementPct: parseFloatOrZero(f.extra_cash_retirement_pct, 50)
|
||
};
|
||
|
||
function parseScenarioOverride(overrideVal, fallbackVal) {
|
||
if (overrideVal === null) {
|
||
return fallbackVal;
|
||
}
|
||
return parseFloatOrZero(overrideVal, fallbackVal);
|
||
}
|
||
|
||
const s = scenarioRow;
|
||
const scenarioOverrides = {
|
||
monthlyExpenses: parseScenarioOverride(
|
||
s.planned_monthly_expenses,
|
||
financialBase.monthlyExpenses
|
||
),
|
||
monthlyDebtPayments: parseScenarioOverride(
|
||
s.planned_monthly_debt_payments,
|
||
financialBase.monthlyDebtPayments
|
||
),
|
||
monthlyRetirementContribution: parseScenarioOverride(
|
||
s.planned_monthly_retirement_contribution,
|
||
financialBase.retirementContribution
|
||
),
|
||
monthlyEmergencyContribution: parseScenarioOverride(
|
||
s.planned_monthly_emergency_contribution,
|
||
financialBase.emergencyContribution
|
||
),
|
||
surplusEmergencyAllocation: parseScenarioOverride(
|
||
s.planned_surplus_emergency_pct,
|
||
financialBase.extraCashEmergencyPct
|
||
),
|
||
surplusRetirementAllocation: parseScenarioOverride(
|
||
s.planned_surplus_retirement_pct,
|
||
financialBase.extraCashRetirementPct
|
||
),
|
||
additionalIncome: parseScenarioOverride(
|
||
s.planned_additional_income,
|
||
financialBase.additionalIncome
|
||
)
|
||
};
|
||
|
||
const c = collegeProfile;
|
||
const collegeData = {
|
||
studentLoanAmount: parseFloatOrZero(c.existing_college_debt, 0),
|
||
interestRate: parseFloatOrZero(c.interest_rate, 5),
|
||
loanTerm: parseFloatOrZero(c.loan_term, 10),
|
||
loanDeferralUntilGraduation: !!c.loan_deferral_until_graduation,
|
||
academicCalendar: c.academic_calendar || 'monthly',
|
||
annualFinancialAid: parseFloatOrZero(c.annual_financial_aid, 0),
|
||
calculatedTuition: parseFloatOrZero(c.tuition, 0),
|
||
extraPayment: parseFloatOrZero(c.extra_payment, 0),
|
||
inCollege:
|
||
c.college_enrollment_status === 'currently_enrolled' ||
|
||
c.college_enrollment_status === 'prospective_student',
|
||
gradDate: c.expected_graduation || null,
|
||
programType: c.program_type || null,
|
||
creditHoursPerYear: parseFloatOrZero(c.credit_hours_per_year, 0),
|
||
hoursCompleted: parseFloatOrZero(c.hours_completed, 0),
|
||
programLength: parseFloatOrZero(c.program_length, 0),
|
||
expectedSalary:
|
||
parseFloatOrZero(c.expected_salary) || parseFloatOrZero(f.current_salary, 0)
|
||
};
|
||
|
||
|
||
/* ── NEW: auto-extend horizon to cover furthest milestone ── */
|
||
let horizonYears = simulationYears; // default from the input box
|
||
if (allMilestones.length) {
|
||
// last dated milestone → Date object
|
||
const last = allMilestones
|
||
.filter(m => m.date)
|
||
.reduce(
|
||
(max, m) => (new Date(m.date) > max ? new Date(m.date) : max),
|
||
new Date()
|
||
);
|
||
|
||
const months = Math.ceil((last - new Date()) / (1000 * 60 * 60 * 24 * 30.44));
|
||
const years = Math.ceil(months / 12) + 1; // +1 yr buffer
|
||
horizonYears = Math.max(simulationYears, years);
|
||
}
|
||
|
||
|
||
const mergedProfile = {
|
||
currentSalary: financialBase.currentSalary,
|
||
monthlyExpenses: scenarioOverrides.monthlyExpenses,
|
||
monthlyDebtPayments: scenarioOverrides.monthlyDebtPayments,
|
||
retirementSavings: financialBase.retirementSavings,
|
||
emergencySavings: financialBase.emergencySavings,
|
||
monthlyRetirementContribution: scenarioOverrides.monthlyRetirementContribution,
|
||
monthlyEmergencyContribution: scenarioOverrides.monthlyEmergencyContribution,
|
||
surplusEmergencyAllocation: scenarioOverrides.surplusEmergencyAllocation,
|
||
surplusRetirementAllocation: scenarioOverrides.surplusRetirementAllocation,
|
||
additionalIncome: scenarioOverrides.additionalIncome,
|
||
|
||
// college
|
||
studentLoanAmount: collegeData.studentLoanAmount,
|
||
interestRate: collegeData.interestRate,
|
||
loanTerm: collegeData.loanTerm,
|
||
loanDeferralUntilGraduation: collegeData.loanDeferralUntilGraduation,
|
||
academicCalendar: collegeData.academicCalendar,
|
||
annualFinancialAid: collegeData.annualFinancialAid,
|
||
calculatedTuition: collegeData.calculatedTuition,
|
||
extraPayment: collegeData.extraPayment,
|
||
enrollmentDate: collegeProfile.enrollmentDate || null,
|
||
inCollege: collegeData.inCollege,
|
||
gradDate: collegeData.gradDate,
|
||
programType: collegeData.programType,
|
||
creditHoursPerYear: collegeData.creditHoursPerYear,
|
||
hoursCompleted: collegeData.hoursCompleted,
|
||
programLength: collegeData.programLength,
|
||
expectedSalary: collegeData.expectedSalary,
|
||
|
||
startDate: new Date().toISOString().slice(0, 10),
|
||
simulationYears: horizonYears,
|
||
milestoneImpacts: allImpacts,
|
||
|
||
interestStrategy,
|
||
flatAnnualRate,
|
||
monthlyReturnSamples: [], // or keep an array if you have historical data
|
||
randomRangeMin,
|
||
randomRangeMax
|
||
};
|
||
|
||
console.log('Merged profile to simulate =>', mergedProfile);
|
||
|
||
const { projectionData: pData, loanPaidOffMonth } =
|
||
simulateFinancialProjection(mergedProfile);
|
||
|
||
let cumu = mergedProfile.emergencySavings || 0;
|
||
const finalData = pData.map((mo) => {
|
||
cumu += mo.netSavings || 0;
|
||
return { ...mo, cumulativeNetSavings: cumu };
|
||
});
|
||
|
||
setProjectionData(finalData);
|
||
setLoanPayoffMonth(loanPaidOffMonth);
|
||
} catch (err) {
|
||
console.error('Error in scenario simulation =>', err);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!financialProfile || !scenarioRow || !collegeProfile) return;
|
||
fetchMilestones();
|
||
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]);
|
||
|
||
|
||
|
||
const [clickCount, setClickCount] = useState(() => {
|
||
const storedCount = localStorage.getItem('aiClickCount');
|
||
const storedDate = localStorage.getItem('aiClickDate');
|
||
const today = new Date().toISOString().slice(0, 10).slice(0, 10);
|
||
if (storedDate !== today) {
|
||
localStorage.setItem('aiClickDate', today);
|
||
localStorage.setItem('aiClickCount', '0');
|
||
return 0;
|
||
}
|
||
return parseInt(storedCount || '0', 10);
|
||
});
|
||
|
||
const DAILY_CLICK_LIMIT = 10; // example limit per day
|
||
|
||
|
||
const emergencyData = {
|
||
label: 'Emergency Savings',
|
||
data: projectionData.map((p) => p.emergencySavings),
|
||
borderColor: 'rgba(255, 159, 64, 1)',
|
||
backgroundColor: 'rgba(255, 159, 64, 0.2)',
|
||
tension: 0.4,
|
||
fill: true
|
||
};
|
||
const retirementData = {
|
||
label: 'Retirement Savings',
|
||
data: projectionData.map((p) => p.retirementSavings),
|
||
borderColor: 'rgba(75, 192, 192, 1)',
|
||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||
tension: 0.4,
|
||
fill: true
|
||
};
|
||
const totalSavingsData = {
|
||
label: 'Total Savings',
|
||
data: projectionData.map((p) => p.totalSavings),
|
||
borderColor: 'rgba(54, 162, 235, 1)',
|
||
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||
tension: 0.4,
|
||
fill: true
|
||
};
|
||
const loanBalanceData = {
|
||
label: 'Loan Balance',
|
||
data: projectionData.map((p) => p.loanBalance),
|
||
borderColor: 'rgba(255, 99, 132, 1)',
|
||
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
||
tension: 0.4,
|
||
fill: {
|
||
target: 'origin',
|
||
above: 'rgba(255,99,132,0.3)',
|
||
below: 'transparent'
|
||
}
|
||
};
|
||
|
||
const hasStudentLoan = useMemo(
|
||
() => projectionData.some(p => (p.loanBalance ?? 0) > 0),
|
||
[projectionData]
|
||
);
|
||
|
||
|
||
const chartDatasets = [emergencyData, retirementData];
|
||
if (hasStudentLoan) chartDatasets.push(loanBalanceData);
|
||
chartDatasets.push(totalSavingsData);
|
||
|
||
const yearsInCareer = getYearsInCareer(scenarioRow?.start_date);
|
||
|
||
// -- AI Handler --
|
||
async function handleAiClick() {
|
||
if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
|
||
alert('You have reached the daily limit for suggestions.');
|
||
return;
|
||
}
|
||
|
||
setAiLoading(true);
|
||
setSelectedIds([]);
|
||
|
||
const oldRecTitles = recommendations.map(r => r.title.trim()).filter(Boolean);
|
||
const acceptedTitles = scenarioMilestones.map(m => (m.title || '').trim()).filter(Boolean);
|
||
const allToAvoid = [...oldRecTitles, ...acceptedTitles];
|
||
|
||
try {
|
||
const payload = {
|
||
userProfile,
|
||
scenarioRow,
|
||
financialProfile,
|
||
collegeProfile,
|
||
previouslyUsedTitles: allToAvoid
|
||
};
|
||
|
||
const res = await authFetch('/api/premium/ai/next-steps', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (!res.ok) throw new Error('AI request failed');
|
||
|
||
const data = await res.json();
|
||
const rawText = data.recommendations || '';
|
||
const arr = parseAIJson(rawText);
|
||
|
||
setRecommendations(arr);
|
||
localStorage.setItem('aiRecommendations', JSON.stringify(arr));
|
||
|
||
// Update click count
|
||
setClickCount(prev => {
|
||
const newCount = prev + 1;
|
||
localStorage.setItem('aiClickCount', newCount);
|
||
return newCount;
|
||
});
|
||
|
||
} catch (err) {
|
||
console.error('Error fetching AI next steps =>', err);
|
||
} finally {
|
||
setAiLoading(false);
|
||
}
|
||
}
|
||
|
||
|
||
function handleSimulationYearsChange(e) {
|
||
setSimulationYearsInput(e.target.value);
|
||
}
|
||
function handleSimulationYearsBlur() {
|
||
if (!simulationYearsInput.trim()) setSimulationYearsInput('20');
|
||
}
|
||
|
||
const buttonLabel = recommendations.length > 0 ? 'New Suggestions' : 'What Should I Do Next?';
|
||
const chartRef = useRef(null);
|
||
|
||
const onEditMilestone = useCallback((m) => {
|
||
setMilestoneForModal({
|
||
...m,
|
||
impacts: impactsById[m.id] || [] // give the modal what it needs
|
||
});
|
||
}, [impactsById]);
|
||
|
||
const currentIdRef = useRef(null);
|
||
|
||
/* 1️⃣ The only deps it really needs */
|
||
const fetchMilestones = useCallback(async () => {
|
||
if (!careerProfileId) return;
|
||
|
||
const [profRes, uniRes] = await Promise.all([
|
||
authFetch(`api/premium/milestones?careerProfileId=${careerProfileId}`),
|
||
authFetch(`api/premium/milestones?careerProfileId=universal`)
|
||
]);
|
||
if (!profRes.ok || !uniRes.ok) return;
|
||
|
||
const [{ milestones: profMs }, { milestones: uniMs }] =
|
||
await Promise.all([profRes.json(), uniRes.json()]);
|
||
|
||
const merged = [...profMs, ...uniMs];
|
||
setScenarioMilestones(merged);
|
||
if (financialProfile && scenarioRow && collegeProfile) {
|
||
buildProjection(merged);
|
||
} // single rebuild
|
||
}, [financialProfile, scenarioRow, careerProfileId]); // ← NOTICE: no buildProjection here
|
||
|
||
const handleMilestonesCreated = useCallback(
|
||
(count = 0) => {
|
||
// optional toast
|
||
if (count) console.log(`💾 ${count} milestone(s) saved – refreshing list…`);
|
||
fetchMilestones();
|
||
},
|
||
[fetchMilestones]
|
||
);
|
||
|
||
return (
|
||
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-4">
|
||
|
||
{/* 0) New CareerCoach at the top */}
|
||
<CareerCoach
|
||
userProfile={userProfile}
|
||
financialProfile={financialProfile}
|
||
scenarioRow={scenarioRow}
|
||
setScenarioRow={setScenarioRow}
|
||
careerProfileId={careerProfileId}
|
||
collegeProfile={collegeProfile}
|
||
onMilestonesCreated={handleMilestonesCreated}
|
||
|
||
|
||
|
||
onAiRiskFetched={(riskData) => {
|
||
// store it in local state
|
||
setAiRisk(riskData);
|
||
}}
|
||
/>
|
||
|
||
{/* 1) Then your "Where Am I Now?" */}
|
||
<h2 className="text-2xl font-bold mb-4">Where you are now and where you are going:</h2>
|
||
|
||
{/* 1) Career */}
|
||
<div className="bg-white p-4 rounded shadow mb-4 flex flex-col justify-center items-center min-h-[80px]">
|
||
<p>
|
||
<strong>Target Career:</strong>{' '}
|
||
{scenarioRow?.career_name || '(Select a career)'}
|
||
</p>
|
||
{yearsInCareer && (
|
||
<p>
|
||
<strong>Time in this career:</strong> {yearsInCareer}{' '}
|
||
{yearsInCareer === '<1' ? 'year' : 'years'}
|
||
</p>
|
||
)}
|
||
|
||
{aiRisk?.riskLevel && (
|
||
<p className="text-center mt-2">
|
||
<strong>AI Automation Risk:</strong>{' '}
|
||
{aiRisk.riskLevel} <br />
|
||
<em>{aiRisk.reasoning}</em>
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 2) Salary Benchmarks */}
|
||
<div className="flex flex-col md:flex-row gap-4">
|
||
{salaryLoading && (
|
||
<div className="bg-white p-4 rounded shadow w-full text-center">
|
||
<p className="text-sm text-gray-500">Loading salary data…</p>
|
||
</div>
|
||
)}
|
||
{!salaryLoading && salaryData?.regional && (
|
||
|
||
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
|
||
<h4 className="font-medium mb-2">
|
||
Regional Salary Data ({userArea || 'U.S.'})
|
||
</h4>
|
||
<p>
|
||
10th percentile:{' '}
|
||
{salaryData.regional.regional_PCT10
|
||
? `$${parseFloat(salaryData.regional.regional_PCT10).toLocaleString()}`
|
||
: 'N/A'}
|
||
</p>
|
||
<p>
|
||
Median:{' '}
|
||
{salaryData.regional.regional_MEDIAN
|
||
? `$${parseFloat(salaryData.regional.regional_MEDIAN).toLocaleString()}`
|
||
: 'N/A'}
|
||
</p>
|
||
<p>
|
||
90th percentile:{' '}
|
||
{salaryData.regional.regional_PCT90
|
||
? `$${parseFloat(salaryData.regional.regional_PCT90).toLocaleString()}`
|
||
: 'N/A'}
|
||
</p>
|
||
|
||
|
||
<SalaryGauge
|
||
userSalary={userSalary}
|
||
percentileRow={salaryData.regional}
|
||
prefix="regional"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{!salaryLoading && salaryData?.national && (
|
||
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
|
||
<h4 className="font-medium mb-2">National Salary Data</h4>
|
||
<p>
|
||
10th percentile:{' '}
|
||
{salaryData.national.national_PCT10
|
||
? `$${parseFloat(salaryData.national.national_PCT10).toLocaleString()}`
|
||
: 'N/A'}
|
||
</p>
|
||
<p>
|
||
Median:{' '}
|
||
{salaryData.national.national_MEDIAN
|
||
? `$${parseFloat(salaryData.national.national_MEDIAN).toLocaleString()}`
|
||
: 'N/A'}
|
||
</p>
|
||
<p>
|
||
90th percentile:{' '}
|
||
{salaryData.national.national_PCT90
|
||
? `$${parseFloat(salaryData.national.national_PCT90).toLocaleString()}`
|
||
: 'N/A'}
|
||
</p>
|
||
|
||
<SalaryGauge
|
||
userSalary={userSalary}
|
||
percentileRow={salaryData.national}
|
||
prefix="national"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{!salaryLoading && !salaryData?.regional && !salaryData?.national && (
|
||
<div className="bg-white p-4 rounded shadow w-full text-center">
|
||
<p className="text-sm text-gray-500">No salary data found.</p>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
|
||
{/* 3) Economic Projections */}
|
||
<div className="flex flex-col md:flex-row gap-4 h-auto overflow-none">
|
||
{econLoading && (
|
||
<div className="bg-white p-4 rounded shadow w-full text-center">
|
||
<p className="text-sm text-gray-500">Loading projections…</p>
|
||
</div>
|
||
)}
|
||
{!econLoading && economicProjections?.state && (
|
||
<EconomicProjectionsBar data={economicProjections.state} />
|
||
)}
|
||
{!econLoading && economicProjections?.national && (
|
||
<EconomicProjectionsBar data={economicProjections.national} />
|
||
)}
|
||
</div>
|
||
{!economicProjections?.state && !economicProjections?.national && (
|
||
<div className="bg-white p-4 rounded shadow">
|
||
<p className="text-sm text-gray-500">No economic data found.</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 4) Career Goals
|
||
<div className="bg-white p-4 rounded shadow">
|
||
<h3 className="text-lg font-semibold mb-2">Your Career Goals</h3>
|
||
<p className="text-gray-700">
|
||
{scenarioRow?.career_goals || 'No career goals entered yet.'}
|
||
</p>
|
||
</div>*/}
|
||
|
||
{/* --- FINANCIAL PROJECTION SECTION -------------------------------- */}
|
||
|
||
{showMissingBanner && (
|
||
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 rounded shadow mb-4">
|
||
<p className="text-sm text-gray-800">
|
||
We need a few basics (income, expenses, etc.) before we can show a full
|
||
projection.
|
||
</p>
|
||
<Button
|
||
className="mt-2"
|
||
onClick={() => { setShowEditModal(true); setShowMissingBanner(false); }}
|
||
>
|
||
Add Details
|
||
</Button>
|
||
<button
|
||
className="ml-3 text-xs text-gray-600 underline"
|
||
onClick={() => setShowMissingBanner(false)}
|
||
>
|
||
Dismiss
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-white p-4 rounded shadow">
|
||
<h3 className="text-lg font-semibold mb-2 flex items-center justify-center gap-1">
|
||
Financial Projection
|
||
<InfoTooltip message="This projection uses the salary, expense, loan and contribution inputs you’ve set below. Click “Edit Simulation Inputs” to adjust them, or edit your financial information in Profile -> Financial Profile." />
|
||
</h3>
|
||
|
||
|
||
{projectionData.length ? (
|
||
<div>
|
||
{/* Chart – now full width */}
|
||
<div style={{ height: 360, width: '100%' }}>
|
||
<Line
|
||
ref={chartRef}
|
||
data={{
|
||
labels: projectionData.map(p => p.month),
|
||
datasets: chartDatasets
|
||
}}
|
||
options={{
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { position: 'bottom' },
|
||
tooltip: { mode: 'index', intersect: false },
|
||
annotation: { annotations: allAnnotations }, // ✅ new
|
||
zoom: zoomConfig
|
||
},
|
||
scales: xAndYScales // unchanged
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<Button onClick={() => chartRef.current?.resetZoom()}
|
||
className="mt-2 text-xs text-blue-600 hover:underline"
|
||
>
|
||
Reset Zoom
|
||
</Button>
|
||
|
||
{loanPayoffMonth && hasStudentLoan && (
|
||
<p className="font-semibold text-sm mt-2">
|
||
Loan Paid Off:
|
||
<span className="text-yellow-600">{loanPayoffMonth}</span>
|
||
</p>
|
||
)}
|
||
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-gray-500">No financial projection data found.</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Milestones – stacked list under chart */}
|
||
<div className="mt-4 bg-white p-4 rounded shadow">
|
||
<h4 className="text-lg font-semibold mb-2">
|
||
Milestones
|
||
<InfoTooltip message="Milestones are career or life events—promotions, relocations, degree completions, etc.—that may change your income or spending. They feed directly into the financial projection if they have a financial impact." />
|
||
</h4>
|
||
<MilestonePanel
|
||
groups={milestoneGroups}
|
||
onEdit={onEditMilestone}
|
||
onSelect={(m) => {
|
||
setDrawerMilestone(m);
|
||
setDrawerOpen(true);
|
||
}}
|
||
onAddNewMilestone={() => setAddingNewMilestone(true)}
|
||
/>
|
||
|
||
</div>
|
||
|
||
{/* 6) Simulation length + Edit scenario */}
|
||
<div className="mt-4 space-x-2">
|
||
<label className="font-medium">Simulation Length (years):</label>
|
||
<input
|
||
type="text"
|
||
value={simulationYearsInput}
|
||
onChange={handleSimulationYearsChange}
|
||
onBlur={handleSimulationYearsBlur}
|
||
className="border rounded p-1 w-16"
|
||
/>
|
||
<Button onClick={() => setShowEditModal(true)} className="ml-2">
|
||
Edit Simulation Inputs
|
||
</Button>
|
||
</div>
|
||
|
||
<ScenarioEditModal
|
||
key={careerProfileId}
|
||
show={showEditModal}
|
||
onClose={(didSave) => {
|
||
setShowEditModal(false);
|
||
if (didSave) reloadScenarioAndCollege(); // 👈 refresh after save
|
||
}}
|
||
scenario={scenarioRow}
|
||
financialProfile={financialProfile}
|
||
setFinancialProfile={setFinancialProfile}
|
||
collegeProfile={collegeProfile}
|
||
setCollegeProfile={setCollegeProfile}
|
||
authFetch={authFetch}
|
||
/>
|
||
|
||
|
||
{/* (E1) Interest Strategy */}
|
||
<label className="ml-4 font-medium">Interest Rate:</label>
|
||
<select
|
||
value={interestStrategy}
|
||
onChange={(e) => setInterestStrategy(e.target.value)}
|
||
className="border rounded p-1"
|
||
>
|
||
<option value="NONE">No Interest</option>
|
||
<option value="FLAT">Flat Rate</option>
|
||
<option value="MONTE_CARLO">Random</option>
|
||
</select>
|
||
|
||
{/* (E2) If FLAT => show the annual rate */}
|
||
{interestStrategy === 'FLAT' && (
|
||
<div className="inline-block ml-4">
|
||
<label className="mr-1">Annual Rate (%):</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={flatAnnualRate}
|
||
onChange={(e) => setFlatAnnualRate(parseFloatOrZero(e.target.value, 0.06))}
|
||
className="border rounded p-1 w-20"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* (E3) If MONTE_CARLO => show the random range */}
|
||
{interestStrategy === 'MONTE_CARLO' && (
|
||
<div className="inline-block ml-4">
|
||
<label className="mr-1">Min Return (%):</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={randomRangeMin}
|
||
onChange={(e) => setRandomRangeMin(parseFloatOrZero(e.target.value, -0.02))}
|
||
className="border rounded p-1 w-20 mr-2"
|
||
/>
|
||
<label className="mr-1">Max Return (%):</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={randomRangeMax}
|
||
onChange={(e) => setRandomRangeMax(parseFloatOrZero(e.target.value, 0.02))}
|
||
className="border rounded p-1 w-20"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* ───────────────────────────────────────────────
|
||
1. EDIT EXISTING MILESTONE (modal pops from grid, unchanged)
|
||
─────────────────────────────────────────────── */}
|
||
{milestoneForModal && (
|
||
<MilestoneEditModal
|
||
careerProfileId={careerProfileId}
|
||
milestones={scenarioMilestones}
|
||
milestone={milestoneForModal} /* ← edit mode */
|
||
fetchMilestones={fetchMilestones}
|
||
onClose={(didSave) => {
|
||
if (didSave) handleMilestonesCreated();
|
||
setMilestoneForModal(null);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* ───────────────────────────────────────────────
|
||
2. ADD-NEW MILESTONE (same modal, milestone = null)
|
||
─────────────────────────────────────────────── */}
|
||
{addingNewMilestone && (
|
||
<MilestoneEditModal
|
||
careerProfileId={careerProfileId}
|
||
milestones={scenarioMilestones}
|
||
milestone={null} /* ← create mode */
|
||
fetchMilestones={fetchMilestones}
|
||
onClose={(didSave) => {
|
||
setAddingNewMilestone(false);
|
||
if (didSave) fetchMilestones();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* ───────────────────────────────────────────────
|
||
3. RIGHT-HAND DRAWER
|
||
─────────────────────────────────────────────── */}
|
||
<MilestoneDrawer
|
||
open={drawerOpen}
|
||
milestone={drawerMilestone}
|
||
onClose={() => setDrawerOpen(false)}
|
||
onTaskToggle={(id, newStatus) => {
|
||
/* optimistic update or just refetch */
|
||
fetchMilestones();
|
||
}}
|
||
onAddNewMilestone={() => {
|
||
setDrawerOpen(false); // close drawer first
|
||
setAddingNewMilestone(true); // then open modal in create mode
|
||
}}
|
||
/>
|
||
|
||
{/* 7) AI Next Steps */}
|
||
{/* <div className="bg-white p-4 rounded shadow mt-4">
|
||
<Button onClick={handleAiClick} disabled={aiLoading || clickCount >= DAILY_CLICK_LIMIT}>
|
||
{buttonLabel}
|
||
</Button>
|
||
|
||
{aiLoading && <p>Generating your next steps…</p>}
|
||
|
||
{/* If we have structured recs, show checkboxes
|
||
{recommendations.length > 0 && (
|
||
<div className="mt-3">
|
||
<h3 className="font-semibold">Select the Advice You Want to Keep</h3>
|
||
<ul className="mt-2 space-y-2">
|
||
{recommendations.map((m) => (
|
||
<li key={m.id} className="flex items-start gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedIds.includes(m.id)}
|
||
onChange={() => handleToggle(m.id)}
|
||
/>
|
||
<div className="flex flex-col text-left">
|
||
<strong>{m.title}</strong>
|
||
<span>{m.date}</span>
|
||
<p className="text-sm">{m.description}</p>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{selectedIds.length > 0 && (
|
||
<Button className="mt-3" onClick={handleCreateSelectedMilestones}>
|
||
Create Milestones from Selected
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>*/}
|
||
</div>
|
||
);
|
||
}
|