From 402e7606725339b04ef303bed11a563336ea62e5 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 29 Apr 2025 15:04:43 +0000 Subject: [PATCH] Fixed multiscenarioView to simulate properly. --- backend/server3.js | 48 +- src/components/MilestoneCopyWizard.js | 45 + src/components/MultiScenarioView.js | 216 +-- .../PremiumOnboarding/FinancialOnboarding.js | 76 +- .../PremiumOnboarding/OnboardingContainer.js | 39 +- src/components/ScenarioContainer.js | 1069 +++++++++++-- src/components/ScenarioEditModal.js | 1352 ++++++++++------- src/components/ScenarioEditWizard.js | 165 ++ src/utils/FinancialProjectionService.js | 107 +- user_profile.db | Bin 106496 -> 106496 bytes user_proile.db | 0 11 files changed, 2229 insertions(+), 888 deletions(-) create mode 100644 src/components/MilestoneCopyWizard.js create mode 100644 src/components/ScenarioEditWizard.js create mode 100644 user_proile.db diff --git a/backend/server3.js b/backend/server3.js index a319b50..3efd846 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -115,7 +115,7 @@ app.get('/api/premium/career-profile/:careerPathId', authenticatePremiumUser, as }); // POST a new career profile -// server3.js + app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res) => { const { scenario_title, @@ -143,8 +143,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res const newCareerPathId = uuidv4(); const now = new Date().toISOString(); - // Insert or update row in career_paths. We rely on ON CONFLICT(user_id, career_name). - // If you want a different conflict target, change accordingly. + // Upsert via ON CONFLICT(user_id, career_name) await db.run(` INSERT INTO career_paths ( id, @@ -156,7 +155,6 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res projected_end_date, college_enrollment_status, currently_working, - planned_monthly_expenses, planned_monthly_debt_payments, planned_monthly_retirement_contribution, @@ -164,13 +162,16 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res planned_surplus_emergency_pct, planned_surplus_retirement_pct, planned_additional_income, - created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, - ?, ?) + VALUES ( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, + ?, ? + ) ON CONFLICT(user_id, career_name) DO UPDATE SET status = excluded.status, @@ -189,19 +190,19 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res updated_at = ? `, [ - newCareerPathId, - req.userId, - scenario_title || null, - career_name, - status || 'planned', - start_date || now, - projected_end_date || null, - college_enrollment_status || null, - currently_working || null, + // 18 items for the INSERT columns + newCareerPathId, // id + req.userId, // user_id + scenario_title || null, // scenario_title + career_name, // career_name + status || 'planned', // status + start_date || now, // start_date + projected_end_date || null, // projected_end_date + college_enrollment_status || null, // college_enrollment_status + currently_working || null, // currently_working - // new planned columns - planned_monthly_expenses ?? null, - planned_monthly_debt_payments ?? null, + planned_monthly_expenses ?? null, // planned_monthly_expenses + planned_monthly_debt_payments ?? null, // planned_monthly_debt_payments planned_monthly_retirement_contribution ?? null, planned_monthly_emergency_contribution ?? null, planned_surplus_emergency_pct ?? null, @@ -210,10 +211,12 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res now, // created_at now, // updated_at - now // updated_at on conflict + + // Then 1 more param for "updated_at = ?" in the conflict update + now ]); - // Optionally fetch the row's ID (or entire row) after upsert: + // Optionally fetch the row's ID or entire row after upsert const result = await db.get(` SELECT id FROM career_paths @@ -231,7 +234,6 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res } }); -// server3.js (or your premium server file) // Delete a career path (scenario) by ID app.delete('/api/premium/career-profile/:careerPathId', authenticatePremiumUser, async (req, res) => { diff --git a/src/components/MilestoneCopyWizard.js b/src/components/MilestoneCopyWizard.js new file mode 100644 index 0000000..db449ca --- /dev/null +++ b/src/components/MilestoneCopyWizard.js @@ -0,0 +1,45 @@ +// src/components/MilestoneCopyWizard.js +import React, { useState, useEffect } from 'react'; + +export default function MilestoneCopyWizard({ milestone, authFetch, onClose }) { + const [scenarios, setScenarios] = useState([]); + const [selectedScenarios, setSelectedScenarios] = useState([]); + + useEffect(() => { + // fetch /api/premium/career-profile/all => setScenarios + }, [authFetch]); + + function toggleScenario(id) { + setSelectedScenarios((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ); + } + + async function handleCopy() { + // POST => /api/premium/milestone/copy + // with { milestoneId: milestone.id, scenarioIds: selectedScenarios } + // Then onClose(true) + } + + if (!milestone) return null; + return ( +
+
+

Copy: {milestone.title}

+ {scenarios.map((s) => ( + + ))} +
+ + +
+
+ ); +} diff --git a/src/components/MultiScenarioView.js b/src/components/MultiScenarioView.js index 01a5767..b12263e 100644 --- a/src/components/MultiScenarioView.js +++ b/src/components/MultiScenarioView.js @@ -1,8 +1,10 @@ -// src/components/MultiScenarioView.js import React, { useEffect, useState } from 'react'; import authFetch from '../utils/authFetch.js'; import ScenarioContainer from './ScenarioContainer.js'; -import ScenarioEditModal from './ScenarioEditModal.js'; // The floating modal + +// This component loads the user's global financial profile +// plus a list of all scenarios, and renders one +// for each. It also has the "Add Scenario" and "Clone/Remove" logic. export default function MultiScenarioView() { const [loading, setLoading] = useState(false); @@ -11,44 +13,38 @@ export default function MultiScenarioView() { // The user’s single overall financial profile const [financialProfile, setFinancialProfile] = useState(null); - // All scenario rows (from career_paths) + // The list of scenario "headers" (career_paths) const [scenarios, setScenarios] = useState([]); - // The scenario we’re currently editing in a top-level modal: - const [editingScenario, setEditingScenario] = useState(null); - // The collegeProfile we load for that scenario (passed to edit modal) - const [editingCollegeProfile, setEditingCollegeProfile] = useState(null); - useEffect(() => { - async function loadData() { - setLoading(true); - setError(null); - try { - // 1) Fetch the user’s overall financial profile - const finRes = await authFetch('/api/premium/financial-profile'); - if (!finRes.ok) throw new Error(`FinancialProfile error: ${finRes.status}`); - const finData = await finRes.json(); - - // 2) Fetch all scenarios (career_paths) - const scenRes = await authFetch('/api/premium/career-profile/all'); - if (!scenRes.ok) throw new Error(`Scenarios error: ${scenRes.status}`); - const scenData = await scenRes.json(); - - setFinancialProfile(finData); - setScenarios(scenData.careerPaths || []); - } catch (err) { - console.error('Error loading data in MultiScenarioView:', err); - setError(err.message || 'Failed to load scenarios/financial'); - } finally { - setLoading(false); - } - } - loadData(); + loadScenariosAndFinancial(); }, []); - // --------------------------- - // Add a new scenario - // --------------------------- + async function loadScenariosAndFinancial() { + setLoading(true); + setError(null); + try { + // 1) fetch user’s global financialProfile + const finRes = await authFetch('/api/premium/financial-profile'); + if (!finRes.ok) throw new Error(`FinancialProfile error: ${finRes.status}`); + const finData = await finRes.json(); + + // 2) fetch scenario list + const scenRes = await authFetch('/api/premium/career-profile/all'); + if (!scenRes.ok) throw new Error(`Scenarios error: ${scenRes.status}`); + const scenData = await scenRes.json(); + + setFinancialProfile(finData); + setScenarios(scenData.careerPaths || []); + } catch (err) { + console.error('MultiScenarioView load error:', err); + setError(err.message || 'Failed to load multi-scenarios'); + } finally { + setLoading(false); + } + } + + // Add a new scenario => then reload async function handleAddScenario() { try { const body = { @@ -64,37 +60,30 @@ export default function MultiScenarioView() { body: JSON.stringify(body) }); if (!res.ok) throw new Error(`Add scenario error: ${res.status}`); - const data = await res.json(); - - // Insert the new row into local state - const newRow = { - id: data.career_path_id, - 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]); + await loadScenariosAndFinancial(); } catch (err) { - console.error('Failed adding scenario:', err); - alert(err.message || 'Could not add scenario'); + alert(err.message); } } - // --------------------------- - // Clone scenario - // --------------------------- - async function handleCloneScenario(sourceScenario) { + // Clone a scenario => then reload + async function handleCloneScenario(s) { try { 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 + scenario_title: s.scenario_title ? s.scenario_title + ' (Copy)' : null, + career_name: s.career_name ? s.career_name + ' (Copy)' : 'Untitled (Copy)', + status: s.status, + start_date: s.start_date, + projected_end_date: s.projected_end_date, + college_enrollment_status: s.college_enrollment_status, + currently_working: s.currently_working, + planned_monthly_expenses: s.planned_monthly_expenses, + planned_monthly_debt_payments: s.planned_monthly_debt_payments, + planned_monthly_retirement_contribution: s.planned_monthly_retirement_contribution, + planned_monthly_emergency_contribution: s.planned_monthly_emergency_contribution, + planned_surplus_emergency_pct: s.planned_surplus_emergency_pct, + planned_surplus_retirement_pct: s.planned_surplus_retirement_pct, + planned_additional_income: s.planned_additional_income }; const res = await authFetch('/api/premium/career-profile', { method: 'POST', @@ -102,112 +91,51 @@ export default function MultiScenarioView() { 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; - 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]); + await loadScenariosAndFinancial(); } catch (err) { - console.error('Failed cloning scenario:', err); - alert(err.message || 'Could not clone scenario'); + alert(err.message); } } - // --------------------------- - // Delete scenario - // --------------------------- - async function handleRemoveScenario(scenarioId) { - // confirm - const confirmDel = window.confirm( - 'Delete this scenario (and associated collegeProfile/milestones)?' - ); + // Remove => reload + async function handleRemoveScenario(id) { + const confirmDel = window.confirm('Delete this scenario?'); if (!confirmDel) return; - try { - const res = await authFetch(`/api/premium/career-profile/${scenarioId}`, { + const res = await authFetch(`/api/premium/career-profile/${id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(`Delete scenario error: ${res.status}`); - // remove from local - setScenarios((prev) => prev.filter((s) => s.id !== scenarioId)); + await loadScenariosAndFinancial(); } catch (err) { - console.error('Delete scenario error:', err); - alert(err.message || 'Could not delete scenario'); + alert(err.message); } } - // --------------------------- - // User clicks "Edit" in ScenarioContainer => we fetch that scenario’s collegeProfile - // set editingScenario / editingCollegeProfile => modal - // --------------------------- - async function handleEditScenario(scenarioObj) { - if (!scenarioObj?.id) return; - try { - // fetch the collegeProfile - const colResp = await authFetch(`/api/premium/college-profile?careerPathId=${scenarioObj.id}`); - let colData = {}; - if (colResp.ok) { - colData = await colResp.json(); - } - setEditingScenario(scenarioObj); - setEditingCollegeProfile(colData); - } catch (err) { - console.error('Error loading collegeProfile for editing:', err); - setEditingScenario(scenarioObj); - setEditingCollegeProfile({}); - } - } - - // Called by on close => we optionally update local scenario - function handleModalClose(updatedScenario, updatedCollege) { - if (updatedScenario) { - setScenarios(prev => - prev.map((s) => (s.id === updatedScenario.id ? { ...s, ...updatedScenario } : s)) - ); - } - // We might not store the updatedCollege in local state unless we want to re-simulate immediately - // For now, do nothing or re-fetch if needed - setEditingScenario(null); - setEditingCollegeProfile(null); - } + // If user wants to "edit" a scenario, we'll pass it down to the container's "onEdit" + // or you can open a modal at this level. For now, we rely on the ScenarioContainer + // "onEdit" prop if needed. if (loading) return

Loading scenarios...

; - if (error) return

Error: {error}

; + if (error) return

{error}

; return ( -
- {scenarios.map((scen) => ( +
+ {scenarios.map(sc => ( handleCloneScenario(scen)} - onRemove={() => handleRemoveScenario(scen.id)} - onEdit={() => handleEditScenario(scen)} // new callback + onClone={(s) => handleCloneScenario(s)} + onRemove={(id) => handleRemoveScenario(id)} + onEdit={(sc) => { + // Example: open an edit modal or navigate to a scenario editor. + console.log('Edit scenario clicked:', sc); + }} /> ))} -
- -
- - {/* The floating modal at the bottom => only if editingScenario != null */} - {editingScenario && ( - - )} +
); } diff --git a/src/components/PremiumOnboarding/FinancialOnboarding.js b/src/components/PremiumOnboarding/FinancialOnboarding.js index f507dbc..6d6da21 100644 --- a/src/components/PremiumOnboarding/FinancialOnboarding.js +++ b/src/components/PremiumOnboarding/FinancialOnboarding.js @@ -1,6 +1,6 @@ import React from 'react'; -const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => { +const FinancialOnboarding = ({ nextStep, prevStep, data, setData, isEditMode = false }) => { const { currently_working = '', current_salary = 0, @@ -12,7 +12,15 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => { emergency_fund = 0, emergency_contribution = 0, extra_cash_emergency_pct = "", - extra_cash_retirement_pct = "" + extra_cash_retirement_pct = "", + + planned_monthly_expenses = '', + planned_monthly_debt_payments = '', + planned_monthly_retirement_contribution = '', + planned_monthly_emergency_contribution = '', + planned_surplus_emergency_pct = '', + planned_surplus_retirement_pct = '', + planned_additional_income = '' } = data; const handleChange = (e) => { @@ -134,6 +142,70 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => { onChange={handleChange} /> +{/* Only show the planned overrides if isEditMode is true */} +{isEditMode && ( + <> +
+

Planned Scenario Overrides

+

These fields let you override your real finances for this scenario.

+ + + + + + + + + + + + + + + + + + + + + + + )}
diff --git a/src/components/PremiumOnboarding/OnboardingContainer.js b/src/components/PremiumOnboarding/OnboardingContainer.js index b87353f..244f816 100644 --- a/src/components/PremiumOnboarding/OnboardingContainer.js +++ b/src/components/PremiumOnboarding/OnboardingContainer.js @@ -26,23 +26,30 @@ const OnboardingContainer = () => { // Now we do the final “all done” submission when the user finishes the last step const handleFinalSubmit = async () => { try { - // 1) POST career-profile + // Build a scenarioPayload that includes the optional planned_* fields. + // We parseFloat them to avoid sending strings, and default to 0 if empty. + const scenarioPayload = { + ...careerData, + planned_monthly_expenses: parseFloat(careerData.planned_monthly_expenses) || 0, + planned_monthly_debt_payments: parseFloat(careerData.planned_monthly_debt_payments) || 0, + planned_monthly_retirement_contribution: parseFloat(careerData.planned_monthly_retirement_contribution) || 0, + planned_monthly_emergency_contribution: parseFloat(careerData.planned_monthly_emergency_contribution) || 0, + planned_surplus_emergency_pct: parseFloat(careerData.planned_surplus_emergency_pct) || 0, + planned_surplus_retirement_pct: parseFloat(careerData.planned_surplus_retirement_pct) || 0, + planned_additional_income: parseFloat(careerData.planned_additional_income) || 0 + }; + + // 1) POST career-profile (scenario) const careerRes = await authFetch('/api/premium/career-profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(careerData), + body: JSON.stringify(scenarioPayload), }); if (!careerRes.ok) throw new Error('Failed to save career profile'); const careerJson = await careerRes.json(); const { career_path_id } = careerJson; if (!career_path_id) throw new Error('No career_path_id returned by server'); - const mergedCollegeData = { - ...collegeData, - // ensure this field isn’t null - college_enrollment_status: careerData.college_enrollment_status, - career_path_id - }; - + // 2) POST financial-profile const financialRes = await authFetch('/api/premium/financial-profile', { method: 'POST', @@ -50,19 +57,20 @@ const OnboardingContainer = () => { body: JSON.stringify(financialData), }); if (!financialRes.ok) throw new Error('Failed to save financial profile'); - + // 3) POST college-profile (include career_path_id) - const mergedCollege = { - ...collegeData, - college_enrollment_status: careerData.college_enrollment_status, - career_path_id }; + const mergedCollege = { + ...collegeData, + career_path_id, + college_enrollment_status: careerData.college_enrollment_status + }; const collegeRes = await authFetch('/api/premium/college-profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mergedCollege), }); if (!collegeRes.ok) throw new Error('Failed to save college profile'); - + // Done => navigate away navigate('/milestone-tracker'); } catch (err) { @@ -70,6 +78,7 @@ const OnboardingContainer = () => { // (optionally show error to user) } }; + const onboardingSteps = [ , diff --git a/src/components/ScenarioContainer.js b/src/components/ScenarioContainer.js index ac75a62..611d934 100644 --- a/src/components/ScenarioContainer.js +++ b/src/components/ScenarioContainer.js @@ -1,152 +1,1039 @@ -// src/components/ScenarioContainer.js -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Line } from 'react-chartjs-2'; -import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; -import MilestoneTimeline from './MilestoneTimeline.js'; -import AISuggestedMilestones from './AISuggestedMilestones.js'; -import authFetch from '../utils/authFetch.js'; +import { Chart as ChartJS } from 'chart.js'; +import annotationPlugin from 'chartjs-plugin-annotation'; +import authFetch from '../utils/authFetch.js'; +import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; +import AISuggestedMilestones from './AISuggestedMilestones.js'; +import ScenarioEditModal from './ScenarioEditModal.js'; + +// Register the annotation plugin (though we won't use it for milestone markers). +ChartJS.register(annotationPlugin); + +/** + * ScenarioContainer + * ----------------- + * This component: + * - Renders a handler + function handleScenarioSelect(e) { + const chosenId = e.target.value; + const found = allScenarios.find((s) => s.id === chosenId); + if (found) setLocalScenario(found); + else setLocalScenario(null); + } + + /************************************************************* + * 2) COLLEGE PROFILE + MILESTONES + IMPACTS + *************************************************************/ + const [collegeProfile, setCollegeProfile] = useState(null); + const [milestones, setMilestones] = useState([]); + const [impactsByMilestone, setImpactsByMilestone] = useState({}); + + const [showEditModal, setShowEditModal] = useState(false); + const [editingScenarioData, setEditingScenarioData] = useState({ + scenario: null, + collegeProfile: null + }); + + // (A) Load the college profile for localScenario + useEffect(() => { + if (!localScenario?.id) { setCollegeProfile(null); return; } async function loadCollegeProfile() { try { - const url = `/api/premium/college-profile?careerPathId=${scenario.id}`; + const url = `/api/premium/college-profile?careerPathId=${localScenario.id}`; const res = await authFetch(url); if (res.ok) { const data = await res.json(); - setCollegeProfile(data); + // Might be an object or array + setCollegeProfile(Array.isArray(data) ? data[0] || {} : data); } else { setCollegeProfile({}); } } catch (err) { console.error('Error loading collegeProfile:', err); + setCollegeProfile({}); } } loadCollegeProfile(); - }, [scenario]); + }, [localScenario]); + + // (B) Load milestones + const fetchMilestones = useCallback(async () => { + if (!localScenario?.id) { + setMilestones([]); + setImpactsByMilestone({}); + return; + } + try { + const res = await authFetch(`/api/premium/milestones?careerPathId=${localScenario.id}`); + if (!res.ok) { + console.error('Failed fetching milestones. Status:', res.status); + return; + } + const data = await res.json(); + const mils = data.milestones || []; + setMilestones(mils); + + // For each milestone => fetch impacts + const impactsData = {}; + for (const m of mils) { + const iRes = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`); + if (iRes.ok) { + const iData = await iRes.json(); + impactsData[m.id] = iData.impacts || []; + } else { + impactsData[m.id] = []; + } + } + setImpactsByMilestone(impactsData); + + } catch (err) { + console.error('Error fetching milestones:', err); + } + }, [localScenario?.id]); useEffect(() => { - if (!financialProfile || !collegeProfile || !scenario?.id) return; + fetchMilestones(); + }, [fetchMilestones]); - // Merge the user’s base financial profile + scenario overwrites + college profile + /************************************************************* + * 3) MERGE & RUN SIMULATION => projectionData + *************************************************************/ + const [projectionData, setProjectionData] = useState([]); + const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null); + const [simulationYearsInput, setSimulationYearsInput] = useState('20'); + + useEffect(() => { + // Wait until we have localScenario + collegeProfile + financialProfile + if (!financialProfile || !localScenario?.id || !collegeProfile) return; + + // Gather all milestoneImpacts into one array for the aggregator + let allImpacts = []; + Object.keys(impactsByMilestone).forEach((mId) => { + allImpacts = allImpacts.concat(impactsByMilestone[mId]); + }); + + const simYears = parseInt(simulationYearsInput, 10) || 20; + + // Build the mergedProfile exactly like single-scenario aggregator const mergedProfile = { + // Base user financial currentSalary: financialProfile.current_salary || 0, + monthlyExpenses: - scenario.planned_monthly_expenses ?? financialProfile.monthly_expenses ?? 0, + localScenario.planned_monthly_expenses ?? financialProfile.monthly_expenses ?? 0, monthlyDebtPayments: - scenario.planned_monthly_debt_payments ?? financialProfile.monthly_debt_payments ?? 0, - // ... + localScenario.planned_monthly_debt_payments ?? financialProfile.monthly_debt_payments ?? 0, + + // Overridden savings from scenario or fallback to financialProfile + retirementSavings: financialProfile.retirement_savings ?? 0, + emergencySavings: financialProfile.emergency_fund ?? 0, + + monthlyRetirementContribution: + localScenario.planned_monthly_retirement_contribution ?? + financialProfile.retirement_contribution ?? + 0, + monthlyEmergencyContribution: + localScenario.planned_monthly_emergency_contribution ?? + financialProfile.emergency_contribution ?? + 0, + + surplusEmergencyAllocation: + localScenario.planned_surplus_emergency_pct ?? + financialProfile.extra_cash_emergency_pct ?? + 50, + surplusRetirementAllocation: + localScenario.planned_surplus_retirement_pct ?? + financialProfile.extra_cash_retirement_pct ?? + 50, + + additionalIncome: + localScenario.planned_additional_income ?? financialProfile.additional_income ?? 0, + + // College data studentLoanAmount: collegeProfile.existing_college_debt || 0, interestRate: collegeProfile.interest_rate || 5, loanTerm: collegeProfile.loan_term || 10, - // ... - simulationYears: parseInt(simulationYearsInput, 10) || 20, - milestoneImpacts: [] + 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, + + // Are we in college? + 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 start date + startDate: localScenario.start_date || new Date().toISOString(), + simulationYears: simYears, + + // MILESTONE IMPACTS + milestoneImpacts: allImpacts }; + // Run the simulation const { projectionData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile); - setProjectionData(projectionData); + + // Optionally compute a "cumulativeNetSavings" to display + let cumulative = mergedProfile.emergencySavings || 0; + const finalData = projectionData.map((monthRow) => { + cumulative += (monthRow.netSavings || 0); + return { ...monthRow, cumulativeNetSavings: cumulative }; + }); + + setProjectionData(finalData); setLoanPaidOffMonth(loanPaidOffMonth); - }, [financialProfile, collegeProfile, scenario, simulationYearsInput]); - function handleDeleteScenario() { - // let the parent actually do the DB deletion - onRemove(scenario.id); + }, [ + financialProfile, + localScenario, + collegeProfile, + impactsByMilestone, + simulationYearsInput + ]); + + function handleSimulationYearsChange(e) { + setSimulationYearsInput(e.target.value); } - function handleSimulationYearsBlur() { - if (simulationYearsInput.trim() === '') { + if (!simulationYearsInput.trim()) { setSimulationYearsInput('20'); } } - // chart data - const labels = projectionData.map(p => p.month); - const netSavData = projectionData.map(p => p.cumulativeNetSavings || 0); - const retData = projectionData.map(p => p.retirementSavings || 0); - const loanData = projectionData.map(p => p.loanBalance || 0); + /************************************************************* + * 4) CHART: lines + milestone markers + *************************************************************/ + // Create arrays from projectionData + const chartLabels = projectionData.map((p) => p.month); + // We'll show netSavings, retirementSavings, and loanBalance + const netSavingsData = projectionData.map((p) => p.cumulativeNetSavings || 0); + const retData = projectionData.map((p) => p.retirementSavings || 0); + const loanData = projectionData.map((p) => p.loanBalance || 0); + + // Build a "milestone points" array to place markers on the chart + function getLabelIndexForMilestone(m) { + if (!m.date) return -1; + const short = m.date.slice(0, 7); // "YYYY-MM" + return chartLabels.indexOf(short); + } + + const milestonePoints = milestones + .map((m) => { + const xIndex = getLabelIndexForMilestone(m); + if (xIndex < 0) return null; + return { + x: xIndex, + y: 0, // place them at 0 or some other reference + milestoneObj: m + }; + }) + .filter(Boolean); const chartData = { - labels, + labels: chartLabels, datasets: [ - { label: 'Net Savings', data: netSavData, borderColor: 'blue', fill: false }, - { label: 'Retirement', data: retData, borderColor: 'green', fill: false }, - { label: 'Loan', data: loanData, borderColor: 'red', fill: false } + { + label: 'Net Savings', + data: netSavingsData, + borderColor: 'blue', + fill: false + }, + { + label: 'Retirement', + data: retData, + borderColor: 'green', + fill: false + }, + { + label: 'Loan', + data: loanData, + borderColor: 'red', + fill: false + }, + { + // The milestone dataset for clickable markers + label: 'Milestones', + data: milestonePoints, + showLine: false, + pointStyle: 'triangle', + pointRadius: 8, + borderColor: 'orange', + backgroundColor: 'orange' + } ] }; + function handleChartClick(evt, elements, chart) { + if (!elements || elements.length === 0) return; + const { datasetIndex, index } = elements[0]; + const ds = chartData.datasets[datasetIndex]; + if (ds.label === 'Milestones') { + const clickedPoint = ds.data[index]; // e.g. { x, y, milestoneObj } + const milestone = clickedPoint.milestoneObj; + handleEditMilestone(milestone); + } + } + + const chartOptions = { + responsive: true, + scales: { + x: { type: 'category' }, + y: { title: { display: true, text: 'Amount ($)' } } + }, + onClick: handleChartClick, + plugins: { + tooltip: { + callbacks: { + label: function (context) { + if (context.dataset.label === 'Milestones') { + const { milestoneObj } = context.raw; + return milestoneObj.title || '(Untitled milestone)'; + } + return `${context.dataset.label}: ${context.formattedValue}`; + } + } + } + } + }; + + /************************************************************* + * 5) MILESTONE CRUD: same as your code (Add, Copy, etc.) + *************************************************************/ + const [showForm, setShowForm] = useState(false); + const [editingMilestone, setEditingMilestone] = useState(null); + const [newMilestone, setNewMilestone] = useState({ + title: '', + description: '', + date: '', + progress: 0, + newSalary: '', + impacts: [], + isUniversal: 0 + }); + const [impactsToDelete, setImpactsToDelete] = useState([]); + + // tasks + const [showTaskForm, setShowTaskForm] = useState(null); + const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' }); + + // copy wizard + const [copyWizardMilestone, setCopyWizardMilestone] = useState(null); + + function handleNewMilestone() { + setEditingMilestone(null); + setNewMilestone({ + title: '', + description: '', + date: '', + progress: 0, + newSalary: '', + impacts: [], + isUniversal: 0 + }); + setImpactsToDelete([]); + setShowForm(true); + } + + async function handleEditMilestone(m) { + if (!localScenario?.id) return; + setEditingMilestone(m); + setImpactsToDelete([]); + + // fetch impacts for this milestone + try { + const impRes = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`); + if (impRes.ok) { + const data = await impRes.json(); + const fetchedImpacts = data.impacts || []; + setNewMilestone({ + title: m.title || '', + description: m.description || '', + date: m.date || '', + progress: m.progress || 0, + newSalary: m.new_salary || '', + impacts: fetchedImpacts.map((imp) => ({ + id: imp.id, + impact_type: imp.impact_type || 'ONE_TIME', + direction: imp.direction || 'subtract', + amount: imp.amount || 0, + start_date: imp.start_date || '', + end_date: imp.end_date || '' + })), + isUniversal: m.is_universal ? 1 : 0 + }); + setShowForm(true); + } + } catch (err) { + console.error('Error loading milestone impacts:', err); + } + } + + function addNewImpact() { + setNewMilestone((prev) => ({ + ...prev, + impacts: [ + ...prev.impacts, + { + impact_type: 'ONE_TIME', + direction: 'subtract', + amount: 0, + start_date: '', + end_date: '' + } + ] + })); + } + function removeImpact(idx) { + setNewMilestone((prev) => { + const copy = [...prev.impacts]; + const removed = copy[idx]; + if (removed && removed.id) { + setImpactsToDelete((old) => [...old, removed.id]); + } + copy.splice(idx, 1); + return { ...prev, impacts: copy }; + }); + } + function updateImpact(idx, field, value) { + setNewMilestone((prev) => { + const copy = [...prev.impacts]; + copy[idx] = { ...copy[idx], [field]: value }; + return { ...prev, impacts: copy }; + }); + } + + async function saveMilestone() { + if (!localScenario?.id) return; + + const url = editingMilestone + ? `/api/premium/milestones/${editingMilestone.id}` + : `/api/premium/milestone`; + const method = editingMilestone ? 'PUT' : 'POST'; + + const payload = { + milestone_type: 'Financial', // or "Career" if needed + title: newMilestone.title, + description: newMilestone.description, + date: newMilestone.date, + career_path_id: localScenario.id, + progress: newMilestone.progress, + status: newMilestone.progress >= 100 ? 'completed' : 'planned', + new_salary: newMilestone.newSalary ? parseFloat(newMilestone.newSalary) : null, + is_universal: newMilestone.isUniversal || 0 + }; + + try { + const res = await authFetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!res.ok) { + const errData = await res.json(); + alert(errData.error || 'Error saving milestone'); + return; + } + const savedMilestone = await res.json(); + + // handle impacts (delete old, upsert new) + for (const id of impactsToDelete) { + await authFetch(`/api/premium/milestone-impacts/${id}`, { method: 'DELETE' }); + } + for (let i = 0; i < newMilestone.impacts.length; i++) { + const imp = newMilestone.impacts[i]; + const impPayload = { + milestone_id: savedMilestone.id, + impact_type: imp.impact_type, + direction: imp.direction, + amount: parseFloat(imp.amount) || 0, + start_date: imp.start_date || null, + end_date: imp.end_date || null + }; + if (imp.id) { + // PUT + await authFetch(`/api/premium/milestone-impacts/${imp.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(impPayload) + }); + } else { + // POST + await authFetch('/api/premium/milestone-impacts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(impPayload) + }); + } + } + + await fetchMilestones(); + + // reset form + setShowForm(false); + setEditingMilestone(null); + setNewMilestone({ + title: '', + description: '', + date: '', + progress: 0, + newSalary: '', + impacts: [], + isUniversal: 0 + }); + setImpactsToDelete([]); + } catch (err) { + console.error('Error saving milestone:', err); + alert('Failed to save milestone'); + } + } + + async function handleDeleteMilestone(m) { + if (m.is_universal === 1) { + const userChoice = window.confirm( + 'Universal milestone. OK => remove from ALL scenarios, or Cancel => just remove from this scenario.' + ); + if (userChoice) { + try { + await authFetch(`/api/premium/milestones/${m.id}/all`, { method: 'DELETE' }); + } catch (err) { + console.error('Error removing universal milestone from all:', err); + } + } else { + await deleteSingleMilestone(m); + } + } else { + await deleteSingleMilestone(m); + } + await fetchMilestones(); + } + async function deleteSingleMilestone(m) { + try { + await authFetch(`/api/premium/milestones/${m.id}`, { method: 'DELETE' }); + } catch (err) { + console.error('Error removing milestone:', err); + } + } + + async function addTask(milestoneId) { + try { + const payload = { + milestone_id: milestoneId, + title: newTask.title, + description: newTask.description, + due_date: newTask.due_date + }; + const res = await authFetch('/api/premium/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!res.ok) { + alert('Failed to add task'); + return; + } + await fetchMilestones(); + setNewTask({ title: '', description: '', due_date: '' }); + setShowTaskForm(null); + } catch (err) { + console.error('Error adding task:', err); + } + } + + // Instead of immediately calling `onEdit(scenario)`, we handle locally: + function handleEditScenario() { + if (!localScenario) return; + // We'll store the scenario and also fetch the collegeProfile if needed + // or we can just reuse the existing `collegeProfile` state. + setEditingScenarioData({ + scenario: localScenario, + collegeProfile + }); + setShowEditModal(true); + } + + // Scenario-level + function handleDeleteScenario() { + if (localScenario) onRemove(localScenario.id); + } + function handleCloneScenario() { + if (localScenario) onClone(localScenario); + } + + + /************************************************************* + * 6) COPY WIZARD + *************************************************************/ + function CopyMilestoneWizard({ milestone, scenarios, onClose }) { + const [selectedScenarios, setSelectedScenarios] = useState([]); + + if (!milestone) return null; + + function toggleScenario(id) { + setSelectedScenarios((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ); + } + + async function handleCopy() { + try { + const res = await authFetch('/api/premium/milestone/copy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + milestoneId: milestone.id, + scenarioIds: selectedScenarios + }) + }); + if (!res.ok) throw new Error('Failed to copy milestone'); + onClose(); + // Optionally reload the page or call fetchMilestones again + window.location.reload(); + } catch (err) { + console.error('Error copying milestone:', err); + } + } + + return ( +
+
+

Copy Milestone to Other Scenarios

+

+ Milestone: {milestone.title} +

+ + {scenarios.map((s) => ( +
+ +
+ ))} + +
+ + +
+
+
+ ); + } + + /************************************************************* + * 7) RENDER + *************************************************************/ return (
-

{scenario.scenario_title || scenario.career_name || 'Untitled Scenario'}

+ {/* (A) scenario dropdown */} + -
- - setSimulationYearsInput(e.target.value)} - onBlur={handleSimulationYearsBlur} - /> -
+ {/* If localScenario is selected => show UI */} + {localScenario && ( + <> +

{localScenario.scenario_title || localScenario.career_name}

+

+ Status: {localScenario.status}
+ Start: {localScenario.start_date}
+ End: {localScenario.projected_end_date} +

- + {/* Simulation length */} +
+ + +
-
- Loan Paid Off: {loanPaidOffMonth || 'N/A'} - {projectionData.length > 0 && ( - <> -
- Final Retirement: {projectionData[projectionData.length - 1].retirementSavings.toFixed(0)} - - )} -
+ {/* The line chart */} + - {scenario?.id && ( - {}} - onMilestoneUpdated={() => {}} - /> + {projectionData.length > 0 && ( +
+ Loan Paid Off: {loanPaidOffMonth || 'N/A'} +
+ Final Retirement:{' '} + {Math.round( + projectionData[projectionData.length - 1].retirementSavings + )} +
+ )} + + + + {/* AI-Suggested Milestones (unchanged) */} + + +
+ {/* Instead of calling onEdit(localScenario), show local modal */} + + + +
+ + {/* The inline form for milestone creation/edit */} + {showForm && ( +
+

{editingMilestone ? 'Edit Milestone' : 'New Milestone'}

+ + setNewMilestone({ ...newMilestone, title: e.target.value })} + /> + setNewMilestone({ ...newMilestone, description: e.target.value })} + /> + setNewMilestone({ ...newMilestone, date: e.target.value })} + /> + + setNewMilestone((prev) => ({ + ...prev, + progress: parseInt(e.target.value || '0', 10) + })) + } + /> + +
+ +
+ + setNewMilestone({ ...newMilestone, newSalary: e.target.value })} + /> + + {/* Impacts sub-form */} +
+
Financial Impacts
+ {newMilestone.impacts.map((imp, idx) => ( +
+ {imp.id &&

ID: {imp.id}

} +
+ + +
+
+ + +
+
+ + updateImpact(idx, 'amount', e.target.value)} + /> +
+
+ + updateImpact(idx, 'start_date', e.target.value)} + /> +
+ {imp.impact_type === 'MONTHLY' && ( +
+ + updateImpact(idx, 'end_date', e.target.value)} + /> +
+ )} + +
+ ))} + +
+ +
+ + +
+
+ )} + + {/* Render existing milestones + tasks + copy wizard, etc. */} + {milestones.map((m) => { + const tasks = m.tasks || []; + return ( +
+
{m.title}
+ {m.description &&

{m.description}

} +

+ Date: {m.date} — Progress: {m.progress}% +

+ + {/* tasks list */} + {tasks.length > 0 && ( +
    + {tasks.map((t) => ( +
  • + {t.title} + {t.description ? ` - ${t.description}` : ''} + {t.due_date ? ` (Due: ${t.due_date})` : ''} +
  • + ))} +
+ )} + + + + + + + {/* Task form */} + {showTaskForm === m.id && ( +
+ setNewTask({ ...newTask, title: e.target.value })} + /> + setNewTask({ ...newTask, description: e.target.value })} + /> + setNewTask({ ...newTask, due_date: e.target.value })} + /> + +
+ )} +
+ ); + })} + + {/* (B) RENDER THE EDIT MODAL IF showEditModal */} + setShowEditModal(false)} + scenario={editingScenarioData.scenario} + collegeProfile={editingScenarioData.collegeProfile} + /> + + {/* The copy wizard if copying a milestone */} + {copyWizardMilestone && ( + setCopyWizardMilestone(null)} + /> + )} + )} - {scenario?.id && ( - - )} - -
- - - -
); } diff --git a/src/components/ScenarioEditModal.js b/src/components/ScenarioEditModal.js index f6317ac..074d87a 100644 --- a/src/components/ScenarioEditModal.js +++ b/src/components/ScenarioEditModal.js @@ -1,45 +1,60 @@ // src/components/ScenarioEditModal.js - import React, { useState, useEffect, useRef } from 'react'; import authFetch from '../utils/authFetch.js'; +import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; // Or wherever your simulator is -// Paths to your JSON/CSV data. Adjust if needed: +// JSON/CSV data paths const CIP_URL = '/cip_institution_mapping_new.json'; const IPEDS_URL = '/ic2023_ay.csv'; const CAREER_CLUSTERS_URL = '/career_clusters.json'; export default function ScenarioEditModal({ show, - onClose, // onClose(updatedScenario, updatedCollege) + onClose, scenario, collegeProfile }) { - const [formData, setFormData] = useState({}); - - // CIP & IPEDS data + /********************************************************* + * 1) CIP / IPEDS data states + *********************************************************/ const [schoolData, setSchoolData] = useState([]); const [icTuitionData, setIcTuitionData] = useState([]); - // suggestions + /********************************************************* + * 2) Suggestions & program types + *********************************************************/ const [schoolSuggestions, setSchoolSuggestions] = useState([]); const [programSuggestions, setProgramSuggestions] = useState([]); const [availableProgramTypes, setAvailableProgramTypes] = useState([]); - // manual vs auto for tuition & program length + /********************************************************* + * 3) Manual vs auto for tuition & program length + *********************************************************/ const [manualTuition, setManualTuition] = useState(''); const [autoTuition, setAutoTuition] = useState(0); const [manualProgLength, setManualProgLength] = useState(''); const [autoProgLength, setAutoProgLength] = useState('0.00'); - // career auto-suggest + /********************************************************* + * 4) Career auto-suggest + *********************************************************/ const [allCareers, setAllCareers] = useState([]); const [careerSearchInput, setCareerSearchInput] = useState(''); const [careerMatches, setCareerMatches] = useState([]); const careerDropdownRef = useRef(null); - // ---------- Load CIP/iPEDS/career data once ---------- + /********************************************************* + * 5) Combined formData => scenario + college + *********************************************************/ + const [formData, setFormData] = useState({}); + + /********************************************************* + * 6) On show => load CIP, IPEDS, CAREERS + *********************************************************/ useEffect(() => { - async function loadCIP() { + if (!show) return; + + const loadCIP = async () => { try { const res = await fetch(CIP_URL); const text = await res.text(); @@ -55,10 +70,11 @@ export default function ScenarioEditModal({ .filter(Boolean); setSchoolData(parsed); } catch (err) { - console.error('Failed to load CIP data:', err); + console.error('Failed loading CIP data:', err); } - } - async function loadIPEDS() { + }; + + const loadIPEDS = async () => { try { const res = await fetch(IPEDS_URL); const text = await res.text(); @@ -69,69 +85,50 @@ export default function ScenarioEditModal({ ); setIcTuitionData(dataRows); } catch (err) { - console.error('Failed to load iPEDS data:', err); + console.error('Failed loading IPEDS data:', err); } - } - async function loadCareers() { + }; + + const loadCareers = async () => { try { - const res = await fetch(CAREER_CLUSTERS_URL); - const data = await res.json(); - const titles = new Set(); + const resp = await fetch(CAREER_CLUSTERS_URL); + if (!resp.ok) { + throw new Error(`Failed career_clusters fetch: ${resp.status}`); + } + const data = await resp.json(); + const titlesSet = new Set(); for (const cluster of Object.keys(data)) { - for (const subdiv of Object.keys(data[cluster])) { - const arr = data[cluster][subdiv]; - arr.forEach((obj) => { - if (obj.title) titles.add(obj.title); - }); + for (const sub of Object.keys(data[cluster])) { + const arr = data[cluster][sub]; + if (Array.isArray(arr)) { + arr.forEach((cObj) => { + if (cObj?.title) titlesSet.add(cObj.title); + }); + } } } - setAllCareers([...titles]); + setAllCareers([...titlesSet]); } catch (err) { - console.error('Failed to load career_clusters:', err); + console.error('Failed loading career_clusters:', err); } - } + }; loadCIP(); loadIPEDS(); loadCareers(); - }, []); + }, [show]); - // ---------- career auto-suggest logic ---------- + /********************************************************* + * 7) If scenario + collegeProfile => fill form + *********************************************************/ useEffect(() => { - if (!careerSearchInput) { - setCareerMatches([]); - return; - } - const lower = careerSearchInput.toLowerCase(); - const partials = allCareers - .filter((title) => title.toLowerCase().includes(lower)) - .slice(0, 15); - setCareerMatches(partials); - }, [careerSearchInput, allCareers]); - - function handleCareerInputChange(e) { - const val = e.target.value; - setCareerSearchInput(val); - if (allCareers.includes(val)) { - setFormData((prev) => ({ ...prev, career_name: val })); - } - } - function handleSelectCareer(title) { - setCareerSearchInput(title); - setFormData((prev) => ({ ...prev, career_name: title })); - setCareerMatches([]); - } - - // ---------- Show => populate formData from scenario & college ---------- - useEffect(() => { - if (!show) return; - if (!scenario) return; + if (!show || !scenario) return; const s = scenario || {}; const c = collegeProfile || {}; setFormData({ - // Scenario + // scenario portion scenario_title: s.scenario_title || '', career_name: s.career_name || '', status: s.status || 'planned', @@ -150,18 +147,20 @@ export default function ScenarioEditModal({ planned_surplus_retirement_pct: s.planned_surplus_retirement_pct ?? '', planned_additional_income: s.planned_additional_income ?? '', - // College + // college portion selected_school: c.selected_school || '', selected_program: c.selected_program || '', program_type: c.program_type || '', - academic_calendar: c.academic_calendar || 'semester', + academic_calendar: c.academic_calendar || 'monthly', + is_in_state: !!c.is_in_state, is_in_district: !!c.is_in_district, is_online: !!c.is_online, + // This is the college row's enrollment status college_enrollment_status_db: c.college_enrollment_status || 'not_enrolled', + annual_financial_aid: c.annual_financial_aid ?? '', existing_college_debt: c.existing_college_debt ?? '', - tuition: c.tuition ?? 0, tuition_paid: c.tuition_paid ?? 0, loan_deferral_until_graduation: !!c.loan_deferral_until_graduation, loan_term: c.loan_term ?? 10, @@ -175,27 +174,120 @@ export default function ScenarioEditModal({ expected_salary: c.expected_salary ?? '' }); - // set up manual vs auto - setManualTuition(''); - setAutoTuition(0); - setManualProgLength(''); - setAutoProgLength('0.00'); + if (c.tuition != null && c.tuition !== 0) { + setManualTuition(String(c.tuition)); + setAutoTuition(''); // So user sees the DB value, not auto + } else { + // Else we do our auto-calc, or just set 0 if you prefer + const autoCalc = 12000; // or your IPEDS-based logic + setAutoTuition(String(autoCalc)); + setManualTuition(''); + } + + if (c.program_length != null && c.program_length !== 0) { + // DB has real program length + setManualProgLength(String(c.program_length)); + setAutoProgLength(''); // so we know user is seeing DB + } else { + // No real DB value => show auto + const autoLen = 2.0; // or your own logic + setAutoProgLength(String(autoLen)); + setManualProgLength(''); + } - // career input setCareerSearchInput(s.career_name || ''); }, [show, scenario, collegeProfile]); - // ---------- handle form changes ---------- + /********************************************************* + * 8) Auto-calc tuition + program length => placeholders + *********************************************************/ + useEffect(() => { + if (!show) return; + }, [ + show, + formData.selected_school, + formData.program_type, + formData.credit_hours_per_year, + formData.is_in_district, + formData.is_in_state, + schoolData, + icTuitionData + ]); + + useEffect(() => { + if (!show) return; + }, [ + show, + formData.program_type, + formData.hours_completed, + formData.credit_hours_per_year, + formData.credit_hours_required + ]); + + /********************************************************* + * 9) Career auto-suggest + *********************************************************/ + useEffect(() => { + if (!show) return; + if (!careerSearchInput.trim()) { + setCareerMatches([]); + return; + } + const lower = careerSearchInput.toLowerCase(); + const partials = allCareers + .filter((title) => title.toLowerCase().includes(lower)) + .slice(0, 15); + setCareerMatches(partials); + }, [show, careerSearchInput, allCareers]); + + /********************************************************* + * 9.5) Program Type from CIP + * => Populate availableProgramTypes by matching CIP rows for + * (selected_school, selected_program) => (CREDDESC). + *********************************************************/ + useEffect(() => { + if (!show) return; + if (!formData.selected_school || !formData.selected_program) { + setAvailableProgramTypes([]); + return; + } + const filtered = schoolData.filter( + (row) => + row.INSTNM.toLowerCase() === formData.selected_school.toLowerCase() && + row.CIPDESC === formData.selected_program + ); + const possibleTypes = [...new Set(filtered.map((r) => r.CREDDESC))]; + setAvailableProgramTypes(possibleTypes); + }, [ + show, + formData.selected_school, + formData.selected_program, + schoolData + ]); + + /********************************************************* + * 10) Handlers + *********************************************************/ function handleFormChange(e) { const { name, type, checked, value } = e.target; let val = value; - if (type === 'checkbox') { - val = checked; - } + if (type === 'checkbox') val = checked; setFormData((prev) => ({ ...prev, [name]: val })); } - // ---------- school / program changes ---------- + function handleCareerInputChange(e) { + const val = e.target.value; + setCareerSearchInput(val); + if (allCareers.includes(val)) { + setFormData((prev) => ({ ...prev, career_name: val })); + } + } + function handleSelectCareer(title) { + setCareerSearchInput(title); + setFormData((prev) => ({ ...prev, career_name: title })); + setCareerMatches([]); + } + function handleSchoolChange(e) { const val = e.target.value; setFormData((prev) => ({ @@ -207,27 +299,26 @@ export default function ScenarioEditModal({ })); if (!val) { setSchoolSuggestions([]); + setProgramSuggestions([]); + setAvailableProgramTypes([]); return; } const filtered = schoolData.filter((s) => s.INSTNM.toLowerCase().includes(val.toLowerCase()) ); - const uniqueSchools = [...new Set(filtered.map((s) => s.INSTNM))]; - setSchoolSuggestions(uniqueSchools.slice(0, 10)); - setProgramSuggestions([]); - setAvailableProgramTypes([]); + const unique = [...new Set(filtered.map((s) => s.INSTNM))]; + setSchoolSuggestions(unique.slice(0, 10)); } - function handleSchoolSelect(schoolName) { + + function handleSchoolSelect(sch) { setFormData((prev) => ({ ...prev, - selected_school: schoolName, + selected_school: sch, selected_program: '', program_type: '', credit_hours_required: '' })); setSchoolSuggestions([]); - setProgramSuggestions([]); - setAvailableProgramTypes([]); } function handleProgramChange(e) { @@ -239,13 +330,13 @@ export default function ScenarioEditModal({ } const filtered = schoolData.filter( (row) => - row.INSTNM.toLowerCase() === - formData.selected_school.toLowerCase() && + row.INSTNM.toLowerCase() === formData.selected_school.toLowerCase() && row.CIPDESC.toLowerCase().includes(val.toLowerCase()) ); - const uniquePrograms = [...new Set(filtered.map((r) => r.CIPDESC))]; - setProgramSuggestions(uniquePrograms.slice(0, 10)); + const unique = [...new Set(filtered.map((r) => r.CIPDESC))]; + setProgramSuggestions(unique.slice(0, 10)); } + function handleProgramSelect(prog) { setFormData((prev) => ({ ...prev, selected_program: prog })); setProgramSuggestions([]); @@ -261,7 +352,6 @@ export default function ScenarioEditModal({ setAutoProgLength('0.00'); } - // ---------- manual tuition & program length ---------- function handleManualTuitionChange(e) { setManualTuition(e.target.value); } @@ -269,232 +359,320 @@ export default function ScenarioEditModal({ setManualProgLength(e.target.value); } - // ---------- auto-calc tuition ---------- - useEffect(() => { - const { - selected_school, - program_type, - credit_hours_per_year, - is_in_state, - is_in_district - } = formData; - if (!icTuitionData.length) return; - if (!selected_school || !program_type || !credit_hours_per_year) return; + /********************************************************* + * 11) After saving, we want to re-fetch scenario & college + * and then pass inCollege to the simulator + *********************************************************/ + const [projectionData, setProjectionData] = useState([]); + const [loanPayoffMonth, setLoanPayoffMonth] = useState(null); - // find - const found = schoolData.find( - (s) => s.INSTNM.toLowerCase() === selected_school.toLowerCase() - ); - if (!found?.UNITID) return; - const match = icTuitionData.find((row) => row.UNITID === found.UNITID); - if (!match) return; + // aggregator + function buildMergedUserProfile(scenarioRow, collegeRow, financialData) { + // Make sure we read the final updated scenario row's enrollment + const enrollment = scenarioRow.college_enrollment_status; + const inCollege = (enrollment === 'currently_enrolled' || enrollment === 'prospective_student'); - const isGradOrProf = [ - "Master's Degree", - "Doctoral Degree", - "Graduate/Professional Certificate", - "First Professional Degree" - ].includes(program_type); + return { + currentSalary: financialData.current_salary || 0, + monthlyExpenses: scenarioRow.planned_monthly_expenses ?? financialData.monthly_expenses ?? 0, + monthlyDebtPayments: scenarioRow.planned_monthly_debt_payments ?? financialData.monthly_debt_payments ?? 0, + partTimeIncome: scenarioRow.planned_additional_income ?? financialData.additional_income ?? 0, - let partTimeRate = 0; - let fullTimeTuition = 0; - if (isGradOrProf) { - if (is_in_district) { - partTimeRate = parseFloat(match.HRCHG5 || 0); - fullTimeTuition = parseFloat(match.TUITION5 || 0); - } else if (is_in_state) { - partTimeRate = parseFloat(match.HRCHG6 || 0); - fullTimeTuition = parseFloat(match.TUITION6 || 0); - } else { - partTimeRate = parseFloat(match.HRCHG7 || 0); - fullTimeTuition = parseFloat(match.TUITION7 || 0); - } - } else { - // undergrad - if (is_in_district) { - partTimeRate = parseFloat(match.HRCHG1 || 0); - fullTimeTuition = parseFloat(match.TUITION1 || 0); - } else if (is_in_state) { - partTimeRate = parseFloat(match.HRCHG2 || 0); - fullTimeTuition = parseFloat(match.TUITION2 || 0); - } else { - partTimeRate = parseFloat(match.HRCHG3 || 0); - fullTimeTuition = parseFloat(match.TUITION3 || 0); - } - } - const chpy = parseFloat(credit_hours_per_year) || 0; - let estimate = 0; - if (chpy < 24 && partTimeRate) { - estimate = partTimeRate * chpy; - } else { - estimate = fullTimeTuition; - } - setAutoTuition(Math.round(estimate)); - }, [ - icTuitionData, - formData.selected_school, - formData.program_type, - formData.credit_hours_per_year, - formData.is_in_district, - formData.is_in_state, - schoolData - ]); + emergencySavings: financialData.emergency_fund ?? 0, + retirementSavings: financialData.retirement_savings ?? 0, + monthlyRetirementContribution: + scenarioRow.planned_monthly_retirement_contribution ?? + financialData.retirement_contribution ?? + 0, + monthlyEmergencyContribution: + scenarioRow.planned_monthly_emergency_contribution ?? + financialData.emergency_contribution ?? + 0, + surplusEmergencyAllocation: + scenarioRow.planned_surplus_emergency_pct ?? + financialData.extra_cash_emergency_pct ?? + 50, + surplusRetirementAllocation: + scenarioRow.planned_surplus_retirement_pct ?? + financialData.extra_cash_retirement_pct ?? + 50, - // ---------- auto-calc program length ---------- - useEffect(() => { - const { program_type, hours_completed, credit_hours_per_year, credit_hours_required } = - formData; - if (!program_type) return; - if (!hours_completed || !credit_hours_per_year) return; + // college + inCollege, + studentLoanAmount: collegeRow.existing_college_debt || 0, + interestRate: collegeRow.interest_rate || 5, + loanTerm: collegeRow.loan_term || 10, + loanDeferralUntilGraduation: !!collegeRow.loan_deferral_until_graduation, + academicCalendar: collegeRow.academic_calendar || 'monthly', + annualFinancialAid: collegeRow.annual_financial_aid || 0, + calculatedTuition: collegeRow.tuition || 0, + extraPayment: collegeRow.extra_payment || 0, - let required = 0; - switch (program_type) { - case "Associate's Degree": - required = 60; - break; - case "Bachelor's Degree": - required = 120; - break; - case "Master's Degree": - required = 60; - break; - case "Doctoral Degree": - required = 120; - break; - case "First Professional Degree": - required = 180; - break; - case "Graduate/Professional Certificate": - required = parseInt(credit_hours_required, 10) || 0; - break; - default: - required = parseInt(credit_hours_required, 10) || 0; - break; - } - const remain = required - (parseInt(hours_completed, 10) || 0); - const yrs = remain / (parseFloat(credit_hours_per_year) || 1); - setAutoProgLength(yrs.toFixed(2)); - }, [ - formData.program_type, - formData.hours_completed, - formData.credit_hours_per_year, - formData.credit_hours_required - ]); + gradDate: collegeRow.expected_graduation || null, + programType: collegeRow.program_type || null, + hoursCompleted: collegeRow.hours_completed || 0, + creditHoursPerYear: collegeRow.credit_hours_per_year || 0, + programLength: collegeRow.program_length || 0, + expectedSalary: collegeRow.expected_salary || financialData.current_salary || 0, - // ---------- handleSave => PUT scenario & college => onClose(...) + startDate: scenarioRow.start_date || new Date().toISOString(), + simulationYears: 20, + milestoneImpacts: [] + }; + } + + + /********************************************************* + * 12) handleSave => upsert scenario & college + * => Then re-fetch scenario, college, financial => aggregator => simulate + *********************************************************/ async function handleSave() { try { - // chosen tuition - const chosenTuition = - manualTuition.trim() === '' ? autoTuition : parseFloat(manualTuition); - const chosenProgLen = - manualProgLength.trim() === '' ? autoProgLength : manualProgLength; - - // scenario payload - const scenarioPayload = { - scenario_title: formData.scenario_title || '', - career_name: formData.career_name || '', - status: formData.status || 'planned', - start_date: formData.start_date || null, - projected_end_date: formData.projected_end_date || null, - college_enrollment_status: formData.college_enrollment_status || 'not_enrolled', - currently_working: formData.currently_working || 'no', - - planned_monthly_expenses: - formData.planned_monthly_expenses === '' ? null : Number(formData.planned_monthly_expenses), - planned_monthly_debt_payments: - formData.planned_monthly_debt_payments === '' ? null : Number(formData.planned_monthly_debt_payments), - planned_monthly_retirement_contribution: - formData.planned_monthly_retirement_contribution === '' ? null : Number(formData.planned_monthly_retirement_contribution), - planned_monthly_emergency_contribution: - formData.planned_monthly_emergency_contribution === '' ? null : Number(formData.planned_monthly_emergency_contribution), - planned_surplus_emergency_pct: - formData.planned_surplus_emergency_pct === '' ? null : Number(formData.planned_surplus_emergency_pct), - planned_surplus_retirement_pct: - formData.planned_surplus_retirement_pct === '' ? null : Number(formData.planned_surplus_retirement_pct), - planned_additional_income: - formData.planned_additional_income === '' ? null : Number(formData.planned_additional_income) - }; - - // 1) Put scenario - const scenRes = await authFetch(`/api/premium/career-profile/${scenario.id}`, { - method: 'PUT', + // --- Helper functions for partial update: --- + function parseNumberIfGiven(val) { + if (val == null) return undefined; // skip if null/undefined + + // Convert to string before trimming + const valStr = String(val).trim(); + if (valStr === '') { + return undefined; // skip if empty + } + + const num = Number(valStr); + return isNaN(num) ? undefined : num; + } + + function parseStringIfGiven(val) { + if (val == null) return undefined; + const trimmed = String(val).trim(); + return trimmed === '' ? undefined : trimmed; + } + + // Let’s handle your manualTuition / manualProgLength logic too + const chosenTuitionVal = + manualTuition.trim() !== '' ? Number(manualTuition) : undefined; + const chosenProgLengthVal = + manualProgLength.trim() !== '' ? Number(manualProgLength) : undefined; + + // The user sets scenario.college_enrollment_status => "currently_enrolled" + // We'll explicitly set the college row's status to match + let finalCollegeStatus = formData.college_enrollment_status_db; + if ( + formData.college_enrollment_status === 'currently_enrolled' || + formData.college_enrollment_status === 'prospective_student' + ) { + finalCollegeStatus = formData.college_enrollment_status; + } else { + finalCollegeStatus = 'not_enrolled'; + } + + // --- Build scenarioPayload with partial updates --- + const scenarioPayload = {}; + + // (A) Some fields you always want to set: + scenarioPayload.college_enrollment_status = finalCollegeStatus; + scenarioPayload.currently_working = formData.currently_working || 'no'; + + // (B) scenario_title, career_name, status => only if typed + const scenarioTitle = parseStringIfGiven(formData.scenario_title); + if (scenarioTitle !== undefined) { + scenarioPayload.scenario_title = scenarioTitle; + } + const careerName = parseStringIfGiven(formData.career_name); + if (careerName !== undefined) { + scenarioPayload.career_name = careerName; + } + const scenarioStatus = parseStringIfGiven(formData.status); + if (scenarioStatus !== undefined) { + scenarioPayload.status = scenarioStatus; + } + + // (C) Dates + if (formData.start_date && formData.start_date.trim() !== '') { + scenarioPayload.start_date = formData.start_date.trim(); + } + if (formData.projected_end_date && formData.projected_end_date.trim() !== '') { + scenarioPayload.projected_end_date = formData.projected_end_date.trim(); + } + + // (D) Numeric overrides + const pme = parseNumberIfGiven(formData.planned_monthly_expenses); + if (pme !== undefined) scenarioPayload.planned_monthly_expenses = pme; + + const pmdp = parseNumberIfGiven(formData.planned_monthly_debt_payments); + if (pmdp !== undefined) scenarioPayload.planned_monthly_debt_payments = pmdp; + + const pmrc = parseNumberIfGiven(formData.planned_monthly_retirement_contribution); + if (pmrc !== undefined) scenarioPayload.planned_monthly_retirement_contribution = pmrc; + + const pmec = parseNumberIfGiven(formData.planned_monthly_emergency_contribution); + if (pmec !== undefined) scenarioPayload.planned_monthly_emergency_contribution = pmec; + + const psep = parseNumberIfGiven(formData.planned_surplus_emergency_pct); + if (psep !== undefined) scenarioPayload.planned_surplus_emergency_pct = psep; + + const psrp = parseNumberIfGiven(formData.planned_surplus_retirement_pct); + if (psrp !== undefined) scenarioPayload.planned_surplus_retirement_pct = psrp; + + const pai = parseNumberIfGiven(formData.planned_additional_income); + if (pai !== undefined) scenarioPayload.planned_additional_income = pai; + + // 1) Upsert scenario row + const scenRes = await authFetch('/api/premium/career-profile', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scenarioPayload) }); if (!scenRes.ok) { - const eText = await scenRes.text(); - throw new Error('Scenario update failed: ' + eText); + const msg = await scenRes.text(); + throw new Error(`Scenario upsert failed: ${msg}`); } - const updatedScenario = await scenRes.json(); // updated scenario row - - // 2) Put college - const colId = collegeProfile?.id; // or handle no ID + const scenData = await scenRes.json(); + const updatedScenarioId = scenData.career_path_id; + + // --- Build collegePayload with partial updates --- const collegePayload = { - selected_school: formData.selected_school || null, - selected_program: formData.selected_program || null, - program_type: formData.program_type || null, - academic_calendar: formData.academic_calendar || 'semester', + career_path_id: updatedScenarioId, + // We always sync these booleans or statuses + college_enrollment_status: finalCollegeStatus, is_in_state: formData.is_in_state ? 1 : 0, is_in_district: formData.is_in_district ? 1 : 0, - is_online: formData.is_online ? 1 : 0, - college_enrollment_status: formData.college_enrollment_status_db || 'not_enrolled', - annual_financial_aid: - formData.annual_financial_aid === '' ? 0 : Number(formData.annual_financial_aid), - existing_college_debt: - formData.existing_college_debt === '' ? 0 : Number(formData.existing_college_debt), - tuition: chosenTuition, - tuition_paid: - formData.tuition_paid === '' ? 0 : Number(formData.tuition_paid), - loan_deferral_until_graduation: - formData.loan_deferral_until_graduation ? 1 : 0, - loan_term: - formData.loan_term === '' ? 10 : Number(formData.loan_term), - interest_rate: - formData.interest_rate === '' ? 5 : Number(formData.interest_rate), - extra_payment: - formData.extra_payment === '' ? 0 : Number(formData.extra_payment), - credit_hours_per_year: - formData.credit_hours_per_year === '' ? 0 : Number(formData.credit_hours_per_year), - hours_completed: - formData.hours_completed === '' ? 0 : Number(formData.hours_completed), - program_length: chosenProgLen, - credit_hours_required: - formData.credit_hours_required === '' ? 0 : Number(formData.credit_hours_required), - expected_graduation: formData.expected_graduation || null, - expected_salary: - formData.expected_salary === '' ? 0 : Number(formData.expected_salary) + is_in_online: formData.is_in_online ? 1 : 0 }; - - const colRes = await authFetch(`/api/premium/college-profile/${colId}`, { - method: 'PUT', + + // Strings + const selSchool = parseStringIfGiven(formData.selected_school); + if (selSchool !== undefined) collegePayload.selected_school = selSchool; + + const selProg = parseStringIfGiven(formData.selected_program); + if (selProg !== undefined) collegePayload.selected_program = selProg; + + const progType = parseStringIfGiven(formData.program_type); + if (progType !== undefined) collegePayload.program_type = progType; + + const acCal = parseStringIfGiven(formData.academic_calendar); + if (acCal !== undefined) collegePayload.academic_calendar = acCal; + + // If user typed a date for expected_graduation + if (formData.expected_graduation && formData.expected_graduation.trim() !== '') { + collegePayload.expected_graduation = formData.expected_graduation.trim(); + } + + // Numeric fields + const afa = parseNumberIfGiven(formData.annual_financial_aid); + if (afa !== undefined) collegePayload.annual_financial_aid = afa; + + const ecd = parseNumberIfGiven(formData.existing_college_debt); + if (ecd !== undefined) collegePayload.existing_college_debt = ecd; + + const tp = parseNumberIfGiven(formData.tuition_paid); + if (tp !== undefined) collegePayload.tuition_paid = tp; + + // Chosen tuition if user typed manualTuition + if (chosenTuitionVal !== undefined && !isNaN(chosenTuitionVal)) { + collegePayload.tuition = chosenTuitionVal; + } + // chosenProgLength if user typed manualProgLength + if (chosenProgLengthVal !== undefined && !isNaN(chosenProgLengthVal)) { + collegePayload.program_length = chosenProgLengthVal; + } + + const ltg = parseNumberIfGiven(formData.loan_term); + if (ltg !== undefined) collegePayload.loan_term = ltg; + + const ir = parseNumberIfGiven(formData.interest_rate); + if (ir !== undefined) collegePayload.interest_rate = ir; + + const ep = parseNumberIfGiven(formData.extra_payment); + if (ep !== undefined) collegePayload.extra_payment = ep; + + const chpy = parseNumberIfGiven(formData.credit_hours_per_year); + if (chpy !== undefined) collegePayload.credit_hours_per_year = chpy; + + const hc = parseNumberIfGiven(formData.hours_completed); + if (hc !== undefined) collegePayload.hours_completed = hc; + + const chr = parseNumberIfGiven(formData.credit_hours_required); + if (chr !== undefined) collegePayload.credit_hours_required = chr; + + const esal = parseNumberIfGiven(formData.expected_salary); + if (esal !== undefined) collegePayload.expected_salary = esal; + + // Defer Loan + if (formData.loan_deferral_until_graduation) { + collegePayload.loan_deferral_until_graduation = 1; + } + + // 2) Upsert the college row + const colRes = await authFetch('/api/premium/college-profile', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(collegePayload) }); if (!colRes.ok) { - const colText = await colRes.text(); - throw new Error('College update failed: ' + colText); + const msg2 = await colRes.text(); + throw new Error(`College upsert failed: ${msg2}`); } - const updatedCollege = await colRes.json(); - - onClose(updatedScenario, updatedCollege); + + // 3) Re-fetch scenario, college, & financial => aggregator => simulate + const [scenResp2, colResp2, finResp] = await Promise.all([ + authFetch(`/api/premium/career-profile/${updatedScenarioId}`), + authFetch(`/api/premium/college-profile?careerPathId=${updatedScenarioId}`), + authFetch(`/api/premium/financial-profile`) + ]); + if (!scenResp2.ok || !colResp2.ok || !finResp.ok) { + console.error('One re-fetch failed after upsert.', { + scenarioStatus: scenResp2.status, + collegeStatus: colResp2.status, + financialStatus: finResp.status + }); + onClose(); // or show an error + return; + } + + const [finalScenarioRow, finalCollegeRaw, finalFinancial] = await Promise.all([ + scenResp2.json(), + colResp2.json(), + finResp.json() + ]); + + let finalCollegeRow = Array.isArray(finalCollegeRaw) + ? finalCollegeRaw[0] || {} + : finalCollegeRaw; + + // 4) Build the aggregator and run the simulation + const userProfile = buildMergedUserProfile( + finalScenarioRow, + finalCollegeRow, + finalFinancial + ); + const results = simulateFinancialProjection(userProfile); + setProjectionData(results.projectionData); + setLoanPayoffMonth(results.loanPaidOffMonth); + + // 5) Now close the modal automatically + onClose(); + window.location.reload(); } catch (err) { - console.error('Error in handleSave:', err); - alert(err.message || 'Failed to save scenario changes'); + console.error('Error saving scenario + college:', err); + alert(err.message || 'Failed to save scenario data.'); } } + + + /********************************************************* + * 13) Render + *********************************************************/ if (!show) return null; - // displayed tuition/programLength const displayedTuition = manualTuition.trim() === '' ? autoTuition : manualTuition; - const displayedProgLen = + const displayedProgLength = manualProgLength.trim() === '' ? autoProgLength : manualProgLength; return (

- Edit Scenario: {scenario?.scenario_title || scenario?.career_name} + Edit Scenario: {scenario?.scenario_title || scenario?.career_name || '(untitled)'}

- {/* ============ SCENARIO (CAREER) SECTION ============ */} -

Scenario (Career Paths)

-
-
- - + {/* -- SCENARIO FIELDS -- */} +

Scenario & Career

+ + - - - {careerMatches.length > 0 && ( -
    - {careerMatches.map((c, idx) => ( -
  • handleSelectCareer(c)} - > - {c} -
  • - ))} -
- )} -

- Current Career: {formData.career_name || '(none)'} -

- - - + {careerMatches.length > 0 && ( +
    - - - - - + {careerMatches.map((c, idx) => ( +
  • handleSelectCareer(c)} + > + {c} +
  • + ))} +
+ )} +

+ Current Career: {formData.career_name || '(none)'} +

- - + + - - + + - - + + - - -
+ + - {/* ============ SCENARIO (FINANCIAL) SECTION ============ */} -

Scenario Overwrites (financial)

-
+ + + + {/* -- SCENARIO FINANCIAL OVERRIDES -- */} +

Scenario Financial Overwrites

@@ -647,7 +821,7 @@ export default function ScenarioEditModal({ />
- +
- +
- +
- {/* ============ COLLEGE SECTION ============ */} + {/* -- COLLEGE PROFILE FIELDS -- */}

College Profile

-
- {(formData.college_enrollment_status === 'currently_enrolled' || - formData.college_enrollment_status === 'prospective_student') ? ( -
-
+ {(formData.college_enrollment_status === 'currently_enrolled' + || formData.college_enrollment_status === 'prospective_student' + ) ? ( + <> +
+ + +
+ ) : (

Not currently enrolled or prospective. Minimal college fields only.

)} + {/* final actions */}
- - +
+ + {/* Show a preview if we have simulation data */} + {projectionData.length > 0 && ( +
+

Simulation Preview (first 5 months):

+
+              {JSON.stringify(projectionData.slice(0,5), null, 2)}
+            
+ {loanPayoffMonth && ( +

Loan Payoff Month: {loanPayoffMonth}

+ )} +
+ )}
); diff --git a/src/components/ScenarioEditWizard.js b/src/components/ScenarioEditWizard.js new file mode 100644 index 0000000..668ebdb --- /dev/null +++ b/src/components/ScenarioEditWizard.js @@ -0,0 +1,165 @@ +// ScenarioEditWizard.js +import React, { useState, useEffect } from 'react'; +import CareerOnboarding from './PremiumOnboarding/CareerOnboarding.js'; +import FinancialOnboarding from './PremiumOnboarding/FinancialOnboarding.js'; +import CollegeOnboarding from './PremiumOnboarding/CollegeOnboarding.js'; +import ReviewPage from './PremiumOnboarding/ReviewPage.js'; +import authFetch from '../utils/authFetch.js'; +import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; + +export default function ScenarioEditWizard({ + show, + onClose, + scenarioId // or scenario object +}) { + const [step, setStep] = useState(0); + const [careerData, setCareerData] = useState({}); + const [financialData, setFinancialData] = useState({}); + const [collegeData, setCollegeData] = useState({}); + + // You might also store scenario + college IDs + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!show || !scenarioId) return; + // 1) fetch scenario => careerData + // 2) fetch financial => financialData + // 3) fetch college => collegeData + // Pre-fill the same states your Onboarding steps expect. + async function fetchExisting() { + setLoading(true); + try { + const [scenRes, finRes, colRes] = await Promise.all([ + authFetch(`/api/premium/career-profile/${scenarioId}`), + authFetch(`/api/premium/financial-profile`), + authFetch(`/api/premium/college-profile?careerPathId=${scenarioId}`) + ]); + if (!scenRes.ok || !finRes.ok || !colRes.ok) { + throw new Error('Failed fetching existing scenario or financial or college.'); + } + const [scenData, finData, colDataRaw] = await Promise.all([ + scenRes.json(), + finRes.json(), + colRes.json() + ]); + let colData = Array.isArray(colDataRaw) ? colDataRaw[0] : colDataRaw; + + // Now put them into the same shape as your Onboarding step states: + setCareerData({ + career_name: scenData.career_name, + college_enrollment_status: scenData.college_enrollment_status, + currently_working: scenData.currently_working, + status: scenData.status, + start_date: scenData.start_date, + projected_end_date: scenData.projected_end_date, + planned_monthly_expenses: scenData.planned_monthly_expenses, + planned_monthly_debt_payments: scenData.planned_monthly_debt_payments, + planned_monthly_retirement_contribution: scenData.planned_monthly_retirement_contribution, + planned_monthly_emergency_contribution: scenData.planned_monthly_emergency_contribution, + planned_surplus_emergency_pct: scenData.planned_surplus_emergency_pct, + planned_surplus_retirement_pct: scenData.planned_surplus_retirement_pct, + planned_additional_income: scenData.planned_additional_income, + user_id: scenData.user_id, + // etc... + }); + + setFinancialData({ + // your financial table fields + current_salary: finData.current_salary, + additional_income: finData.additional_income, + monthly_expenses: finData.monthly_expenses, + monthly_debt_payments: finData.monthly_debt_payments, + retirement_savings: finData.retirement_savings, + emergency_fund: finData.emergency_fund, + retirement_contribution: finData.retirement_contribution, + emergency_contribution: finData.emergency_contribution, + extra_cash_emergency_pct: finData.extra_cash_emergency_pct, + extra_cash_retirement_pct: finData.extra_cash_retirement_pct + }); + + setCollegeData({ + // from colData + selected_school: colData.selected_school, + selected_program: colData.selected_program, + program_type: colData.program_type, + academic_calendar: colData.academic_calendar, + is_in_state: colData.is_in_state, + is_in_district: colData.is_in_district, + is_online: colData.is_online, + college_enrollment_status: colData.college_enrollment_status, + annual_financial_aid: colData.annual_financial_aid, + existing_college_debt: colData.existing_college_debt, + tuition: colData.tuition, + tuition_paid: colData.tuition_paid, + loan_deferral_until_graduation: colData.loan_deferral_until_graduation, + loan_term: colData.loan_term, + interest_rate: colData.interest_rate, + extra_payment: colData.extra_payment, + credit_hours_per_year: colData.credit_hours_per_year, + hours_completed: colData.hours_completed, + program_length: colData.program_length, + credit_hours_required: colData.credit_hours_required, + expected_graduation: colData.expected_graduation, + expected_salary: colData.expected_salary + }); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + } + fetchExisting(); + }, [show, scenarioId]); + + const nextStep = () => setStep(s => s + 1); + const prevStep = () => setStep(s => s - 1); + + if (!show) return null; + if (loading) return
Loading existing scenario...
; + + const steps = [ + , + , + , + { + // same final logic from Onboarding: upsert scenario, financial, college + // Then close + onClose(); + }} + onBack={prevStep} + /> + ]; + + return ( +
+
+ {steps[step]} +
+
+ ); +} diff --git a/src/utils/FinancialProjectionService.js b/src/utils/FinancialProjectionService.js index d03972e..a131863 100644 --- a/src/utils/FinancialProjectionService.js +++ b/src/utils/FinancialProjectionService.js @@ -45,8 +45,6 @@ function calculateAnnualFederalTaxSingle(annualTaxable) { /*************************************************** * HELPER: Monthly Federal Tax (no YTD) - * We just treat (monthlyGross * 12) - standardDed - * -> bracket -> / 12 ***************************************************/ function calculateMonthlyFedTaxNoYTD(monthlyGross) { const annualGross = monthlyGross * 12; @@ -59,7 +57,6 @@ function calculateMonthlyFedTaxNoYTD(monthlyGross) { /*************************************************** * HELPER: Monthly State Tax (no YTD) - * Uses GA (5%) by default if user doesn't override ***************************************************/ function calculateMonthlyStateTaxNoYTD(monthlyGross, stateCode = 'GA') { const rate = APPROX_STATE_TAX_RATES[stateCode] ?? 0.05; @@ -87,6 +84,9 @@ function calculateLoanPayment(principal, annualRate, years) { * MAIN SIMULATION FUNCTION ***************************************************/ export function simulateFinancialProjection(userProfile) { + // 1) Show userProfile at the start + console.log("simulateFinancialProjection() called with userProfile:", userProfile); + /*************************************************** * 1) DESTRUCTURE USER PROFILE ***************************************************/ @@ -105,7 +105,7 @@ export function simulateFinancialProjection(userProfile) { loanDeferralUntilGraduation = false, // College config - inCollege = false, + inCollege = false, // <<==== user-provided programType, hoursCompleted = 0, creditHoursPerYear, @@ -131,12 +131,12 @@ export function simulateFinancialProjection(userProfile) { // Program length override programLength, - // State code for taxes (default to GA if not provided) + // State code stateCode = 'GA', // Financial milestone impacts milestoneImpacts = [], - + // Simulation duration simulationYears = 20 @@ -170,7 +170,7 @@ export function simulateFinancialProjection(userProfile) { const finalProgramLength = programLength || dynamicProgramLength; /*************************************************** - * 4) TUITION CALC: lumps, deferral, etc. + * 4) TUITION CALC ***************************************************/ const netAnnualTuition = Math.max(0, (calculatedTuition || 0) - (annualFinancialAid || 0)); const totalTuitionCost = netAnnualTuition * finalProgramLength; @@ -205,10 +205,19 @@ export function simulateFinancialProjection(userProfile) { ? 0 : calculateLoanPayment(studentLoanAmount, interestRate, loanTerm); + // Log the initial loan info: + console.log("Initial loan payment setup:", { + studentLoanAmount, + interestRate, + loanTerm, + loanDeferralUntilGraduation, + monthlyLoanPayment + }); + /*************************************************** * 6) SETUP FOR THE SIMULATION LOOP ***************************************************/ - const maxMonths = simulationYears*12; // 20 years + const maxMonths = simulationYears * 12; let loanBalance = Math.max(studentLoanAmount, 0); let loanPaidOffMonth = null; @@ -217,28 +226,30 @@ export function simulateFinancialProjection(userProfile) { let projectionData = []; - // Keep track of YTD gross & tax for reference + // Track YTD gross & tax let fedYTDgross = 0; let fedYTDtax = 0; let stateYTDgross = 0; let stateYTDtax = 0; + // We'll keep track that we started in deferral if inCollege & deferral is true let wasInDeferral = inCollege && loanDeferralUntilGraduation; + + // If there's a gradDate, let's see if we pass it: const graduationDateObj = gradDate ? moment(gradDate).startOf('month') : null; /*************************************************** * 7) THE MONTHLY LOOP ***************************************************/ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) { - // date for this iteration const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months'); - // check if loan is fully paid + // Check if loan is fully paid if (loanBalance <= 0 && !loanPaidOffMonth) { loanPaidOffMonth = currentSimDate.format('YYYY-MM'); } - // Are we still in college? + // Are we still in college for this month? let stillInCollege = false; if (inCollege) { if (graduationDateObj) { @@ -271,7 +282,7 @@ export function simulateFinancialProjection(userProfile) { ************************************************/ let baseMonthlyIncome = 0; if (!stillInCollege) { - // user is out of college => expected or current + // user out of college => expected or current baseMonthlyIncome = (expectedSalary || currentSalary) / 12; } else { // in college => might have partTimeIncome + current @@ -315,7 +326,7 @@ export function simulateFinancialProjection(userProfile) { }); /************************************************ - * 7.4 CALCULATE TAXES (No YTD approach) + * 7.4 CALCULATE TAXES ************************************************/ const monthlyFederalTax = calculateMonthlyFedTaxNoYTD(baseMonthlyIncome); const monthlyStateTax = calculateMonthlyStateTaxNoYTD(baseMonthlyIncome, stateCode); @@ -324,7 +335,7 @@ export function simulateFinancialProjection(userProfile) { // net after tax const netMonthlyIncome = baseMonthlyIncome - combinedTax; - // increment YTD gross & tax for reference + // increment YTD for reference fedYTDgross += baseMonthlyIncome; fedYTDtax += monthlyFederalTax; stateYTDgross += baseMonthlyIncome; @@ -333,28 +344,47 @@ export function simulateFinancialProjection(userProfile) { /************************************************ * 7.5 LOAN + EXPENSES ************************************************/ + // Check if we're now exiting college & deferral ended => recalc monthlyLoanPayment const nowExitingCollege = wasInDeferral && !stillInCollege; if (nowExitingCollege) { + // recalc monthlyLoanPayment with the current loanBalance monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, loanTerm); + console.log( + `== Exiting deferral at monthIndex=${monthIndex}, ` + + `loanBalance=${loanBalance}, new monthlyLoanPayment=${monthlyLoanPayment}` + ); } + // sum up all monthly expenses let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth + extraImpactsThisMonth; + // (UPDATED) console log includes inCollege + stillInCollege + loanDeferral + console.log( + `Month ${monthIndex}, date=${currentSimDate.format('YYYY-MM')} => ` + + `inCollege=${inCollege}, stillInCollege=${stillInCollege}, ` + + `loanDeferralUntilGrad=${loanDeferralUntilGraduation}, ` + + `loanBalBefore=${loanBalance.toFixed(2)}, ` + + `monthlyLoanPayment=${monthlyLoanPayment.toFixed(2)}, extraPayment=${extraPayment}` + ); + if (stillInCollege && loanDeferralUntilGraduation) { - // accumulate interest + // accumulate interest only const interestForMonth = loanBalance * (interestRate / 100 / 12); loanBalance += interestForMonth; + console.log(` (deferral) interest added=${interestForMonth.toFixed(2)}, loanBalAfter=${loanBalance.toFixed(2)}`); } else { // pay principal if (loanBalance > 0) { const interestForMonth = loanBalance * (interestRate / 100 / 12); - const principalForMonth = Math.min( - loanBalance, - (monthlyLoanPayment + extraPayment) - interestForMonth - ); + const totalThisMonth = monthlyLoanPayment + extraPayment; + const principalForMonth = Math.min(loanBalance, totalThisMonth - interestForMonth); loanBalance = Math.max(loanBalance - principalForMonth, 0); + totalMonthlyExpenses += totalThisMonth; - totalMonthlyExpenses += (monthlyLoanPayment + extraPayment); + console.log( + ` (payment) interest=${interestForMonth.toFixed(2)}, principal=${principalForMonth.toFixed(2)}, ` + + `loanBalAfter=${loanBalance.toFixed(2)}` + ); } } @@ -379,7 +409,6 @@ export function simulateFinancialProjection(userProfile) { const canCover = Math.min(shortfall, currentEmergencySavings); currentEmergencySavings -= canCover; shortfall -= canCover; - // leftover -= shortfall; // if you want negative leftover } // Surplus => leftover @@ -392,11 +421,15 @@ export function simulateFinancialProjection(userProfile) { currentRetirementSavings += retPortion; } - // net savings const netSavings = netMonthlyIncome - actualExpensesPaid; + // (UPDATED) add inCollege, stillInCollege, loanDeferralUntilGraduation to the result projectionData.push({ month: currentSimDate.format('YYYY-MM'), + inCollege, // new + stillInCollege, // new + loanDeferralUntilGraduation, // new + grossMonthlyIncome: +baseMonthlyIncome.toFixed(2), monthlyFederalTax: +monthlyFederalTax.toFixed(2), monthlyStateTax: +monthlyStateTax.toFixed(2), @@ -408,24 +441,35 @@ export function simulateFinancialProjection(userProfile) { effectiveEmergencyContribution: +effectiveEmergencyContribution.toFixed(2), netSavings: +netSavings.toFixed(2), - emergencySavings: +currentEmergencySavings.toFixed(2), - retirementSavings: +currentRetirementSavings.toFixed(2), + // If you want to show the new running values, + // you can keep them as is or store them: + emergencySavings: (typeof currentEmergencySavings === 'number') + ? +currentEmergencySavings.toFixed(2) + : currentEmergencySavings, + retirementSavings: (typeof currentRetirementSavings === 'number') + ? +currentRetirementSavings.toFixed(2) + : currentRetirementSavings, loanBalance: +loanBalance.toFixed(2), - // actual loan payment loanPaymentThisMonth: +(monthlyLoanPayment + extraPayment).toFixed(2), - // YTD references fedYTDgross: +fedYTDgross.toFixed(2), fedYTDtax: +fedYTDtax.toFixed(2), stateYTDgross: +stateYTDgross.toFixed(2), - stateYTDtax: +stateYTDtax.toFixed(2), + stateYTDtax: +stateYTDtax.toFixed(2) }); - // update deferral - wasInDeferral = stillInCollege && loanDeferralUntilGraduation; + wasInDeferral = (stillInCollege && loanDeferralUntilGraduation); } + // final loanPaidOffMonth if never set + if (loanBalance <= 0 && !loanPaidOffMonth) { + loanPaidOffMonth = scenarioStartClamped.clone().add(maxMonths, 'months').format('YYYY-MM'); + } + + console.log("End of simulation: finalLoanBalance=", loanBalance.toFixed(2), + "loanPaidOffMonth=", loanPaidOffMonth); + return { projectionData, loanPaidOffMonth, @@ -433,10 +477,9 @@ export function simulateFinancialProjection(userProfile) { finalRetirementSavings: +currentRetirementSavings.toFixed(2), finalLoanBalance: +loanBalance.toFixed(2), - // Final YTD totals fedYTDgross: +fedYTDgross.toFixed(2), fedYTDtax: +fedYTDtax.toFixed(2), stateYTDgross: +stateYTDgross.toFixed(2), - stateYTDtax: +stateYTDtax.toFixed(2), + stateYTDtax: +stateYTDtax.toFixed(2) }; } diff --git a/user_profile.db b/user_profile.db index b9c2af5b9e60b3a1e5d2b38d3731e7d420f401c2..ecc73c3ca017f57d98962424eb9f3692846564a2 100644 GIT binary patch delta 3401 zcma)8e~27s8J^kuwY$09xim>za=EyBq`9Q_+xvdbH#2fg$~B6SM2)v7$Jxs_Gv8>) zx?8RnOKrLB1`L8olPtIjQd?0$NgK~ouPxN3@h_nWQi})TKWhI_3N3=5LeY0-?{@dD z*RV4j`|-{<_dWBx&-=XZ)3=77zBPPx+i&a%=oqNMdFaeEoY&9x z=4?;u|8|w(!4t5ZyG(awc)MFcmoD;<(8Gicg zsl~ZZo;mf{C4YHI1Tg;`yyiS6;Tjit|;bu3*o~%Av#bw9f+Vx z=`rSER-&>8;Z~fI62^3X?Z9u}J(@MG4a3^7Zdy0j^6x!mo$IHP2aXMt&3=@Sni?o) zVv+yC94w5Q)@j2!J^F+AULPx%*GCr{bAwhn^^vJuF+FaSXJ#^Gp@j~$lt2S>5tlN8 zLZui{p)}Ztup!f#$&X*Cs%mgSRnF;ZsFw&p5VHqTg}jn&qV zECxZ!$NOUD#S`ZAY#)vg1Ip#zG4fx^KWN>|U(LU4ZR8JFS1g|YOYR+OPYm#l+_@NF zKPHrmH}>NNqw5czHGg9si22CBZ{*+4{XO?^_VM`6++JkwfLA35-KyBS~{J zP53d>#WZ6g;0$9|f_nrUH0)5MpuZvNDnYOeeb?vh=28Djt-Ac>$`iq*r6pZmt~}9c zw%MT+WYTT-1XU`X&HySf`*d-DF->SdUBKcy7>rtC7dc~L z+<@)Kwk2Vd5!`%rA;b)yAWnpVN*(H>F$^2TIo83`pA*k&# z=oA{vSW+MPEQ}Rv6Yg`yY{fE@STL#-DV%x~8llLI}Pxvy6<255%MKoKZj z1l>?V15y$>Lc2(Zp-mW%Y!U_i4FH>x>Ydy`OkE>R`aMi|%;qFC%qgvBA2AQs z_kCk(%=pKcb+QN*7hnH9c-Kj5;n=@!KeGT3H*3KB!>#Rj9W+eA!5T zw|_%C^JJ25V7@DXX^Dh{oO9j!^Md)kAuAo;y}$MKFPg^>x`nNAux*KN8`j&_ZR>~D z>GiSm<~OYjK%9bUY_`_Q*UWo{+k^D_>os$>05fCK(Klk7YTv7kH}dtwMfEpm6!zG=$^ImsP1Djq65)h&o3xo-&v?9Rueznx3R+Q`mXf6?ByDW3oh;q% zMw>+9P$e~os)$S56<7!aMF?@J5`|Dw4xj=jkcxVY#HHd-ocKAxsb#!rvMBzHh$w=KRg3^EaC=bpq#dR}MH!m3*beYNk)X1na~Nac)-6IfM8+&#f)?fuf* z4`;CM=arWA5myf|*Ri^5Hd`}TLQ@s6r}j%}-#pFGblq1@Z-mC!-&0OyDD%g7#FYi+ zf8@TuV_7m*H3P|(rXi{rGSW?=qqIeJotmaiEGyibtDK#how2LZG0U2rGG>aE^7No| zuvC(c%}mqqXd%(^Z_VRqLvxmd& zKCrpITB2k%VeAg@+iK5Ou0;g#IwxL-m&G5J+z-TB|6C#}xO+o<{&5#4f%p3;h`)Hq zu2d(AhIF_%F8NgN3ujGW%PtLu=Si z*weOAwI(IDk(4X3HI`9Y_gbEtzZeVL;NXqrHu)OtICSNSE4?Bo@csa7C<^!1f$(_k zn|Jynvb27%A6^Yek3_>eICxt8Ozdtw*Ya&kCi+I?&&bK}$Kf4I?jND$9bf$#j|3s* z;36D@tIOhV=itCrj!Nhk^1!Dq$nO^N;kvLs0s*Oo7mstWUp(GA7yW4^CV?w0PgATq z$aFLvkpi_W%Wp=zaNbrNL)T4=oI;98#L+0ytO7jM|B}|&10rvD4dYaXy;M)=ly0ci zZx5U8dfL(qhnS2N>a&uvkzo@VVU-d^rH*$z0amxUdEf&ZXaMs9NzmtANPs=wDgr3Z zwq+$tGE_-uy81x4O?FR6f?cr)D-j+p`l?uE&)7;#21nsD%=+F|4k_S^V=KgZ3y*`O z7fgc3y8Mt)aX%+ci2LE9cuD+(1-<~O_#KS;;d{lE{X^hO&??^Lgq8)aWuf&3Jh$Gi4Ku|L+Fixu?LODg4}iG4#s4&EVF% zACZA}enVJwuo!0){U1z><{ZP1jwbKJ7x;DfocHx8*qw9t30-UjTi6|91ci7$6b5yM z>I4~7bu8I2k!oWZ(NtPSnx+%v7^YsJ*i@)y*W|glcV`52HSdl^f;+bdJn#Z|2vLb$ ze5@5Ke%oHVt{Ns^073W^2Y-ZLz&GKUmDo6V6~^NM{s{Qbi(%#S3dl(j0bbzhvRPbm YUxLs1GTchK^=7t}gJ0`)w?;wVU$qwyCIA2c diff --git a/user_proile.db b/user_proile.db new file mode 100644 index 0000000..e69de29