dev1/src/components/CareerRoadmap.js

1723 lines
58 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 users 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 profileid ------------------------------------------------ */
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 EditScenario 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 users 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 youve 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:&nbsp;
<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>
);
}