dev1/src/components/MilestoneTracker.js

498 lines
17 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.

// src/components/MilestoneTracker.js
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';
// Keep MilestoneTimeline for +Add Milestone & tasks CRUD
import MilestoneTimeline from './MilestoneTimeline.js';
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);
// We will store the scenarios milestones in state so we can build annotation lines
const [scenarioMilestones, setScenarioMilestones] = useState([]);
const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
// --- ADDED: showEditModal state
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);
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); // store them for annotation lines
// fetch impacts for each
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 all
const allImpacts = milestonesWithImpacts.flatMap((m) => m.impacts);
// 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]);
// If you want to re-run simulation after any milestone changes:
const reSimulate = async () => {
// Put your logic to re-fetch scenario + milestones, then re-run sim (if needed).
};
// handle user typing simulation length
const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value);
const handleSimulationYearsBlur = () => {
if (!simulationYearsInput.trim()) {
setSimulationYearsInput('20');
}
};
// Build annotation lines 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}`;
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 you want them clickable:
onClick: () => {
console.log('Clicked milestone line => open editing for', m.title);
// e.g. open the MilestoneTimeline's edit feature, or do something
}
};
});
// If we also show 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) We keep MilestoneTimeline for tasks, +Add Milestone button, etc. */}
<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 input + the new Edit button */}
<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"
/>
{/* EDIT BUTTON => open ScenarioEditModal */}
<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>
)}
{/* Pass scenarioRow to the modal, and optionally do a hard refresh onClose */}
<ScenarioEditModal
show={showEditModal}
onClose={() => {
setShowEditModal(false);
// Hard-refresh if you want to replicate "ScenarioContainer" approach:
window.location.reload();
}}
scenario={scenarioRow}
financialProfile={financialProfile}
setFinancialProfile={setFinancialProfile}
collegeProfile={collegeProfile}
setCollegeProfile={setCollegeProfile}
apiURL={apiURL}
authFetch={authFetch}
/>
</div>
);
};
export default MilestoneTracker;