Added scenario length user input field and drafted scenario multi-view and scenario container components.

This commit is contained in:
Josh 2025-04-22 13:27:59 +00:00
parent 3c4247f3b8
commit eed3767172
6 changed files with 429 additions and 5 deletions

View File

@ -58,6 +58,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
const [simulationYearsInput, setSimulationYearsInput] = useState("20");
const [showEditModal, setShowEditModal] = useState(false);
// Possibly loaded from location.state
@ -66,6 +68,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
loanPayoffMonth: initialLoanPayoffMonth = null
} = location.state || {};
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
// -------------------------
// 1. Fetch career paths + financialProfile on mount
// -------------------------
@ -198,7 +201,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary,
// The key: impacts
milestoneImpacts: allImpacts
milestoneImpacts: allImpacts,
simulationYears,
};
// 5) Run the simulation
@ -218,7 +223,19 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
console.error('Error fetching initial milestones/impacts or simulating:', err);
}
})();
}, [financialProfile, collegeProfile, selectedCareer, careerPathId]);
}, [financialProfile, collegeProfile, simulationYears, selectedCareer, careerPathId]);
const handleSimulationYearsChange = (e) => {
setSimulationYearsInput(e.target.value); // let user type partial/blank
};
const handleSimulationYearsBlur = () => {
// Optionally, onBlur you can “normalize” the value
// (e.g. if they left it blank, revert to "20").
if (simulationYearsInput.trim() === "") {
setSimulationYearsInput("20");
}
};
// -------------------------------------------------
// 4. reSimulate() => re-fetch everything (financial, college, milestones & impacts),
@ -453,6 +470,16 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
</div>
)}
<div>
<label>Simulation Length (years): </label>
<input
type="text"
value={simulationYearsInput}
onChange={handleSimulationYearsChange}
onBlur={handleSimulationYearsBlur}
/>
</div>
<CareerSearch
onSelectCareer={(careerName) => setPendingCareerForModal(careerName)}
setPendingCareerForModal={setPendingCareerForModal}

View File

@ -0,0 +1,162 @@
// src/components/MultiScenarioView.js
import React, { useEffect, useState } from 'react';
import authFetch from '../utils/authFetch.js';
import ScenarioContainer from './ScenarioContainer.js';
import { v4 as uuidv4 } from 'uuid';
export default function MultiScenarioView() {
const [loading, setLoading] = useState(false);
const [financialProfile, setFinancialProfile] = useState(null);
const [scenarios, setScenarios] = useState([]); // each scenario corresponds to a row in career_paths
// For error reporting
const [error, setError] = useState(null);
// 1) On mount, fetch the users single financial profile + all career_paths.
useEffect(() => {
async function loadData() {
setLoading(true);
setError(null);
try {
// A) fetch financial profile
let finRes = await authFetch('/api/premium/financial-profile');
if (!finRes.ok) throw new Error(`FIN profile error: ${finRes.status}`);
let finData = await finRes.json();
// B) fetch all career_paths (scenarios)
let scenRes = await authFetch('/api/premium/career-profile/all');
if (!scenRes.ok) throw new Error(`Scenarios error: ${scenRes.status}`);
let scenData = await scenRes.json();
setFinancialProfile(finData);
setScenarios(scenData.careerPaths || []);
} catch (err) {
console.error('Error loading premium data:', err);
setError(err.message || 'Failed to load data');
} finally {
setLoading(false);
}
}
loadData();
}, []);
// 2) “Add Scenario” => create a brand new row in career_paths
async function handleAddScenario() {
try {
// You might prompt user for a scenario name, or just default
const body = {
career_name: 'New Scenario ' + new Date().toLocaleDateString(),
status: 'planned',
start_date: new Date().toISOString(),
college_enrollment_status: 'not_enrolled',
currently_working: 'no'
};
const res = await authFetch('/api/premium/career-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`Add scenario error: ${res.status}`);
const data = await res.json();
// re-fetch scenarios or just push a new scenario object
const newRow = {
id: data.career_path_id,
user_id: null, // we can skip if not needed
career_name: body.career_name,
status: body.status,
start_date: body.start_date,
projected_end_date: null,
college_enrollment_status: body.college_enrollment_status,
currently_working: body.currently_working
};
setScenarios(prev => [...prev, newRow]);
} catch (err) {
console.error('Failed adding scenario:', err);
alert('Could not add scenario');
}
}
// 3) “Clone” => POST a new row in career_paths with copied fields
async function handleCloneScenario(sourceScenario) {
try {
// A simple approach: just create a new row with the same fields
// Then copy the existing scenario fields
const body = {
career_name: sourceScenario.career_name + ' (Copy)',
status: sourceScenario.status,
start_date: sourceScenario.start_date,
projected_end_date: sourceScenario.projected_end_date,
college_enrollment_status: sourceScenario.college_enrollment_status,
currently_working: sourceScenario.currently_working
};
const res = await authFetch('/api/premium/career-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`Clone scenario error: ${res.status}`);
const data = await res.json();
const newScenarioId = data.career_path_id;
// Optionally, also clone the scenarios milestones, if you want them duplicated:
// (Youd fetch all existing milestones for sourceScenario, then re-insert them for newScenario.)
// This example just leaves that out for brevity.
// Add it to local state
const newRow = {
id: newScenarioId,
career_name: body.career_name,
status: body.status,
start_date: body.start_date,
projected_end_date: body.projected_end_date,
college_enrollment_status: body.college_enrollment_status,
currently_working: body.currently_working
};
setScenarios(prev => [...prev, newRow]);
} catch (err) {
console.error('Failed cloning scenario:', err);
alert('Could not clone scenario');
}
}
// 4) “Remove” => (If you want a delete scenario)
async function handleRemoveScenario(scenarioId) {
try {
// If you have a real DELETE endpoint for career_paths, use it:
// For now, well just remove from the local UI:
setScenarios(prev => prev.filter(s => s.id !== scenarioId));
// Optionally, implement an API call:
// await authFetch(`/api/premium/career-profile/${scenarioId}`, { method: 'DELETE' });
} catch (err) {
console.error('Failed removing scenario:', err);
alert('Could not remove scenario');
}
}
if (loading) return <p>Loading scenarios...</p>;
if (error) return <p className="text-red-600">Error: {error}</p>;
return (
<div className="multi-scenario-view" style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
{scenarios.map((scen) => (
<ScenarioContainer
key={scen.id}
scenario={scen}
financialProfile={financialProfile} // shared for all scenarios
onClone={() => handleCloneScenario(scen)}
onRemove={() => handleRemoveScenario(scen.id)}
// Optionally refresh the scenario if user changes it
onScenarioUpdated={(updated) => {
setScenarios(prev => prev.map(s => s.id === scen.id ? { ...s, ...updated } : s));
}}
/>
))}
<div style={{ alignSelf: 'flex-start' }}>
<button onClick={handleAddScenario}>+ Add Scenario</button>
</div>
</div>
);
}

View File

@ -266,7 +266,7 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData, careerPathId })
}
setAutoTuition(Math.round(estimate));
// We do NOT auto-update parent's data. We'll do that in handleSubmit or if you prefer, you can store it in parent's data anyway.
}, [
icTuitionData, selected_school, program_type,
credit_hours_per_year, is_in_state, is_in_district, schoolData

View File

@ -0,0 +1,231 @@
// src/components/ScenarioContainer.js
import React, { useState, useEffect } from 'react';
import { Line } from 'react-chartjs-2';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
// Reuse your existing:
import ScenarioEditModal from './ScenarioEditModal.js';
import MilestoneTimeline from './MilestoneTimeline.js';
import AISuggestedMilestones from './AISuggestedMilestones.js';
import authFetch from '../utils/authFetch.js';
export default function ScenarioContainer({
scenario, // from career_paths row
financialProfile, // single row, shared across user
onClone,
onRemove,
onScenarioUpdated // callback to parent to store updated scenario data
}) {
const [collegeProfile, setCollegeProfile] = useState(null);
const [milestones, setMilestones] = useState([]);
const [universalMilestones, setUniversalMilestones] = useState([]);
const [projectionData, setProjectionData] = useState([]);
const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null);
const [editOpen, setEditOpen] = useState(false);
// 1) Fetch the college profile for this scenario
useEffect(() => {
if (!scenario?.id) return;
async function loadCollegeProfile() {
try {
const res = await authFetch(`/api/premium/college-profile?careerPathId=${scenario.id}`);
if (res.ok) {
const data = await res.json();
setCollegeProfile(data);
} else {
console.warn('No college profile found or error:', res.status);
setCollegeProfile({});
}
} catch (err) {
console.error('Failed fetching college profile:', err);
}
}
loadCollegeProfile();
}, [scenario]);
// 2) Fetch scenarios milestones (where is_universal=0) + universal (is_universal=1)
useEffect(() => {
if (!scenario?.id) return;
async function loadMilestones() {
try {
const [scenRes, uniRes] = await Promise.all([
authFetch(`/api/premium/milestones?careerPathId=${scenario.id}`),
// for universal: we do an extra call with no careerPathId.
// But your current code always requires a careerPathId. So you might
// create a new endpoint /api/premium/milestones?is_universal=1 or something.
// We'll assume you have it:
authFetch(`/api/premium/milestones?careerPathId=universal`)
]);
let scenarioData = scenRes.ok ? (await scenRes.json()) : { milestones: [] };
let universalData = uniRes.ok ? (await uniRes.json()) : { milestones: [] };
setMilestones(scenarioData.milestones || []);
setUniversalMilestones(universalData.milestones || []);
} catch (err) {
console.error('Failed to load milestones:', err);
}
}
loadMilestones();
}, [scenario]);
// 3) Whenever we have financialProfile + collegeProfile + milestones, run the simulation
useEffect(() => {
if (!financialProfile || !collegeProfile) return;
// Merge them into the userProfile object for the simulator:
const mergedProfile = {
// Financial fields
currentSalary: financialProfile.current_salary || 0,
monthlyExpenses: financialProfile.monthly_expenses || 0,
monthlyDebtPayments: financialProfile.monthly_debt_payments || 0,
retirementSavings: financialProfile.retirement_savings || 0,
emergencySavings: financialProfile.emergency_fund || 0,
monthlyRetirementContribution: financialProfile.retirement_contribution || 0,
monthlyEmergencyContribution: financialProfile.emergency_contribution || 0,
surplusEmergencyAllocation: financialProfile.extra_cash_emergency_pct || 50,
surplusRetirementAllocation: financialProfile.extra_cash_retirement_pct || 50,
// College fields (scenario-based)
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 || 'semester',
annualFinancialAid: collegeProfile.annual_financial_aid || 0,
calculatedTuition: collegeProfile.tuition || 0,
extraPayment: collegeProfile.extra_payment || 0,
gradDate: collegeProfile.expected_graduation || null,
programType: collegeProfile.program_type || '',
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
hoursCompleted: collegeProfile.hours_completed || 0,
programLength: collegeProfile.program_length || 0,
// We assume users baseline “inCollege” from the DB:
inCollege:
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
collegeProfile.college_enrollment_status === 'prospective_student',
// If you store expected_salary in collegeProfile
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary,
// Flatten the scenario + universal milestones impacts
milestoneImpacts: buildAllImpacts([...milestones, ...universalMilestones])
};
const { projectionData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile);
setProjectionData(projectionData);
setLoanPaidOffMonth(loanPaidOffMonth);
}, [financialProfile, collegeProfile, milestones, universalMilestones]);
// Helper: Flatten all milestone impacts into one array for the simulator
function buildAllImpacts(allMilestones) {
let impacts = [];
for (let m of allMilestones) {
// Possibly fetch m.impacts if you store them directly on the milestone
// or if you fetch them separately.
// If your code stores them as `m.impacts = [ { direction, amount, ... } ]`
if (m.impacts) {
impacts.push(...m.impacts);
}
// If you also want a milestone that sets a new salary, handle that logic too
// E.g., { impact_type: 'SALARY_CHANGE', start_date: m.date, newSalary: m.new_salary }
}
return impacts;
}
// 4) Well display a single line chart with Net Savings (or cumulativeNetSavings)
const labels = projectionData.map((p) => p.month);
const netSavingsData = projectionData.map((p) => p.cumulativeNetSavings || 0);
// Grab final row for some KPIs
const finalRow = projectionData[projectionData.length - 1] || {};
const finalRet = finalRow.retirementSavings?.toFixed(0) || '0';
const finalEmerg = finalRow.emergencySavings?.toFixed(0) || '0';
// 5) Handle “Edit” scenario -> open your existing `ScenarioEditModal.js`
// But that modal currently references setFinancialProfile, setCollegeProfile directly,
// so you may want a specialized version that changes only this scenarios row.
// For simplicity, well just show how to open it:
return (
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
<h3>{scenario.career_name || 'Untitled Scenario'}</h3>
<p>Status: {scenario.status}</p>
<Line
data={{
labels,
datasets: [
{
label: 'Net Savings',
data: netSavingsData,
borderColor: 'blue',
fill: false
}
]
}}
options={{ responsive: true }}
/>
<div style={{ marginTop: '0.5rem' }}>
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br />
<strong>Final Retirement:</strong> ${finalRet} <br />
<strong>Final Emergency:</strong> ${finalEmerg}
</div>
{/* The timeline for this scenario. We pass careerPathId */}
<MilestoneTimeline
careerPathId={scenario.id}
authFetch={authFetch}
activeView="Financial" // or a state that toggles Career vs. Financial
setActiveView={() => {}}
onMilestoneUpdated={() => {
// re-fetch or something
// We'll just force a re-fetch of scenarios milestones
// or re-run the entire load effect
}}
/>
{/* Show AI suggestions if you like */}
<AISuggestedMilestones
career={scenario.career_name}
careerPathId={scenario.id}
authFetch={authFetch}
activeView="Financial"
projectionData={projectionData}
/>
<div style={{ marginTop: '0.5rem' }}>
<button onClick={() => setEditOpen(true)}>Edit</button>
<button onClick={onClone} style={{ marginLeft: '0.5rem' }}>Clone</button>
<button onClick={onRemove} style={{ marginLeft: '0.5rem', color: 'red' }}>
Remove
</button>
</div>
{/* Reuse your existing ScenarioEditModal that expects
setFinancialProfile, setCollegeProfile, etc.
However, you might want a specialized "ScenarioEditModal" that updates
the DB fields for *this* scenario. For now, we just show how to open. */}
<ScenarioEditModal
show={editOpen}
onClose={() => setEditOpen(false)}
financialProfile={financialProfile}
setFinancialProfile={() => {
// If you truly want scenario-specific financial data,
// youd do a more advanced approach.
// For now, do nothing or re-fetch from server.
}}
collegeProfile={collegeProfile}
setCollegeProfile={(updated) => {
setCollegeProfile((prev) => ({ ...prev, ...updated }));
}}
apiURL="/api"
authFetch={authFetch}
/>
</div>
);
}

View File

@ -135,7 +135,11 @@ export function simulateFinancialProjection(userProfile) {
stateCode = 'GA',
// Financial milestone impacts
milestoneImpacts = []
milestoneImpacts = [],
// Simulation duration
simulationYears = 20
} = userProfile;
/***************************************************
@ -204,7 +208,7 @@ export function simulateFinancialProjection(userProfile) {
/***************************************************
* 6) SETUP FOR THE SIMULATION LOOP
***************************************************/
const maxMonths = 240; // 20 years
const maxMonths = simulationYears*12; // 20 years
let loanBalance = Math.max(studentLoanAmount, 0);
let loanPaidOffMonth = null;

Binary file not shown.