1158 lines
37 KiB
JavaScript
1158 lines
37 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import { useLocation } from 'react-router-dom';
|
||
import { Line, Bar } from 'react-chartjs-2';
|
||
import {
|
||
Chart as ChartJS,
|
||
LineElement,
|
||
BarElement,
|
||
CategoryScale,
|
||
LinearScale,
|
||
Filler,
|
||
PointElement,
|
||
Tooltip,
|
||
Legend
|
||
} from 'chart.js';
|
||
import annotationPlugin from 'chartjs-plugin-annotation';
|
||
|
||
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 { Button } from './ui/button.js';
|
||
import CareerSelectDropdown from './CareerSelectDropdown.js';
|
||
import MilestoneTimeline from './MilestoneTimeline.js';
|
||
import ScenarioEditModal from './ScenarioEditModal.js';
|
||
|
||
import './CareerRoadmap.css';
|
||
import './MilestoneTimeline.css';
|
||
|
||
// --------------
|
||
// Register ChartJS Plugins
|
||
// --------------
|
||
ChartJS.register(
|
||
LineElement,
|
||
BarElement,
|
||
CategoryScale,
|
||
LinearScale,
|
||
Filler,
|
||
PointElement,
|
||
Tooltip,
|
||
Legend,
|
||
annotationPlugin
|
||
);
|
||
|
||
// --------------
|
||
// Helper Functions
|
||
// --------------
|
||
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();
|
||
}
|
||
|
||
/**
|
||
* parseAiJson
|
||
* If ChatGPT returns a fenced code block like:
|
||
* ```json
|
||
* [ { ... }, ... ]
|
||
* ```
|
||
* we extract that JSON. Otherwise, we parse the raw string.
|
||
*/
|
||
function parseAiJson(rawText) {
|
||
const fencedRegex = /```json\s*([\s\S]*?)\s*```/i;
|
||
const match = rawText.match(fencedRegex);
|
||
if (match) {
|
||
const jsonStr = match[1].trim();
|
||
const arr = JSON.parse(jsonStr);
|
||
// Add an "id" for each milestone
|
||
arr.forEach((m) => {
|
||
m.id = crypto.randomUUID();
|
||
});
|
||
return arr;
|
||
} else {
|
||
// fallback if no fences
|
||
const arr = JSON.parse(rawText);
|
||
arr.forEach((m) => {
|
||
m.id = crypto.randomUUID();
|
||
});
|
||
return arr;
|
||
}
|
||
}
|
||
|
||
export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
||
const location = useLocation();
|
||
const apiURL = process.env.REACT_APP_API_URL;
|
||
|
||
const [interestStrategy, setInterestStrategy] = useState('FLAT'); // 'NONE' | 'FLAT' | 'MONTE_CARLO'
|
||
const [flatAnnualRate, setFlatAnnualRate] = useState(0.06); // 6% default
|
||
const [randomRangeMin, setRandomRangeMin] = useState(-0.02); // -3% monthly
|
||
const [randomRangeMax, setRandomRangeMax] = useState(0.02); // 8% monthly
|
||
|
||
// 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 [strippedSocCode, setStrippedSocCode] = useState(null);
|
||
const [salaryData, setSalaryData] = useState(null);
|
||
const [economicProjections, setEconomicProjections] = useState(null);
|
||
|
||
// Milestones & Projection
|
||
const [scenarioMilestones, setScenarioMilestones] = useState([]);
|
||
const [projectionData, setProjectionData] = useState([]);
|
||
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
||
|
||
// 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 {
|
||
projectionData: initProjData = [],
|
||
loanPayoffMonth: initLoanMonth = null
|
||
} = location.state || {};
|
||
|
||
// 1) Fetch user + financial
|
||
useEffect(() => {
|
||
async function fetchUser() {
|
||
try {
|
||
const r = await authFetch('/api/user-profile');
|
||
if (r.ok) setUserProfile(await r.json());
|
||
} catch (err) {
|
||
console.error('Error user-profile =>', err);
|
||
}
|
||
}
|
||
async function fetchFin() {
|
||
try {
|
||
const r = await authFetch(`${apiURL}/premium/financial-profile`);
|
||
if (r.ok) setFinancialProfile(await r.json());
|
||
} catch (err) {
|
||
console.error('Error financial =>', err);
|
||
}
|
||
}
|
||
fetchUser();
|
||
fetchFin();
|
||
}, [apiURL]);
|
||
|
||
const userSalary = parseFloatOrZero(financialProfile?.current_salary, 0);
|
||
const userArea = userProfile?.area || 'U.S.';
|
||
const userState = getFullStateName(userProfile?.state || '') || 'United States';
|
||
|
||
useEffect(() => {
|
||
let timer;
|
||
if (buttonDisabled) {
|
||
timer = setTimeout(() => setButtonDisabled(false), RATE_LIMIT_SECONDS * 1000);
|
||
}
|
||
return () => clearTimeout(timer);
|
||
}, [buttonDisabled]);
|
||
|
||
useEffect(() => {
|
||
const storedRecs = localStorage.getItem('aiRecommendations');
|
||
if (storedRecs) {
|
||
try {
|
||
const arr = JSON.parse(storedRecs);
|
||
arr.forEach((m) => {
|
||
if (!m.id) {
|
||
m.id = crypto.randomUUID();
|
||
}
|
||
});
|
||
setRecommendations(arr);
|
||
} catch (err) {
|
||
console.error('Error parsing stored AI recs =>', err);
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
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
|
||
useEffect(() => {
|
||
async function fetchProfiles() {
|
||
const r = await authFetch(`${apiURL}/premium/career-profile/all`);
|
||
if (!r || !r.ok) return;
|
||
const d = await r.json();
|
||
setExistingCareerProfiles(d.careerProfiles);
|
||
|
||
const fromPopout = location.state?.selectedCareer;
|
||
if (fromPopout) {
|
||
setSelectedCareer(fromPopout);
|
||
setCareerProfileId(fromPopout.career_profile_id);
|
||
} else {
|
||
const stored = localStorage.getItem('lastSelectedCareerProfileId');
|
||
if (stored) {
|
||
const match = d.careerProfiles.find((p) => p.id === stored);
|
||
if (match) {
|
||
setSelectedCareer(match);
|
||
setCareerProfileId(stored);
|
||
return;
|
||
}
|
||
}
|
||
// fallback => latest
|
||
const lr = await authFetch(`${apiURL}/premium/career-profile/latest`);
|
||
if (lr && lr.ok) {
|
||
const ld = await lr.json();
|
||
if (ld?.id) {
|
||
setSelectedCareer(ld);
|
||
setCareerProfileId(ld.id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
fetchProfiles();
|
||
}, [apiURL, location.state]);
|
||
|
||
// 4) scenarioRow + college
|
||
useEffect(() => {
|
||
if (!careerProfileId) {
|
||
setScenarioRow(null);
|
||
setCollegeProfile(null);
|
||
setScenarioMilestones([]);
|
||
return;
|
||
}
|
||
localStorage.setItem('lastSelectedCareerProfileId', careerProfileId);
|
||
|
||
async function fetchScenario() {
|
||
const s = await authFetch(`${apiURL}/premium/career-profile/${careerProfileId}`);
|
||
if (s.ok) setScenarioRow(await s.json());
|
||
}
|
||
async function fetchCollege() {
|
||
const c = await authFetch(`${apiURL}/premium/college-profile?careerProfileId=${careerProfileId}`);
|
||
if (c.ok) setCollegeProfile(await c.json());
|
||
}
|
||
fetchScenario();
|
||
fetchCollege();
|
||
}, [careerProfileId, apiURL]);
|
||
|
||
// 5) from scenarioRow => find the full SOC => strip
|
||
useEffect(() => {
|
||
if (!scenarioRow?.career_name || !masterCareerRatings.length) {
|
||
setStrippedSocCode(null);
|
||
return;
|
||
}
|
||
const lower = scenarioRow.career_name.trim().toLowerCase();
|
||
const found = masterCareerRatings.find(
|
||
(obj) => obj.title?.trim().toLowerCase() === lower
|
||
);
|
||
if (!found) {
|
||
console.warn('No matching SOC =>', scenarioRow.career_name);
|
||
setStrippedSocCode(null);
|
||
return;
|
||
}
|
||
setStrippedSocCode(stripSocCode(found.soc_code));
|
||
}, [scenarioRow, masterCareerRatings]);
|
||
|
||
// 6) Salary
|
||
useEffect(() => {
|
||
if (!strippedSocCode) {
|
||
setSalaryData(null);
|
||
return;
|
||
}
|
||
(async () => {
|
||
try {
|
||
const qs = new URLSearchParams({ socCode: strippedSocCode, area: userArea }).toString();
|
||
const url = `${apiURL}/salary?${qs}`;
|
||
const r = await fetch(url);
|
||
if (!r.ok) {
|
||
console.error('[Salary fetch non-200 =>]', r.status);
|
||
setSalaryData(null);
|
||
return;
|
||
}
|
||
const dd = await r.json();
|
||
setSalaryData(dd);
|
||
} catch (err) {
|
||
console.error('[Salary fetch error]', err);
|
||
setSalaryData(null);
|
||
}
|
||
})();
|
||
}, [strippedSocCode, userArea, apiURL]);
|
||
|
||
// 7) Econ
|
||
useEffect(() => {
|
||
if (!strippedSocCode || !userState) {
|
||
setEconomicProjections(null);
|
||
return;
|
||
}
|
||
(async () => {
|
||
const qs = new URLSearchParams({ state: userState }).toString();
|
||
const econUrl = `${apiURL}/projections/${strippedSocCode}?${qs}`;
|
||
try {
|
||
const r = await authFetch(econUrl);
|
||
if (!r.ok) {
|
||
console.error('[Econ fetch non-200 =>]', r.status);
|
||
setEconomicProjections(null);
|
||
return;
|
||
}
|
||
const econData = await r.json();
|
||
setEconomicProjections(econData);
|
||
} catch (err) {
|
||
console.error('[Econ fetch error]', err);
|
||
setEconomicProjections(null);
|
||
}
|
||
})();
|
||
}, [strippedSocCode, userState, apiURL]);
|
||
|
||
// 8) Build financial projection
|
||
async function buildProjection() {
|
||
try {
|
||
const milUrl = `${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`;
|
||
const mr = await authFetch(milUrl);
|
||
if (!mr.ok) {
|
||
console.error('Failed to fetch milestones =>', mr.status);
|
||
return;
|
||
}
|
||
const md = await mr.json();
|
||
const allMilestones = md.milestones || [];
|
||
setScenarioMilestones(allMilestones);
|
||
|
||
// fetch impacts
|
||
const imPromises = allMilestones.map((m) =>
|
||
authFetch(`${apiURL}/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);
|
||
|
||
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)
|
||
};
|
||
|
||
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,
|
||
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,
|
||
milestoneImpacts: allImpacts,
|
||
|
||
interestStrategy,
|
||
flatAnnualRate,
|
||
monthlyReturnSamples: [], // or keep an array if you have historical data
|
||
randomRangeMin,
|
||
randomRangeMax
|
||
};
|
||
|
||
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;
|
||
buildProjection();
|
||
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, apiURL, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]);
|
||
|
||
// Build chart datasets / annotations
|
||
const milestoneAnnotationLines = {};
|
||
scenarioMilestones.forEach((m) => {
|
||
if (!m.date) return;
|
||
const d = new Date(m.date);
|
||
if (isNaN(d)) return;
|
||
const yyyy = d.getUTCFullYear();
|
||
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||
const short = `${yyyy}-${mm}`;
|
||
if (!projectionData.some((p) => p.month === short)) return;
|
||
|
||
milestoneAnnotationLines[`milestone_${m.id}`] = {
|
||
type: 'line',
|
||
xMin: short,
|
||
xMax: short,
|
||
borderColor: 'orange',
|
||
borderWidth: 2,
|
||
label: {
|
||
display: true,
|
||
content: m.title || 'Milestone',
|
||
color: 'orange',
|
||
position: 'end'
|
||
}
|
||
};
|
||
});
|
||
|
||
const [clickCount, setClickCount] = useState(() => {
|
||
const storedCount = localStorage.getItem('aiClickCount');
|
||
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 hasStudentLoan = projectionData.some((p) => p.loanBalance > 0);
|
||
const annotationConfig = {};
|
||
if (loanPayoffMonth && hasStudentLoan) {
|
||
annotationConfig.loanPaidOffLine = {
|
||
type: 'line',
|
||
xMin: loanPayoffMonth,
|
||
xMax: loanPayoffMonth,
|
||
borderColor: 'rgba(255, 206, 86, 1)',
|
||
borderWidth: 2,
|
||
borderDash: [6, 6],
|
||
label: {
|
||
display: true,
|
||
content: 'Loan Paid Off',
|
||
position: 'end',
|
||
backgroundColor: 'rgba(255, 206, 86, 0.8)',
|
||
color: '#000',
|
||
font: { size: 12 },
|
||
rotation: 0,
|
||
yAdjust: -10
|
||
}
|
||
};
|
||
}
|
||
const allAnnotations = { ...milestoneAnnotationLines, ...annotationConfig };
|
||
|
||
const emergencyData = {
|
||
label: 'Emergency Savings',
|
||
data: projectionData.map((p) => p.emergencySavings),
|
||
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 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;
|
||
}
|
||
if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
|
||
alert('You have reached your daily limit of AI-generated recommendations. Please check back tomorrow.');
|
||
return;
|
||
}
|
||
|
||
setAiLoading(true);
|
||
setSelectedIds([]);
|
||
|
||
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 handleToggle(recId) {
|
||
setSelectedIds((prev) => {
|
||
if (prev.includes(recId)) {
|
||
return prev.filter((x) => x !== recId);
|
||
} else {
|
||
return [...prev, recId];
|
||
}
|
||
});
|
||
}
|
||
|
||
async function handleCreateSelectedMilestones() {
|
||
if (!careerProfileId) return;
|
||
const confirm = window.confirm('Create the selected AI suggestions as milestones?');
|
||
if (!confirm) return;
|
||
|
||
const selectedRecs = recommendations.filter((r) => selectedIds.includes(r.id));
|
||
if (!selectedRecs.length) return;
|
||
|
||
// Use the AI-suggested date:
|
||
const payload = selectedRecs.map((rec) => ({
|
||
title: rec.title,
|
||
description: rec.description || '',
|
||
date: rec.date, // <-- use AI's date, not today's date
|
||
career_profile_id: careerProfileId
|
||
}));
|
||
|
||
try {
|
||
const r = await authFetch('/api/premium/milestone', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ milestones: payload })
|
||
});
|
||
if (!r.ok) throw new Error('Failed to create new milestones');
|
||
|
||
// re-run projection to see them in the chart
|
||
await buildProjection();
|
||
|
||
// optionally clear
|
||
alert('Milestones created successfully!');
|
||
setSelectedIds([]);
|
||
} catch (err) {
|
||
console.error('Error creating milestones =>', err);
|
||
alert('Error saving new AI milestones.');
|
||
}
|
||
}
|
||
|
||
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?';
|
||
|
||
return (
|
||
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-4">
|
||
<h2 className="text-2xl font-bold mb-4">Where Am I Now?</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>Current Career:</strong>{' '}
|
||
{scenarioRow?.career_name || '(Select a career)'}
|
||
</p>
|
||
{yearsInCareer && (
|
||
<p>
|
||
<strong>Time in this career:</strong> {yearsInCareer}{' '}
|
||
{yearsInCareer === '<1' ? 'year' : 'years'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 2) Salary Benchmarks */}
|
||
<div className="flex flex-col md:flex-row gap-4">
|
||
{salaryData?.regional && (
|
||
<div className="bg-white p-4 rounded shadow w-full md:w-1/2">
|
||
<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>
|
||
)}
|
||
|
||
{salaryData?.national && (
|
||
<div className="bg-white p-4 rounded shadow w-full md:w-1/2">
|
||
<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>
|
||
)}
|
||
</div>
|
||
|
||
{/* 3) Economic Projections */}
|
||
<div className="flex flex-col md:flex-row gap-4">
|
||
{economicProjections?.state && (
|
||
<EconomicProjectionsBar data={economicProjections.state} />
|
||
)}
|
||
{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>
|
||
|
||
{/* 5) Financial Projection */}
|
||
<div className="bg-white p-4 rounded shadow">
|
||
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
|
||
{projectionData.length > 0 ? (
|
||
<>
|
||
<Line
|
||
data={{
|
||
labels: projectionData.map((p) => p.month),
|
||
datasets: chartDatasets
|
||
}}
|
||
options={{
|
||
responsive: true,
|
||
plugins: {
|
||
legend: { position: 'bottom' },
|
||
tooltip: { mode: 'index', intersect: false },
|
||
annotation: { annotations: allAnnotations }
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: false,
|
||
ticks: {
|
||
callback: (val) => `$${val.toLocaleString()}`
|
||
}
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
{loanPayoffMonth && hasStudentLoan && (
|
||
<p className="font-semibold text-sm mt-2">
|
||
Loan Paid Off at:{' '}
|
||
<span className="text-yellow-600">{loanPayoffMonth}</span>
|
||
</p>
|
||
)}
|
||
</>
|
||
) : (
|
||
<p className="text-sm text-gray-500">No financial projection data found.</p>
|
||
)}
|
||
</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
|
||
</Button>
|
||
</div>
|
||
<ScenarioEditModal
|
||
show={showEditModal}
|
||
onClose={() => {
|
||
setShowEditModal(false);
|
||
window.location.reload();
|
||
}}
|
||
scenario={scenarioRow}
|
||
financialProfile={financialProfile}
|
||
setFinancialProfile={setFinancialProfile}
|
||
collegeProfile={collegeProfile}
|
||
setCollegeProfile={setCollegeProfile}
|
||
apiURL={apiURL}
|
||
authFetch={authFetch}
|
||
/>
|
||
|
||
{/* (E1) Interest Strategy */}
|
||
<label className="ml-4 font-medium">Interest Strategy:</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>
|
||
)}
|
||
|
||
{/* 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>
|
||
);
|
||
}
|