dev1/src/components/MilestoneTracker.js

481 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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 } 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 users 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;