481 lines
16 KiB
JavaScript
481 lines
16 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import { useLocation, useNavigate } from 'react-router-dom';
|
||
import { Line } from 'react-chartjs-2';
|
||
import {
|
||
Chart as ChartJS,
|
||
LineElement,
|
||
CategoryScale,
|
||
LinearScale,
|
||
PointElement,
|
||
Tooltip,
|
||
Legend
|
||
} from 'chart.js';
|
||
import annotationPlugin from 'chartjs-plugin-annotation';
|
||
import { Filler } from 'chart.js';
|
||
import { Button } from './ui/button.js';
|
||
import authFetch from '../utils/authFetch.js';
|
||
import CareerSelectDropdown from './CareerSelectDropdown.js';
|
||
import CareerSearch from './CareerSearch.js';
|
||
|
||
import MilestoneTimeline from './MilestoneTimeline.js'; // Key: This handles Milestone & Task CRUD
|
||
import AISuggestedMilestones from './AISuggestedMilestones.js';
|
||
import ScenarioEditModal from './ScenarioEditModal.js';
|
||
|
||
import './MilestoneTracker.css';
|
||
import './MilestoneTimeline.css';
|
||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||
|
||
// Register Chart + annotation plugin
|
||
ChartJS.register(
|
||
LineElement,
|
||
CategoryScale,
|
||
LinearScale,
|
||
Filler,
|
||
PointElement,
|
||
Tooltip,
|
||
Legend,
|
||
annotationPlugin
|
||
);
|
||
|
||
const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
const apiURL = process.env.REACT_APP_API_URL;
|
||
|
||
// --------------------------------------------------
|
||
// State
|
||
// --------------------------------------------------
|
||
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
|
||
const [careerPathId, setCareerPathId] = useState(null);
|
||
const [existingCareerPaths, setExistingCareerPaths] = useState([]);
|
||
const [activeView, setActiveView] = useState('Career');
|
||
|
||
const [financialProfile, setFinancialProfile] = useState(null);
|
||
const [scenarioRow, setScenarioRow] = useState(null);
|
||
const [collegeProfile, setCollegeProfile] = useState(null);
|
||
|
||
const [scenarioMilestones, setScenarioMilestones] = useState([]); // for annotation
|
||
|
||
const [projectionData, setProjectionData] = useState([]);
|
||
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
||
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
||
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
|
||
|
||
// Show/hide scenario edit modal
|
||
const [showEditModal, setShowEditModal] = useState(false);
|
||
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
|
||
|
||
const {
|
||
projectionData: initialProjectionData = [],
|
||
loanPayoffMonth: initialLoanPayoffMonth = null
|
||
} = location.state || {};
|
||
|
||
// --------------------------------------------------
|
||
// 1) Fetch user’s scenario list + financialProfile
|
||
// --------------------------------------------------
|
||
useEffect(() => {
|
||
const fetchCareerPaths = async () => {
|
||
const res = await authFetch(`${apiURL}/premium/career-profile/all`);
|
||
if (!res || !res.ok) return;
|
||
const data = await res.json();
|
||
setExistingCareerPaths(data.careerPaths);
|
||
|
||
// If user came from a different route passing in a selected scenario:
|
||
const fromPopout = location.state?.selectedCareer;
|
||
if (fromPopout) {
|
||
setSelectedCareer(fromPopout);
|
||
setCareerPathId(fromPopout.career_path_id);
|
||
} else if (!selectedCareer) {
|
||
// fallback: fetch the 'latest' scenario
|
||
const latest = await authFetch(`${apiURL}/premium/career-profile/latest`);
|
||
if (latest && latest.ok) {
|
||
const latestData = await latest.json();
|
||
if (latestData?.id) {
|
||
setSelectedCareer(latestData);
|
||
setCareerPathId(latestData.id);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const fetchFinancialProfile = async () => {
|
||
const res = await authFetch(`${apiURL}/premium/financial-profile`);
|
||
if (res?.ok) {
|
||
const data = await res.json();
|
||
setFinancialProfile(data);
|
||
}
|
||
};
|
||
|
||
fetchCareerPaths();
|
||
fetchFinancialProfile();
|
||
}, [apiURL, location.state, selectedCareer]);
|
||
|
||
// --------------------------------------------------
|
||
// 2) When careerPathId changes => fetch scenarioRow + collegeProfile
|
||
// --------------------------------------------------
|
||
useEffect(() => {
|
||
if (!careerPathId) {
|
||
setScenarioRow(null);
|
||
setCollegeProfile(null);
|
||
setScenarioMilestones([]);
|
||
return;
|
||
}
|
||
|
||
async function fetchScenario() {
|
||
const scenRes = await authFetch(`${apiURL}/premium/career-profile/${careerPathId}`);
|
||
if (scenRes.ok) {
|
||
const data = await scenRes.json();
|
||
setScenarioRow(data);
|
||
} else {
|
||
console.error('Failed to fetch scenario row:', scenRes.status);
|
||
setScenarioRow(null);
|
||
}
|
||
}
|
||
|
||
async function fetchCollege() {
|
||
const colRes = await authFetch(
|
||
`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`
|
||
);
|
||
if (!colRes?.ok) {
|
||
setCollegeProfile(null);
|
||
return;
|
||
}
|
||
const data = await colRes.json();
|
||
setCollegeProfile(data);
|
||
}
|
||
|
||
fetchScenario();
|
||
fetchCollege();
|
||
}, [careerPathId, apiURL]);
|
||
|
||
// --------------------------------------------------
|
||
// 3) Once scenarioRow + collegeProfile + financialProfile => run simulation
|
||
// + fetch milestones for annotation lines
|
||
// --------------------------------------------------
|
||
useEffect(() => {
|
||
if (!financialProfile || !scenarioRow || !collegeProfile) return;
|
||
|
||
(async () => {
|
||
try {
|
||
// fetch milestones for this scenario
|
||
const milRes = await authFetch(
|
||
`${apiURL}/premium/milestones?careerPathId=${careerPathId}`
|
||
);
|
||
if (!milRes.ok) {
|
||
console.error('Failed to fetch milestones for scenario', careerPathId);
|
||
return;
|
||
}
|
||
const milestonesData = await milRes.json();
|
||
const allMilestones = milestonesData.milestones || [];
|
||
setScenarioMilestones(allMilestones);
|
||
|
||
// fetch impacts for each milestone
|
||
const impactPromises = allMilestones.map((m) =>
|
||
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((data) => data?.impacts || [])
|
||
.catch((err) => {
|
||
console.warn('Error fetching impacts for milestone', m.id, err);
|
||
return [];
|
||
})
|
||
);
|
||
const impactsForEach = await Promise.all(impactPromises);
|
||
const milestonesWithImpacts = allMilestones.map((m, i) => ({
|
||
...m,
|
||
impacts: impactsForEach[i] || []
|
||
}));
|
||
|
||
// flatten
|
||
const allImpacts = milestonesWithImpacts.flatMap((m) => m.impacts);
|
||
|
||
// Build mergedProfile
|
||
const mergedProfile = {
|
||
currentSalary: financialProfile.current_salary || 0,
|
||
monthlyExpenses:
|
||
scenarioRow.planned_monthly_expenses ??
|
||
financialProfile.monthly_expenses ??
|
||
0,
|
||
monthlyDebtPayments:
|
||
scenarioRow.planned_monthly_debt_payments ??
|
||
financialProfile.monthly_debt_payments ??
|
||
0,
|
||
retirementSavings: financialProfile.retirement_savings ?? 0,
|
||
emergencySavings: financialProfile.emergency_fund ?? 0,
|
||
monthlyRetirementContribution:
|
||
scenarioRow.planned_monthly_retirement_contribution ??
|
||
financialProfile.retirement_contribution ??
|
||
0,
|
||
monthlyEmergencyContribution:
|
||
scenarioRow.planned_monthly_emergency_contribution ??
|
||
financialProfile.emergency_contribution ??
|
||
0,
|
||
surplusEmergencyAllocation:
|
||
scenarioRow.planned_surplus_emergency_pct ??
|
||
financialProfile.extra_cash_emergency_pct ??
|
||
50,
|
||
surplusRetirementAllocation:
|
||
scenarioRow.planned_surplus_retirement_pct ??
|
||
financialProfile.extra_cash_retirement_pct ??
|
||
50,
|
||
additionalIncome:
|
||
scenarioRow.planned_additional_income ??
|
||
financialProfile.additional_income ??
|
||
0,
|
||
|
||
// college
|
||
studentLoanAmount: collegeProfile.existing_college_debt || 0,
|
||
interestRate: collegeProfile.interest_rate || 5,
|
||
loanTerm: collegeProfile.loan_term || 10,
|
||
loanDeferralUntilGraduation: !!collegeProfile.loan_deferral_until_graduation,
|
||
academicCalendar: collegeProfile.academic_calendar || 'monthly',
|
||
annualFinancialAid: collegeProfile.annual_financial_aid || 0,
|
||
calculatedTuition: collegeProfile.tuition || 0,
|
||
extraPayment: collegeProfile.extra_payment || 0,
|
||
inCollege:
|
||
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
|
||
collegeProfile.college_enrollment_status === 'prospective_student',
|
||
gradDate: collegeProfile.expected_graduation || null,
|
||
programType: collegeProfile.program_type || null,
|
||
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
|
||
hoursCompleted: collegeProfile.hours_completed || 0,
|
||
programLength: collegeProfile.program_length || 0,
|
||
expectedSalary:
|
||
collegeProfile.expected_salary || financialProfile.current_salary || 0,
|
||
|
||
// scenario horizon
|
||
startDate: new Date().toISOString(),
|
||
simulationYears,
|
||
|
||
milestoneImpacts: allImpacts
|
||
};
|
||
|
||
const { projectionData: pData, loanPaidOffMonth: payoff } =
|
||
simulateFinancialProjection(mergedProfile);
|
||
|
||
let cumu = mergedProfile.emergencySavings || 0;
|
||
const finalData = pData.map((mo) => {
|
||
cumu += mo.netSavings || 0;
|
||
return { ...mo, cumulativeNetSavings: cumu };
|
||
});
|
||
|
||
setProjectionData(finalData);
|
||
setLoanPayoffMonth(payoff);
|
||
} catch (err) {
|
||
console.error('Error in scenario simulation:', err);
|
||
}
|
||
})();
|
||
}, [financialProfile, scenarioRow, collegeProfile, careerPathId, apiURL, simulationYears]);
|
||
|
||
const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value);
|
||
const handleSimulationYearsBlur = () => {
|
||
if (!simulationYearsInput.trim()) {
|
||
setSimulationYearsInput('20');
|
||
}
|
||
};
|
||
|
||
// Build chart annotations from scenarioMilestones
|
||
const milestoneAnnotationLines = {};
|
||
scenarioMilestones.forEach((m) => {
|
||
if (!m.date) return;
|
||
const d = new Date(m.date);
|
||
if (isNaN(d)) return;
|
||
|
||
const year = d.getUTCFullYear();
|
||
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||
const short = `${year}-${month}`;
|
||
|
||
// check if we have data for that month
|
||
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'
|
||
}
|
||
};
|
||
});
|
||
|
||
// If we also want a line for payoff:
|
||
const annotationConfig = {};
|
||
if (loanPayoffMonth) {
|
||
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 };
|
||
|
||
return (
|
||
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-6">
|
||
{/* 1) Career dropdown */}
|
||
<CareerSelectDropdown
|
||
existingCareerPaths={existingCareerPaths}
|
||
selectedCareer={selectedCareer}
|
||
onChange={(selected) => {
|
||
setSelectedCareer(selected);
|
||
setCareerPathId(selected?.id || null);
|
||
}}
|
||
loading={!existingCareerPaths.length}
|
||
authFetch={authFetch}
|
||
/>
|
||
|
||
{/* 2) MilestoneTimeline for Milestone & Task CRUD */}
|
||
<MilestoneTimeline
|
||
careerPathId={careerPathId}
|
||
authFetch={authFetch}
|
||
activeView="Career"
|
||
onMilestoneUpdated={() => {}}
|
||
/>
|
||
|
||
{/* 3) AI-Suggested Milestones */}
|
||
<AISuggestedMilestones
|
||
career={selectedCareer?.career_name}
|
||
careerPathId={careerPathId}
|
||
authFetch={authFetch}
|
||
activeView={activeView}
|
||
projectionData={projectionData}
|
||
/>
|
||
|
||
{/* 4) The main chart with annotation lines */}
|
||
{projectionData.length > 0 && (
|
||
<div className="bg-white p-4 rounded shadow space-y-4">
|
||
<h3 className="text-lg font-semibold">Financial Projection</h3>
|
||
<Line
|
||
data={{
|
||
labels: projectionData.map((p) => p.month),
|
||
datasets: [
|
||
{
|
||
label: 'Total Savings',
|
||
data: projectionData.map((p) => p.cumulativeNetSavings),
|
||
borderColor: 'rgba(54, 162, 235, 1)',
|
||
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||
tension: 0.4,
|
||
fill: true
|
||
},
|
||
{
|
||
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'
|
||
}
|
||
},
|
||
{
|
||
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
|
||
}
|
||
]
|
||
}}
|
||
options={{
|
||
responsive: true,
|
||
plugins: {
|
||
legend: { position: 'bottom' },
|
||
tooltip: { mode: 'index', intersect: false },
|
||
annotation: {
|
||
annotations: allAnnotations
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: false,
|
||
ticks: {
|
||
callback: (value) => `$${value.toLocaleString()}`
|
||
}
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
<div>
|
||
{loanPayoffMonth && (
|
||
<p className="font-semibold text-sm">
|
||
Loan Paid Off at: <span className="text-yellow-600">{loanPayoffMonth}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 5) Simulation length + "Edit" Button => open ScenarioEditModal */}
|
||
<div className="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>
|
||
|
||
{/* 6) Career Search, scenario edit modal, etc. */}
|
||
<CareerSearch
|
||
onCareerSelected={(careerObj) => {
|
||
setPendingCareerForModal(careerObj.title);
|
||
}}
|
||
/>
|
||
|
||
{pendingCareerForModal && (
|
||
<Button
|
||
onClick={() => {
|
||
console.log('User confirmed new career path:', pendingCareerForModal);
|
||
setPendingCareerForModal(null);
|
||
}}
|
||
className="bg-blue-500 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
|
||
>
|
||
Confirm Career Change to {pendingCareerForModal}
|
||
</Button>
|
||
)}
|
||
|
||
<ScenarioEditModal
|
||
show={showEditModal}
|
||
onClose={() => {
|
||
setShowEditModal(false);
|
||
// optionally reload to see scenario changes
|
||
window.location.reload();
|
||
}}
|
||
scenario={scenarioRow}
|
||
financialProfile={financialProfile}
|
||
setFinancialProfile={setFinancialProfile}
|
||
collegeProfile={collegeProfile}
|
||
setCollegeProfile={setCollegeProfile}
|
||
apiURL={apiURL}
|
||
authFetch={authFetch}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default MilestoneTracker;
|