Added scenario length user input field and drafted scenario multi-view and scenario container components.
This commit is contained in:
parent
8d1dcf26b9
commit
23cb8fa28e
@ -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}
|
||||
|
162
src/components/MultiScenarioView.js
Normal file
162
src/components/MultiScenarioView.js
Normal 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 user’s 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 scenario’s milestones, if you want them duplicated:
|
||||
// (You’d 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, we’ll 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>
|
||||
);
|
||||
}
|
@ -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
|
||||
|
231
src/components/ScenarioContainer.js
Normal file
231
src/components/ScenarioContainer.js
Normal 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 scenario’s 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 user’s 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) We’ll 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 scenario’s row.
|
||||
// For simplicity, we’ll 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 scenario’s 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,
|
||||
// you’d 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
|
||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user