UX/UI changes to MilestoneTracker/MilestoneTimeline.js and ScenarioContainer for Tasks.

This commit is contained in:
Josh 2025-05-02 14:33:14 +00:00
parent ce53afb3d1
commit ff7ab9f775
5 changed files with 582 additions and 574 deletions

View File

@ -1,8 +1,8 @@
// src/components/MilestoneTimeline.js
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
const today = new Date();
export default function MilestoneTimeline({ export default function MilestoneTimeline({
careerPathId, careerPathId,
authFetch, authFetch,
@ -12,6 +12,7 @@ export default function MilestoneTimeline({
}) { }) {
const [milestones, setMilestones] = useState({ Career: [], Financial: [] }); const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
// We'll keep your existing milestone form state, tasks, copy wizard, etc.
const [newMilestone, setNewMilestone] = useState({ const [newMilestone, setNewMilestone] = useState({
title: '', title: '',
description: '', description: '',
@ -25,16 +26,14 @@ export default function MilestoneTimeline({
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingMilestone, setEditingMilestone] = useState(null); const [editingMilestone, setEditingMilestone] = useState(null);
// For tasks
const [showTaskForm, setShowTaskForm] = useState(null); const [showTaskForm, setShowTaskForm] = useState(null);
const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' }); const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' });
// The copy wizard
const [scenarios, setScenarios] = useState([]); const [scenarios, setScenarios] = useState([]);
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null); const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 1) HELPER: Add or remove an impact from newMilestone // 1) Financial Impacts sub-form helpers (no change)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
function addNewImpact() { function addNewImpact() {
setNewMilestone((prev) => ({ setNewMilestone((prev) => ({
@ -67,7 +66,7 @@ export default function MilestoneTimeline({
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 2) fetchMilestones => local state // 2) Fetch milestones => store in "milestones[Career]" / "milestones[Financial]"
// ------------------------------------------------------------------ // ------------------------------------------------------------------
const fetchMilestones = useCallback(async () => { const fetchMilestones = useCallback(async () => {
if (!careerPathId) return; if (!careerPathId) return;
@ -101,7 +100,7 @@ export default function MilestoneTimeline({
}, [fetchMilestones]); }, [fetchMilestones]);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 3) Load Scenarios for copy wizard // 3) Load all scenarios for the copy wizard
// ------------------------------------------------------------------ // ------------------------------------------------------------------
useEffect(() => { useEffect(() => {
async function loadScenarios() { async function loadScenarios() {
@ -254,7 +253,7 @@ export default function MilestoneTimeline({
} }
} }
// Re-fetch or update local // Re-fetch
await fetchMilestones(); await fetchMilestones();
// reset form // reset form
@ -306,7 +305,7 @@ export default function MilestoneTimeline({
const createdTask = await res.json(); const createdTask = await res.json();
console.log('Task created:', createdTask); console.log('Task created:', createdTask);
// Re-fetch so the timeline shows the new task // Re-fetch so the list shows the new task
await fetchMilestones(); await fetchMilestones();
setNewTask({ title: '', description: '', due_date: '' }); setNewTask({ title: '', description: '', due_date: '' });
@ -317,7 +316,7 @@ export default function MilestoneTimeline({
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 7) Copy Wizard => now with brute force refresh // 7) Copy Wizard
// ------------------------------------------------------------------ // ------------------------------------------------------------------
function CopyMilestoneWizard({ milestone, scenarios, onClose, authFetch }) { function CopyMilestoneWizard({ milestone, scenarios, onClose, authFetch }) {
const [selectedScenarios, setSelectedScenarios] = useState([]); const [selectedScenarios, setSelectedScenarios] = useState([]);
@ -342,7 +341,6 @@ export default function MilestoneTimeline({
}); });
if (!res.ok) throw new Error('Failed to copy milestone'); if (!res.ok) throw new Error('Failed to copy milestone');
// Brute force page refresh
window.location.reload(); window.location.reload();
onClose(); onClose();
} catch (err) { } catch (err) {
@ -357,7 +355,6 @@ export default function MilestoneTimeline({
<p> <p>
Milestone: <strong>{milestone.title}</strong> Milestone: <strong>{milestone.title}</strong>
</p> </p>
{scenarios.map((s) => ( {scenarios.map((s) => (
<div key={s.id}> <div key={s.id}>
<label> <label>
@ -366,11 +363,10 @@ export default function MilestoneTimeline({
checked={selectedScenarios.includes(s.id)} checked={selectedScenarios.includes(s.id)}
onChange={() => toggleScenario(s.id)} onChange={() => toggleScenario(s.id)}
/> />
{s.career_name} {s.career_name || s.scenario_title || '(untitled)'}
</label> </label>
</div> </div>
))} ))}
<div style={{ marginTop: '1rem' }}> <div style={{ marginTop: '1rem' }}>
<Button onClick={onClose} style={{ marginRight: '0.5rem' }}> <Button onClick={onClose} style={{ marginRight: '0.5rem' }}>
Cancel Cancel
@ -383,12 +379,12 @@ export default function MilestoneTimeline({
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 8) handleDelete => also brute force refresh // 8) Delete Milestone
// ------------------------------------------------------------------ // ------------------------------------------------------------------
async function handleDeleteMilestone(m) { async function handleDeleteMilestone(m) {
if (m.is_universal === 1) { if (m.is_universal === 1) {
const userChoice = window.confirm( const userChoice = window.confirm(
'This milestone is universal. OK => remove from ALL scenarios, Cancel => remove only from this scenario.' 'This milestone is universal. OK => remove from ALL scenarios, Cancel => only remove from this scenario.'
); );
if (userChoice) { if (userChoice) {
// delete from all // delete from all
@ -421,7 +417,7 @@ export default function MilestoneTimeline({
method: 'DELETE' method: 'DELETE'
}); });
if (!delRes.ok) { if (!delRes.ok) {
console.error('Failed to delete single milestone:', delRes.status); console.error('Failed to delete milestone:', delRes.status);
} }
} catch (err) { } catch (err) {
console.error('Error removing milestone from scenario:', err); console.error('Error removing milestone from scenario:', err);
@ -429,37 +425,14 @@ export default function MilestoneTimeline({
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 9) Render the timeline // 9) RENDER: remove the "timeline" code, show a list instead
// ------------------------------------------------------------------ // ------------------------------------------------------------------
const allMilestonesCombined = [...milestones.Career, ...milestones.Financial]; // Combined array if you want to show them all in one list
const lastDate = allMilestonesCombined.reduce((latest, m) => { const allMilestones = [...milestones.Career, ...milestones.Financial];
const d = new Date(m.date);
return d > latest ? d : latest;
}, today);
function calcPosition(dateString) {
const start = today.getTime();
const end = lastDate.getTime();
const dateVal = new Date(dateString).getTime();
if (end === start) return 0;
const ratio = (dateVal - start) / (end - start);
return Math.min(Math.max(ratio * 100, 0), 100);
}
return ( return (
<div className="milestone-timeline"> <div className="milestone-timeline" style={{ padding: '1rem' }}>
<div className="view-selector"> {/* “+ New Milestone” toggles the same form as before */}
{['Career', 'Financial'].map((view) => (
<Button
key={view}
className={activeView === view ? 'active' : ''}
onClick={() => setActiveView(view)}
>
{view}
</Button>
))}
</div>
<Button <Button
onClick={() => { onClick={() => {
if (showForm) { if (showForm) {
@ -480,13 +453,15 @@ export default function MilestoneTimeline({
setShowForm(true); setShowForm(true);
} }
}} }}
style={{ marginBottom: '0.5rem' }}
> >
{showForm ? 'Cancel' : '+ New Milestone'} {showForm ? 'Cancel' : '+ New Milestone'}
</Button> </Button>
{/* CREATE/EDIT FORM */} {/* If showForm => the same create/edit form */}
{showForm && ( {showForm && (
<div className="form"> <div className="border p-2 my-2">
<h4>{editingMilestone ? 'Edit Milestone' : 'New Milestone'}</h4>
<input <input
type="text" type="text"
placeholder="Title" placeholder="Title"
@ -508,28 +483,24 @@ export default function MilestoneTimeline({
type="number" type="number"
placeholder="Progress (%)" placeholder="Progress (%)"
value={newMilestone.progress === 0 ? '' : newMilestone.progress} value={newMilestone.progress === 0 ? '' : newMilestone.progress}
onChange={(e) => { onChange={(e) =>
const val = e.target.value === '' ? 0 : parseInt(e.target.value, 10); setNewMilestone((prev) => ({
setNewMilestone((prev) => ({ ...prev, progress: val })); ...prev,
}} progress: parseInt(e.target.value || '0', 10)
}))
}
/> />
{/* If “Financial” => show impacts */}
{activeView === 'Financial' && ( {activeView === 'Financial' && (
<div> <div style={{ border: '1px solid #ccc', padding: '1rem', marginTop: '1rem' }}>
<input <h5>Financial Impacts</h5>
type="number"
placeholder="Full New Salary (e.g., 70000)"
value={newMilestone.newSalary}
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })}
/>
<p>Enter the full new salary after the milestone occurs.</p>
<div className="impacts-section border p-2 mt-3">
<h4>Financial Impacts</h4>
{newMilestone.impacts.map((imp, idx) => ( {newMilestone.impacts.map((imp, idx) => (
<div key={idx} className="impact-item border p-2 my-2"> <div
{imp.id && <p className="text-xs text-gray-500">Impact ID: {imp.id}</p>} key={idx}
style={{ border: '1px solid #bbb', margin: '0.5rem', padding: '0.5rem' }}
>
{imp.id && <p style={{ fontSize: '0.8rem' }}>ID: {imp.id}</p>}
<div> <div>
<label>Type: </label> <label>Type: </label>
<select <select
@ -540,7 +511,6 @@ export default function MilestoneTimeline({
<option value="MONTHLY">Monthly</option> <option value="MONTHLY">Monthly</option>
</select> </select>
</div> </div>
<div> <div>
<label>Direction: </label> <label>Direction: </label>
<select <select
@ -551,7 +521,6 @@ export default function MilestoneTimeline({
<option value="subtract">Subtract (Expense)</option> <option value="subtract">Subtract (Expense)</option>
</select> </select>
</div> </div>
<div> <div>
<label>Amount: </label> <label>Amount: </label>
<input <input
@ -560,40 +529,36 @@ export default function MilestoneTimeline({
onChange={(e) => updateImpact(idx, 'amount', e.target.value)} onChange={(e) => updateImpact(idx, 'amount', e.target.value)}
/> />
</div> </div>
<div> <div>
<label>Start Date:</label> <label>Start Date: </label>
<input <input
type="date" type="date"
value={imp.start_date || ''} value={imp.start_date || ''}
onChange={(e) => updateImpact(idx, 'start_date', e.target.value)} onChange={(e) => updateImpact(idx, 'start_date', e.target.value)}
/> />
</div> </div>
{imp.impact_type === 'MONTHLY' && ( {imp.impact_type === 'MONTHLY' && (
<div> <div>
<label>End Date (blank if indefinite): </label> <label>End Date: </label>
<input <input
type="date" type="date"
value={imp.end_date || ''} value={imp.end_date || ''}
onChange={(e) => updateImpact(idx, 'end_date', e.target.value || '')} onChange={(e) => updateImpact(idx, 'end_date', e.target.value)}
/> />
</div> </div>
)} )}
<Button
<Button className="text-red-500 mt-2" onClick={() => removeImpact(idx)}> style={{ marginLeft: '0.5rem', color: 'red' }}
Remove Impact onClick={() => removeImpact(idx)}
>
Remove
</Button> </Button>
</div> </div>
))} ))}
<Button onClick={addNewImpact} className="bg-gray-200 px-2 py-1 mt-2"> <Button onClick={addNewImpact}>+ Add Impact</Button>
+ Add Impact
</Button>
</div>
</div> </div>
)} )}
{/* universal checkbox */}
<div style={{ marginTop: '1rem' }}> <div style={{ marginTop: '1rem' }}>
<label> <label>
<input <input
@ -605,46 +570,44 @@ export default function MilestoneTimeline({
isUniversal: e.target.checked ? 1 : 0 isUniversal: e.target.checked ? 1 : 0
})) }))
} }
/> />{' '}
{' '}Apply this milestone to all scenarios? Apply this milestone to all scenarios
</label> </label>
</div> </div>
<Button onClick={saveMilestone} style={{ marginTop: '1rem' }}> <div style={{ marginTop: '1rem' }}>
<Button onClick={saveMilestone}>
{editingMilestone ? 'Update' : 'Add'} Milestone {editingMilestone ? 'Update' : 'Add'} Milestone
</Button> </Button>
</div> </div>
</div>
)} )}
{/* TIMELINE VISUAL */} {/* *** REPLACEMENT FOR THE OLD “TIMELINE VISUAL” *** */}
<div className="milestone-timeline-container"> {/* Instead of a horizontal timeline, we list them in a simple vertical list. */}
<div className="milestone-timeline-line" /> {Object.keys(milestones).map((typeKey) =>
{milestones[activeView].map((m) => { milestones[typeKey].map((m) => {
const leftPos = calcPosition(m.date); const tasks = m.tasks || [];
return ( return (
<div <div
key={m.id} key={m.id}
className="milestone-timeline-post" style={{ border: '1px solid #ccc', marginTop: '1rem', padding: '0.5rem' }}
style={{ left: `${leftPos}%` }}
> >
<div className="milestone-timeline-dot" onClick={() => handleEditMilestone(m)} /> <h5>{m.title}</h5>
<div className="milestone-content">
<div className="title">{m.title}</div>
{m.description && <p>{m.description}</p>} {m.description && <p>{m.description}</p>}
<p>
<strong>Date:</strong> {m.date} <strong>Progress:</strong> {m.progress}%
</p>
<div className="progress-bar"> {/* tasks list */}
<div className="progress" style={{ width: `${m.progress}%` }} /> {tasks.length > 0 && (
</div>
<div className="date">{m.date}</div>
{/* Tasks */}
{m.tasks && m.tasks.length > 0 && (
<ul> <ul>
{m.tasks.map((t) => ( {tasks.map((t) => (
<li key={t.id}> <li key={t.id}>
<strong>{t.title}</strong> <strong>{t.title}</strong>
{t.description ? ` - ${t.description}` : ''} {t.description ? ` - ${t.description}` : ''}
{t.due_date ? ` (Due: ${t.due_date})` : ''} {t.due_date ? ` (Due: ${t.due_date})` : ''}{' '}
{/* If you'd like to add “Edit”/“Delete” for tasks, replicate scenario container logic */}
</li> </li>
))} ))}
</ul> </ul>
@ -655,13 +618,15 @@ export default function MilestoneTimeline({
setShowTaskForm(showTaskForm === m.id ? null : m.id); setShowTaskForm(showTaskForm === m.id ? null : m.id);
setNewTask({ title: '', description: '', due_date: '' }); setNewTask({ title: '', description: '', due_date: '' });
}} }}
style={{ marginRight: '0.5rem' }}
> >
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'} {showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
</Button> </Button>
<div style={{ marginTop: '0.5rem' }}>
<Button onClick={() => handleEditMilestone(m)}>Edit</Button> <Button onClick={() => handleEditMilestone(m)}>Edit</Button>
<Button style={{ marginLeft: '0.5rem' }} onClick={() => setCopyWizardMilestone(m)}> <Button
style={{ marginLeft: '0.5rem' }}
onClick={() => setCopyWizardMilestone(m)}
>
Copy Copy
</Button> </Button>
<Button <Button
@ -670,10 +635,13 @@ export default function MilestoneTimeline({
> >
Delete Delete
</Button> </Button>
</div>
{/* The "Add Task" form if showTaskForm === m.id */}
{showTaskForm === m.id && ( {showTaskForm === m.id && (
<div className="task-form" style={{ marginTop: '0.5rem' }}> <div
style={{ marginTop: '0.5rem', border: '1px solid #aaa', padding: '0.5rem' }}
>
<h5>New Task</h5>
<input <input
type="text" type="text"
placeholder="Task Title" placeholder="Task Title"
@ -695,11 +663,11 @@ export default function MilestoneTimeline({
</div> </div>
)} )}
</div> </div>
</div>
); );
})} })
</div> )}
{/* Copy wizard if open */}
{copyWizardMilestone && ( {copyWizardMilestone && (
<CopyMilestoneWizard <CopyMilestoneWizard
milestone={copyWizardMilestone} milestone={copyWizardMilestone}

View File

@ -1,7 +1,7 @@
// src/components/MilestoneTracker.js // src/components/MilestoneTracker.js
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { v4 as uuidv4 } from 'uuid';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { import {
Chart as ChartJS, Chart as ChartJS,
@ -18,13 +18,18 @@ import { Button } from './ui/button.js';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import CareerSelectDropdown from './CareerSelectDropdown.js'; import CareerSelectDropdown from './CareerSelectDropdown.js';
import CareerSearch from './CareerSearch.js'; import CareerSearch from './CareerSearch.js';
// Keep MilestoneTimeline for +Add Milestone & tasks CRUD
import MilestoneTimeline from './MilestoneTimeline.js'; import MilestoneTimeline from './MilestoneTimeline.js';
import AISuggestedMilestones from './AISuggestedMilestones.js'; import AISuggestedMilestones from './AISuggestedMilestones.js';
import ScenarioEditModal from './ScenarioEditModal.js'; import ScenarioEditModal from './ScenarioEditModal.js';
import './MilestoneTracker.css'; // keeps your local styles
import './MilestoneTimeline.css'; // keeps your local styles import './MilestoneTracker.css';
import './MilestoneTimeline.css';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
// Register Chart + annotation plugin
ChartJS.register( ChartJS.register(
LineElement, LineElement,
CategoryScale, CategoryScale,
@ -47,29 +52,23 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null); const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
const [careerPathId, setCareerPathId] = useState(null); const [careerPathId, setCareerPathId] = useState(null);
const [existingCareerPaths, setExistingCareerPaths] = useState([]); const [existingCareerPaths, setExistingCareerPaths] = useState([]);
const [activeView, setActiveView] = useState("Career"); const [activeView, setActiveView] = useState('Career');
// Real user snapshot
const [financialProfile, setFinancialProfile] = useState(null); const [financialProfile, setFinancialProfile] = useState(null);
// Scenario row (with planned_* overrides)
const [scenarioRow, setScenarioRow] = useState(null); const [scenarioRow, setScenarioRow] = useState(null);
// scenario's collegeProfile row
const [collegeProfile, setCollegeProfile] = useState(null); const [collegeProfile, setCollegeProfile] = useState(null);
// Simulation results // We will store the scenarios milestones in state so we can build annotation lines
const [scenarioMilestones, setScenarioMilestones] = useState([]);
const [projectionData, setProjectionData] = useState([]); const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null); const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
// Possibly let user type the simulation length
const [simulationYearsInput, setSimulationYearsInput] = useState("20");
const simulationYears = parseInt(simulationYearsInput, 10) || 20; const simulationYears = parseInt(simulationYearsInput, 10) || 20;
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [pendingCareerForModal, setPendingCareerForModal] = useState(null); const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
// Possibly loaded from location.state
const { const {
projectionData: initialProjectionData = [], projectionData: initialProjectionData = [],
loanPayoffMonth: initialLoanPayoffMonth = null loanPayoffMonth: initialLoanPayoffMonth = null
@ -90,7 +89,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
setSelectedCareer(fromPopout); setSelectedCareer(fromPopout);
setCareerPathId(fromPopout.career_path_id); setCareerPathId(fromPopout.career_path_id);
} else if (!selectedCareer) { } else if (!selectedCareer) {
// fallback to latest // fallback: fetch the 'latest' scenario
const latest = await authFetch(`${apiURL}/premium/career-profile/latest`); const latest = await authFetch(`${apiURL}/premium/career-profile/latest`);
if (latest && latest.ok) { if (latest && latest.ok) {
const latestData = await latest.json(); const latestData = await latest.json();
@ -121,6 +120,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
if (!careerPathId) { if (!careerPathId) {
setScenarioRow(null); setScenarioRow(null);
setCollegeProfile(null); setCollegeProfile(null);
setScenarioMilestones([]);
return; return;
} }
@ -136,7 +136,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
} }
async function fetchCollege() { async function fetchCollege() {
const colRes = await authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`); const colRes = await authFetch(
`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`
);
if (!colRes?.ok) { if (!colRes?.ok) {
setCollegeProfile(null); setCollegeProfile(null);
return; return;
@ -150,28 +152,32 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
}, [careerPathId, apiURL]); }, [careerPathId, apiURL]);
// -------------------------------------------------- // --------------------------------------------------
// 3) Once we have (financialProfile, scenarioRow, collegeProfile), run initial simulation // 3) Once scenarioRow + collegeProfile + financialProfile => run simulation
// + fetch milestones for annotation lines
// -------------------------------------------------- // --------------------------------------------------
useEffect(() => { useEffect(() => {
if (!financialProfile || !scenarioRow || !collegeProfile) return; if (!financialProfile || !scenarioRow || !collegeProfile) return;
(async () => { (async () => {
try { try {
// 1) load milestones for scenario // fetch milestones for this scenario
const milRes = await authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`); const milRes = await authFetch(
`${apiURL}/premium/milestones?careerPathId=${careerPathId}`
);
if (!milRes.ok) { if (!milRes.ok) {
console.error('Failed to fetch initial milestones for scenario', careerPathId); console.error('Failed to fetch milestones for scenario', careerPathId);
return; return;
} }
const milestonesData = await milRes.json(); const milestonesData = await milRes.json();
const allMilestones = milestonesData.milestones || []; const allMilestones = milestonesData.milestones || [];
setScenarioMilestones(allMilestones); // store them for annotation lines
// 2) fetch impacts for each milestone // fetch impacts for each
const impactPromises = allMilestones.map(m => const impactPromises = allMilestones.map((m) =>
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`) authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
.then(r => r.ok ? r.json() : null) .then((r) => (r.ok ? r.json() : null))
.then(data => data?.impacts || []) .then((data) => data?.impacts || [])
.catch(err => { .catch((err) => {
console.warn('Error fetching impacts for milestone', m.id, err); console.warn('Error fetching impacts for milestone', m.id, err);
return []; return [];
}) })
@ -181,9 +187,11 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
...m, ...m,
impacts: impactsForEach[i] || [] impacts: impactsForEach[i] || []
})); }));
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts);
// 3) Build the merged profile // flatten all
const allImpacts = milestonesWithImpacts.flatMap((m) => m.impacts);
// mergedProfile
const mergedProfile = buildMergedProfile( const mergedProfile = buildMergedProfile(
financialProfile, financialProfile,
scenarioRow, scenarioRow,
@ -192,36 +200,25 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
simulationYears simulationYears
); );
// 4) run the simulation
const { projectionData: pData, loanPaidOffMonth: payoff } = const { projectionData: pData, loanPaidOffMonth: payoff } =
simulateFinancialProjection(mergedProfile); simulateFinancialProjection(mergedProfile);
// 5) If you track cumulative net
let cumu = mergedProfile.emergencySavings || 0; let cumu = mergedProfile.emergencySavings || 0;
const finalData = pData.map(mo => { const finalData = pData.map((mo) => {
cumu += (mo.netSavings || 0); cumu += mo.netSavings || 0;
return { ...mo, cumulativeNetSavings: cumu }; return { ...mo, cumulativeNetSavings: cumu };
}); });
setProjectionData(finalData); setProjectionData(finalData);
setLoanPayoffMonth(payoff); setLoanPayoffMonth(payoff);
} catch (err) { } catch (err) {
console.error('Error in initial scenario simulation:', err); console.error('Error in scenario simulation:', err);
} }
})(); })();
}, [ }, [financialProfile, scenarioRow, collegeProfile, careerPathId, apiURL, simulationYears]);
financialProfile,
scenarioRow,
collegeProfile,
simulationYears,
careerPathId,
apiURL
]);
// Merges the real snapshot w/ scenario overrides + milestones
function buildMergedProfile(finProf, scenRow, colProf, milestoneImpacts, simYears) { function buildMergedProfile(finProf, scenRow, colProf, milestoneImpacts, simYears) {
return { return {
// Real snapshot fallback
currentSalary: finProf.current_salary || 0, currentSalary: finProf.current_salary || 0,
monthlyExpenses: monthlyExpenses:
scenRow.planned_monthly_expenses ?? finProf.monthly_expenses ?? 0, scenRow.planned_monthly_expenses ?? finProf.monthly_expenses ?? 0,
@ -248,7 +245,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
additionalIncome: additionalIncome:
scenRow.planned_additional_income ?? finProf.additional_income ?? 0, scenRow.planned_additional_income ?? finProf.additional_income ?? 0,
// College stuff // college
studentLoanAmount: colProf.existing_college_debt || 0, studentLoanAmount: colProf.existing_college_debt || 0,
interestRate: colProf.interest_rate || 5, interestRate: colProf.interest_rate || 5,
loanTerm: colProf.loan_term || 10, loanTerm: colProf.loan_term || 10,
@ -261,122 +258,93 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
colProf.college_enrollment_status === 'currently_enrolled' || colProf.college_enrollment_status === 'currently_enrolled' ||
colProf.college_enrollment_status === 'prospective_student', colProf.college_enrollment_status === 'prospective_student',
gradDate: colProf.expected_graduation || null, gradDate: colProf.expected_graduation || null,
programType: colProf.program_type, programType: colProf.program_type || null,
creditHoursPerYear: colProf.credit_hours_per_year || 0, creditHoursPerYear: colProf.credit_hours_per_year || 0,
hoursCompleted: colProf.hours_completed || 0, hoursCompleted: colProf.hours_completed || 0,
programLength: colProf.program_length || 0, programLength: colProf.program_length || 0,
expectedSalary: colProf.expected_salary || finProf.current_salary || 0, expectedSalary: colProf.expected_salary || finProf.current_salary || 0,
// Additional // scenario horizon
startDate: new Date().toISOString(), startDate: new Date().toISOString(),
simulationYears: simYears, simulationYears: simYears,
// Milestone Impacts
milestoneImpacts: milestoneImpacts || [] milestoneImpacts: milestoneImpacts || []
}; };
} }
// ------------------------------------------------------ // If you want to re-run simulation after any milestone changes:
// 4) reSimulate => after milestone changes or user toggles something
// ------------------------------------------------------
const reSimulate = async () => { const reSimulate = async () => {
if (!careerPathId) return; // Put your logic to re-fetch scenario + milestones, then re-run sim
try {
// 1) fetch everything again
const [finResp, scenResp, colResp, milResp] = await Promise.all([
authFetch(`${apiURL}/premium/financial-profile`),
authFetch(`${apiURL}/premium/career-profile/${careerPathId}`),
authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`),
authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`)
]);
if (!finResp.ok || !scenResp.ok || !colResp.ok || !milResp.ok) {
console.error(
'One reSimulate fetch failed:',
finResp.status,
scenResp.status,
colResp.status,
milResp.status
);
return;
}
const [updatedFinancial, updatedScenario, updatedCollege, milData] =
await Promise.all([
finResp.json(),
scenResp.json(),
colResp.json(),
milResp.json()
]);
const allMilestones = milData.milestones || [];
const impactsPromises = allMilestones.map(m =>
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
.then(r => r.ok ? r.json() : null)
.then(data => data?.impacts || [])
.catch(err => {
console.warn('Impact fetch err for milestone', m.id, err);
return [];
})
);
const impactsForEach = await Promise.all(impactsPromises);
const milestonesWithImpacts = allMilestones.map((m, i) => ({
...m,
impacts: impactsForEach[i] || []
}));
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts);
// 2) Build merged
const mergedProfile = buildMergedProfile(
updatedFinancial,
updatedScenario,
updatedCollege,
allImpacts,
simulationYears
);
// 3) run
const { projectionData: newProjData, loanPaidOffMonth: payoff } =
simulateFinancialProjection(mergedProfile);
// 4) cumulative
let csum = mergedProfile.emergencySavings || 0;
const finalData = newProjData.map(mo => {
csum += (mo.netSavings || 0);
return { ...mo, cumulativeNetSavings: csum };
});
setProjectionData(finalData);
setLoanPayoffMonth(payoff);
// also store updated scenario, financial, college
setFinancialProfile(updatedFinancial);
setScenarioRow(updatedScenario);
setCollegeProfile(updatedCollege);
console.log('Re-simulated after milestone update', { mergedProfile, finalData });
} catch (err) {
console.error('Error in reSimulate:', err);
}
}; };
// handle user typing simulation length // handle user typing simulation length
const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value); const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value);
const handleSimulationYearsBlur = () => { const handleSimulationYearsBlur = () => {
if (!simulationYearsInput.trim()) { if (!simulationYearsInput.trim()) {
setSimulationYearsInput("20"); setSimulationYearsInput('20');
} }
}; };
// Logging // Build annotation lines from scenarioMilestones
console.log( const milestoneAnnotationLines = {};
'First 5 items of projectionData:', scenarioMilestones.forEach((m) => {
Array.isArray(projectionData) ? projectionData.slice(0, 5) : 'none' if (!m.date) return;
); const d = new Date(m.date);
if (isNaN(d)) return;
const year = d.getUTCFullYear();
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
const short = `${year}-${month}`;
if (!projectionData.some((p) => p.month === short)) return;
milestoneAnnotationLines[`milestone_${m.id}`] = {
type: 'line',
xMin: short,
xMax: short,
borderColor: 'orange',
borderWidth: 2,
label: {
display: true,
content: m.title || 'Milestone',
color: 'orange',
position: 'end'
},
// If you want them clickable:
onClick: () => {
console.log('Clicked milestone line => open editing for', m.title);
// e.g. open the MilestoneTimeline's edit feature, or do something
}
};
});
// If we also show a line for payoff:
const annotationConfig = {};
if (loanPayoffMonth) {
annotationConfig.loanPaidOffLine = {
type: 'line',
xMin: loanPayoffMonth,
xMax: loanPayoffMonth,
borderColor: 'rgba(255, 206, 86, 1)',
borderWidth: 2,
borderDash: [6, 6],
label: {
display: true,
content: 'Loan Paid Off',
position: 'end',
backgroundColor: 'rgba(255, 206, 86, 0.8)',
color: '#000',
font: { size: 12 },
rotation: 0,
yAdjust: -10
}
};
}
const allAnnotations = { ...milestoneAnnotationLines, ...annotationConfig };
return ( return (
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-6"> <div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-6">
{/* Career Select */} {/* 1) Career dropdown */}
<CareerSelectDropdown <CareerSelectDropdown
existingCareerPaths={existingCareerPaths} existingCareerPaths={existingCareerPaths}
selectedCareer={selectedCareer} selectedCareer={selectedCareer}
@ -388,16 +356,17 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
authFetch={authFetch} authFetch={authFetch}
/> />
{/* Milestone Timeline */} {/* 2) We keep MilestoneTimeline for tasks, +Add Milestone button, etc. */}
<MilestoneTimeline <MilestoneTimeline
// e.g. pass the scenario ID
careerPathId={careerPathId} careerPathId={careerPathId}
authFetch={authFetch} authFetch={authFetch}
activeView={activeView} activeView="Career"
setActiveView={setActiveView} onMilestoneUpdated={() => {
onMilestoneUpdated={reSimulate} }}
/> />
{/* AI-Suggested Milestones */} {/* 3) AI-Suggested Milestones */}
<AISuggestedMilestones <AISuggestedMilestones
career={selectedCareer?.career_name} career={selectedCareer?.career_name}
careerPathId={careerPathId} careerPathId={careerPathId}
@ -406,7 +375,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
projectionData={projectionData} projectionData={projectionData}
/> />
{/* Chart Section */} {/* 4) The main chart with annotation lines */}
{projectionData.length > 0 && ( {projectionData.length > 0 && (
<div className="bg-white p-4 rounded shadow space-y-4"> <div className="bg-white p-4 rounded shadow space-y-4">
<h3 className="text-lg font-semibold">Financial Projection</h3> <h3 className="text-lg font-semibold">Financial Projection</h3>
@ -449,30 +418,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
plugins: { plugins: {
legend: { position: 'bottom' }, legend: { position: 'bottom' },
tooltip: { mode: 'index', intersect: false }, tooltip: { mode: 'index', intersect: false },
annotation: loanPayoffMonth annotation: {
? { annotations: allAnnotations
annotations: {
loanPaidOffLine: {
type: 'line',
xMin: loanPayoffMonth,
xMax: loanPayoffMonth,
borderColor: 'rgba(255, 206, 86, 1)',
borderWidth: 2,
borderDash: [6, 6],
label: {
display: true,
content: 'Loan Paid Off',
position: 'end',
backgroundColor: 'rgba(255, 206, 86, 0.8)',
color: '#000',
font: { size: 12 },
rotation: 0,
yAdjust: -10
} }
}
}
}
: undefined
}, },
scales: { scales: {
y: { y: {
@ -484,29 +432,35 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
} }
}} }}
/> />
<div>
{loanPayoffMonth && (
<p className="font-semibold text-sm">
Loan Paid Off at: <span className="text-yellow-600">{loanPayoffMonth}</span>
</p>
)}
</div>
</div> </div>
)} )}
{/* Simulation Length Input */} {/* 5) Simulation length input */}
<div className="space-x-2"> <div className="space-x-2">
<label className="font-medium">Simulation Length (years):</label> <label className="font-medium">Simulation Length (years):</label>
<input <input
type="text" type="text"
value={simulationYearsInput} value={simulationYearsInput}
onChange={handleSimulationYearsChange} onChange={(e) => setSimulationYearsInput(e.target.value)}
onBlur={handleSimulationYearsBlur} onBlur={handleSimulationYearsBlur}
className="border rounded p-1 w-16" className="border rounded p-1 w-16"
/> />
</div> </div>
{/* Career Search */} {/* 6) Career Search, scenario edit modal, etc. */}
<CareerSearch <CareerSearch
onCareerSelected={(careerObj) => { onCareerSelected={(careerObj) => {
setPendingCareerForModal(careerObj.title); setPendingCareerForModal(careerObj.title);
}} }}
/> />
{/* Modal */}
<ScenarioEditModal <ScenarioEditModal
show={showEditModal} show={showEditModal}
onClose={() => setShowEditModal(false)} onClose={() => setShowEditModal(false)}
@ -518,11 +472,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
authFetch={authFetch} authFetch={authFetch}
/> />
{/* Confirm new career scenario */}
{pendingCareerForModal && ( {pendingCareerForModal && (
<Button <Button
onClick={() => { onClick={() => {
// Example action
console.log('User confirmed new career path:', pendingCareerForModal); console.log('User confirmed new career path:', pendingCareerForModal);
setPendingCareerForModal(null); setPendingCareerForModal(null);
}} }}

View File

@ -4,42 +4,29 @@ import React, { useState, useEffect, useCallback } from 'react';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { Chart as ChartJS } from 'chart.js'; import { Chart as ChartJS } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin from 'chartjs-plugin-annotation';
import { Button } from './ui/button.js'; // <-- Universal Button
import { Button } from './ui/button.js'; // universal Button
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import AISuggestedMilestones from './AISuggestedMilestones.js'; import AISuggestedMilestones from './AISuggestedMilestones.js';
import ScenarioEditModal from './ScenarioEditModal.js'; import ScenarioEditModal from './ScenarioEditModal.js';
// Register the annotation plugin // Register the annotation plugin globally
ChartJS.register(annotationPlugin); ChartJS.register(annotationPlugin);
/**
* ScenarioContainer
* -----------------
* This component:
* 1) Lets the user pick a scenario (via <select>), or uses the provided `scenario` prop.
* 2) Loads the collegeProfile + milestones/impacts for that scenario.
* 3) Merges scenario + user financial data + milestone impacts runs `simulateFinancialProjection`.
* 4) Shows a chart of net savings / retirement / loan balances over time.
* 5) Allows milestone CRUD (create, edit, delete, copy).
* 6) Offers Clone / Delete scenario callbacks from the parent.
*/
export default function ScenarioContainer({ export default function ScenarioContainer({
scenario, // The scenario row from career_paths scenario,
financialProfile, // The users overall financial snapshot financialProfile,
onRemove, // Callback for deleting scenario onRemove,
onClone, // Callback for cloning scenario onClone,
onEdit // (Optional) If you want a scenario editing callback onEdit
}) { }) {
/************************************************************* /*************************************************************
* 1) SCENARIO DROPDOWN: Load, store, and let user pick * 1) Scenario Dropdown
*************************************************************/ *************************************************************/
const [allScenarios, setAllScenarios] = useState([]); const [allScenarios, setAllScenarios] = useState([]);
// We keep a local copy of `scenario` so user can switch from the dropdown
const [localScenario, setLocalScenario] = useState(scenario || null); const [localScenario, setLocalScenario] = useState(scenario || null);
// (A) On mount, fetch all scenarios to populate the <select>
useEffect(() => { useEffect(() => {
async function loadScenarios() { async function loadScenarios() {
try { try {
@ -56,40 +43,32 @@ export default function ScenarioContainer({
loadScenarios(); loadScenarios();
}, []); }, []);
// (B) If parent changes the `scenario` prop, update local
useEffect(() => { useEffect(() => {
setLocalScenario(scenario || null); setLocalScenario(scenario || null);
}, [scenario]); }, [scenario]);
// (C) <select> handler for picking a scenario from dropdown
function handleScenarioSelect(e) { function handleScenarioSelect(e) {
const chosenId = e.target.value; const chosenId = e.target.value;
const found = allScenarios.find((s) => s.id === chosenId); const found = allScenarios.find((s) => s.id === chosenId);
if (found) { setLocalScenario(found || null);
setLocalScenario(found);
} else {
setLocalScenario(null);
}
} }
/************************************************************* /*************************************************************
* 2) COLLEGE PROFILE + MILESTONES + IMPACTS * 2) College Profile + Milestones
*************************************************************/ *************************************************************/
const [collegeProfile, setCollegeProfile] = useState(null); const [collegeProfile, setCollegeProfile] = useState(null);
const [milestones, setMilestones] = useState([]); const [milestones, setMilestones] = useState([]);
const [impactsByMilestone, setImpactsByMilestone] = useState({}); const [impactsByMilestone, setImpactsByMilestone] = useState({});
// We'll also track a scenario edit modal
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [editingScenarioData, setEditingScenarioData] = useState({ const [editingScenarioData, setEditingScenarioData] = useState({
scenario: null, scenario: null,
collegeProfile: null collegeProfile: null
}); });
// (A) Load the college profile for the selected scenario // load the college profile
useEffect(() => { useEffect(() => {
if (!localScenario?.id) { if (!localScenario?.id) {
// if no scenario selected, clear the collegeProfile
setCollegeProfile(null); setCollegeProfile(null);
return; return;
} }
@ -99,7 +78,6 @@ export default function ScenarioContainer({
const res = await authFetch(url); const res = await authFetch(url);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
// in some setups, the endpoint returns an array, in others an object
setCollegeProfile(Array.isArray(data) ? data[0] || {} : data); setCollegeProfile(Array.isArray(data) ? data[0] || {} : data);
} else { } else {
setCollegeProfile({}); setCollegeProfile({});
@ -112,7 +90,7 @@ export default function ScenarioContainer({
loadCollegeProfile(); loadCollegeProfile();
}, [localScenario]); }, [localScenario]);
// (B) Load milestones for localScenario (and each milestones impacts) // load milestones (and each milestone's impacts)
const fetchMilestones = useCallback(async () => { const fetchMilestones = useCallback(async () => {
if (!localScenario?.id) { if (!localScenario?.id) {
setMilestones([]); setMilestones([]);
@ -120,7 +98,9 @@ export default function ScenarioContainer({
return; return;
} }
try { try {
const res = await authFetch(`/api/premium/milestones?careerPathId=${localScenario.id}`); const res = await authFetch(
`/api/premium/milestones?careerPathId=${localScenario.id}`
);
if (!res.ok) { if (!res.ok) {
console.error('Failed fetching milestones. Status:', res.status); console.error('Failed fetching milestones. Status:', res.status);
return; return;
@ -132,7 +112,9 @@ export default function ScenarioContainer({
// For each milestone => fetch impacts // For each milestone => fetch impacts
const impactsData = {}; const impactsData = {};
for (const m of mils) { for (const m of mils) {
const iRes = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`); const iRes = await authFetch(
`/api/premium/milestone-impacts?milestone_id=${m.id}`
);
if (iRes.ok) { if (iRes.ok) {
const iData = await iRes.json(); const iData = await iRes.json();
impactsData[m.id] = iData.impacts || []; impactsData[m.id] = iData.impacts || [];
@ -141,7 +123,6 @@ export default function ScenarioContainer({
} }
} }
setImpactsByMilestone(impactsData); setImpactsByMilestone(impactsData);
} catch (err) { } catch (err) {
console.error('Error fetching milestones:', err); console.error('Error fetching milestones:', err);
} }
@ -152,17 +133,16 @@ export default function ScenarioContainer({
}, [fetchMilestones]); }, [fetchMilestones]);
/************************************************************* /*************************************************************
* 3) MERGE & RUN SIMULATION => projectionData * 3) Run Simulation
*************************************************************/ *************************************************************/
const [projectionData, setProjectionData] = useState([]); const [projectionData, setProjectionData] = useState([]);
const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null); const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null);
const [simulationYearsInput, setSimulationYearsInput] = useState('20'); const [simulationYearsInput, setSimulationYearsInput] = useState('20');
useEffect(() => { useEffect(() => {
// Wait until we have localScenario + collegeProfile + financialProfile
if (!financialProfile || !localScenario?.id || !collegeProfile) return; if (!financialProfile || !localScenario?.id || !collegeProfile) return;
// Gather all milestoneImpacts // gather all milestoneImpacts
let allImpacts = []; let allImpacts = [];
Object.keys(impactsByMilestone).forEach((mId) => { Object.keys(impactsByMilestone).forEach((mId) => {
allImpacts = allImpacts.concat(impactsByMilestone[mId]); allImpacts = allImpacts.concat(impactsByMilestone[mId]);
@ -170,19 +150,19 @@ export default function ScenarioContainer({
const simYears = parseInt(simulationYearsInput, 10) || 20; const simYears = parseInt(simulationYearsInput, 10) || 20;
// Build mergedProfile from scenario + user financial + college + milestone // Merge scenario + user financial + college + milestone
const mergedProfile = { const mergedProfile = {
// base user data
currentSalary: financialProfile.current_salary || 0, currentSalary: financialProfile.current_salary || 0,
monthlyExpenses: monthlyExpenses:
localScenario.planned_monthly_expenses ?? financialProfile.monthly_expenses ?? 0, localScenario.planned_monthly_expenses ??
financialProfile.monthly_expenses ??
0,
monthlyDebtPayments: monthlyDebtPayments:
localScenario.planned_monthly_debt_payments ?? financialProfile.monthly_debt_payments ?? 0, localScenario.planned_monthly_debt_payments ??
financialProfile.monthly_debt_payments ??
0,
retirementSavings: financialProfile.retirement_savings ?? 0, retirementSavings: financialProfile.retirement_savings ?? 0,
emergencySavings: financialProfile.emergency_fund ?? 0, emergencySavings: financialProfile.emergency_fund ?? 0,
monthlyRetirementContribution: monthlyRetirementContribution:
localScenario.planned_monthly_retirement_contribution ?? localScenario.planned_monthly_retirement_contribution ??
financialProfile.retirement_contribution ?? financialProfile.retirement_contribution ??
@ -191,7 +171,6 @@ export default function ScenarioContainer({
localScenario.planned_monthly_emergency_contribution ?? localScenario.planned_monthly_emergency_contribution ??
financialProfile.emergency_contribution ?? financialProfile.emergency_contribution ??
0, 0,
surplusEmergencyAllocation: surplusEmergencyAllocation:
localScenario.planned_surplus_emergency_pct ?? localScenario.planned_surplus_emergency_pct ??
financialProfile.extra_cash_emergency_pct ?? financialProfile.extra_cash_emergency_pct ??
@ -200,11 +179,12 @@ export default function ScenarioContainer({
localScenario.planned_surplus_retirement_pct ?? localScenario.planned_surplus_retirement_pct ??
financialProfile.extra_cash_retirement_pct ?? financialProfile.extra_cash_retirement_pct ??
50, 50,
additionalIncome: additionalIncome:
localScenario.planned_additional_income ?? financialProfile.additional_income ?? 0, localScenario.planned_additional_income ??
financialProfile.additional_income ??
0,
// college-related // college
studentLoanAmount: collegeProfile.existing_college_debt || 0, studentLoanAmount: collegeProfile.existing_college_debt || 0,
interestRate: collegeProfile.interest_rate || 5, interestRate: collegeProfile.interest_rate || 5,
loanTerm: collegeProfile.loan_term || 10, loanTerm: collegeProfile.loan_term || 10,
@ -213,33 +193,30 @@ export default function ScenarioContainer({
annualFinancialAid: collegeProfile.annual_financial_aid || 0, annualFinancialAid: collegeProfile.annual_financial_aid || 0,
calculatedTuition: collegeProfile.tuition || 0, calculatedTuition: collegeProfile.tuition || 0,
extraPayment: collegeProfile.extra_payment || 0, extraPayment: collegeProfile.extra_payment || 0,
inCollege: inCollege:
collegeProfile.college_enrollment_status === 'currently_enrolled' || collegeProfile.college_enrollment_status === 'currently_enrolled' ||
collegeProfile.college_enrollment_status === 'prospective_student', collegeProfile.college_enrollment_status === 'prospective_student',
gradDate: collegeProfile.expected_graduation || null, gradDate: collegeProfile.expected_graduation || null,
programType: collegeProfile.program_type || null, programType: collegeProfile.program_type || null,
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0, creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
hoursCompleted: collegeProfile.hours_completed || 0, hoursCompleted: collegeProfile.hours_completed || 0,
programLength: collegeProfile.program_length || 0, programLength: collegeProfile.program_length || 0,
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary || 0, expectedSalary:
collegeProfile.expected_salary || financialProfile.current_salary || 0,
// scenario date + simulation horizon // scenario horizon
startDate: localScenario.start_date || new Date().toISOString(), startDate: localScenario.start_date || new Date().toISOString(),
simulationYears: simYears, simulationYears: simYears,
// milestone impacts
milestoneImpacts: allImpacts milestoneImpacts: allImpacts
}; };
// Run the simulation const { projectionData, loanPaidOffMonth } =
const { projectionData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile); simulateFinancialProjection(mergedProfile);
// Optionally add a "cumulativeNetSavings" for display
let cumulative = mergedProfile.emergencySavings || 0; let cumulative = mergedProfile.emergencySavings || 0;
const finalData = projectionData.map((monthRow) => { const finalData = projectionData.map((monthRow) => {
cumulative += (monthRow.netSavings || 0); cumulative += monthRow.netSavings || 0;
return { ...monthRow, cumulativeNetSavings: cumulative }; return { ...monthRow, cumulativeNetSavings: cumulative };
}); });
@ -263,28 +240,41 @@ export default function ScenarioContainer({
} }
/************************************************************* /*************************************************************
* 4) CHART: build data, handle milestone markers * 4) Chart + Annotations
*************************************************************/ *************************************************************/
// x-axis labels (e.g. "2025-01", "2025-02", etc.)
const chartLabels = projectionData.map((p) => p.month); const chartLabels = projectionData.map((p) => p.month);
// dataset arrays
const netSavingsData = projectionData.map((p) => p.cumulativeNetSavings || 0); const netSavingsData = projectionData.map((p) => p.cumulativeNetSavings || 0);
const retData = projectionData.map((p) => p.retirementSavings || 0); const retData = projectionData.map((p) => p.retirementSavings || 0);
const loanData = projectionData.map((p) => p.loanBalance || 0); const loanData = projectionData.map((p) => p.loanBalance || 0);
// milestone markers => we find index by matching YYYY-MM const milestoneAnnotations = milestones
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) => { .map((m) => {
const xIndex = getLabelIndexForMilestone(m); if (!m.date) return null;
if (xIndex < 0) return null; const d = new Date(m.date);
return { x: xIndex, y: 0, milestoneObj: m }; if (isNaN(d)) return null;
const year = d.getUTCFullYear();
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
const short = `${year}-${month}`;
if (!chartLabels.includes(short)) return null;
return {
type: 'line',
xMin: short,
xMax: short,
borderColor: 'orange',
borderWidth: 2,
label: {
display: true,
content: m.title || 'Milestone',
color: 'orange',
position: 'end'
},
milestoneObj: m,
onClick: () => handleEditMilestone(m)
};
}) })
.filter(Boolean); .filter(Boolean);
@ -308,47 +298,24 @@ export default function ScenarioContainer({
data: loanData, data: loanData,
borderColor: 'red', borderColor: 'red',
fill: false fill: false
},
{
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 = { const chartOptions = {
responsive: true, responsive: true,
scales: { scales: {
x: { type: 'category' }, x: { type: 'category' },
y: { title: { display: true, text: 'Amount ($)' } } y: { title: { display: true, text: 'Amount ($)' } }
}, },
onClick: handleChartClick,
plugins: { plugins: {
annotation: {
annotations: milestoneAnnotations
},
tooltip: { tooltip: {
callbacks: { callbacks: {
label: function (context) { label: (context) =>
if (context.dataset.label === 'Milestones') { `${context.dataset.label}: ${context.formattedValue}`
const { milestoneObj } = context.raw;
return milestoneObj.title || '(Untitled milestone)';
}
return `${context.dataset.label}: ${context.formattedValue}`;
}
} }
} }
} }
@ -372,11 +339,18 @@ export default function ScenarioContainer({
// tasks // tasks
const [showTaskForm, setShowTaskForm] = useState(null); const [showTaskForm, setShowTaskForm] = useState(null);
const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' }); // We'll track a separate "editingTask" so we can fill in the form
const [editingTask, setEditingTask] = useState({
id: null,
title: '',
description: '',
due_date: ''
});
// copy wizard // copy wizard
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null); const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
// create new milestone
function handleNewMilestone() { function handleNewMilestone() {
setEditingMilestone(null); setEditingMilestone(null);
setNewMilestone({ setNewMilestone({
@ -399,7 +373,9 @@ export default function ScenarioContainer({
setImpactsToDelete([]); setImpactsToDelete([]);
try { try {
const impRes = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`); const impRes = await authFetch(
`/api/premium/milestone-impacts?milestone_id=${m.id}`
);
if (impRes.ok) { if (impRes.ok) {
const data = await impRes.json(); const data = await impRes.json();
const fetchedImpacts = data.impacts || []; const fetchedImpacts = data.impacts || [];
@ -431,7 +407,13 @@ export default function ScenarioContainer({
...prev, ...prev,
impacts: [ impacts: [
...prev.impacts, ...prev.impacts,
{ impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' } {
impact_type: 'ONE_TIME',
direction: 'subtract',
amount: 0,
start_date: '',
end_date: ''
}
] ]
})); }));
} }
@ -456,7 +438,6 @@ export default function ScenarioContainer({
}); });
} }
// create or update milestone
async function saveMilestone() { async function saveMilestone() {
if (!localScenario?.id) return; if (!localScenario?.id) return;
@ -466,14 +447,16 @@ export default function ScenarioContainer({
const method = editingMilestone ? 'PUT' : 'POST'; const method = editingMilestone ? 'PUT' : 'POST';
const payload = { const payload = {
milestone_type: 'Financial', // or "Career" if your scenario is about career milestone_type: 'Financial',
title: newMilestone.title, title: newMilestone.title,
description: newMilestone.description, description: newMilestone.description,
date: newMilestone.date, date: newMilestone.date,
career_path_id: localScenario.id, career_path_id: localScenario.id,
progress: newMilestone.progress, progress: newMilestone.progress,
status: newMilestone.progress >= 100 ? 'completed' : 'planned', status: newMilestone.progress >= 100 ? 'completed' : 'planned',
new_salary: newMilestone.newSalary ? parseFloat(newMilestone.newSalary) : null, new_salary: newMilestone.newSalary
? parseFloat(newMilestone.newSalary)
: null,
is_universal: newMilestone.isUniversal || 0 is_universal: newMilestone.isUniversal || 0
}; };
@ -490,9 +473,11 @@ export default function ScenarioContainer({
} }
const savedMilestone = await res.json(); const savedMilestone = await res.json();
// handle impacts (delete old, upsert new) // handle impacts
for (const id of impactsToDelete) { for (const id of impactsToDelete) {
await authFetch(`/api/premium/milestone-impacts/${id}`, { method: 'DELETE' }); await authFetch(`/api/premium/milestone-impacts/${id}`, {
method: 'DELETE'
});
} }
for (let i = 0; i < newMilestone.impacts.length; i++) { for (let i = 0; i < newMilestone.impacts.length; i++) {
const imp = newMilestone.impacts[i]; const imp = newMilestone.impacts[i];
@ -505,14 +490,12 @@ export default function ScenarioContainer({
end_date: imp.end_date || null end_date: imp.end_date || null
}; };
if (imp.id) { if (imp.id) {
// update existing
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, { await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(impPayload) body: JSON.stringify(impPayload)
}); });
} else { } else {
// create new
await authFetch('/api/premium/milestone-impacts', { await authFetch('/api/premium/milestone-impacts', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -521,7 +504,7 @@ export default function ScenarioContainer({
} }
} }
// done => re-fetch // re-fetch
await fetchMilestones(); await fetchMilestones();
// reset form // reset form
@ -543,7 +526,6 @@ export default function ScenarioContainer({
} }
} }
// delete milestone => if universal, ask user if removing from all or just this scenario
async function handleDeleteMilestone(m) { async function handleDeleteMilestone(m) {
if (m.is_universal === 1) { if (m.is_universal === 1) {
const userChoice = window.confirm( const userChoice = window.confirm(
@ -551,7 +533,9 @@ export default function ScenarioContainer({
); );
if (userChoice) { if (userChoice) {
try { try {
await authFetch(`/api/premium/milestones/${m.id}/all`, { method: 'DELETE' }); await authFetch(`/api/premium/milestones/${m.id}/all`, {
method: 'DELETE'
});
} catch (err) { } catch (err) {
console.error('Error removing universal milestone from all:', err); console.error('Error removing universal milestone from all:', err);
} }
@ -572,29 +556,86 @@ export default function ScenarioContainer({
} }
} }
// tasks /*************************************************************
async function addTask(milestoneId) { * 6) TASK CRUD
try { *************************************************************/
// This can handle both new and existing tasks
function handleAddTask(milestoneId) {
setShowTaskForm(milestoneId);
setEditingTask({
id: null,
title: '',
description: '',
due_date: ''
});
}
function handleEditTask(milestoneId, task) {
setShowTaskForm(milestoneId);
setEditingTask({
id: task.id,
title: task.title,
description: task.description || '',
due_date: task.due_date || ''
});
}
async function saveTask(milestoneId) {
if (!editingTask.title.trim()) {
alert('Task needs a title');
return;
}
const payload = { const payload = {
milestone_id: milestoneId, milestone_id: milestoneId,
title: newTask.title, title: editingTask.title,
description: newTask.description, description: editingTask.description,
due_date: newTask.due_date due_date: editingTask.due_date
}; };
const res = await authFetch('/api/premium/tasks', {
method: 'POST', // If we have editingTask.id => PUT, else => POST
try {
let url = '/api/premium/tasks';
let method = 'POST';
if (editingTask.id) {
url = `/api/premium/tasks/${editingTask.id}`;
method = 'PUT';
}
const res = await authFetch(url, {
method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (!res.ok) { if (!res.ok) {
alert('Failed to add task'); alert('Failed to save task');
return; return;
} }
await fetchMilestones(); await fetchMilestones();
setNewTask({ title: '', description: '', due_date: '' });
setShowTaskForm(null); setShowTaskForm(null);
setEditingTask({
id: null,
title: '',
description: '',
due_date: ''
});
} catch (err) { } catch (err) {
console.error('Error adding task:', err); console.error('Error saving task:', err);
}
}
async function deleteTask(taskId) {
if (!taskId) return;
try {
const res = await authFetch(`/api/premium/tasks/${taskId}`, {
method: 'DELETE'
});
if (!res.ok) {
alert('Failed to delete task');
return;
}
await fetchMilestones();
} catch (err) {
console.error('Error deleting task:', err);
} }
} }
@ -616,7 +657,7 @@ export default function ScenarioContainer({
} }
/************************************************************* /*************************************************************
* 6) COPY WIZARD * 7) COPY WIZARD
*************************************************************/ *************************************************************/
function CopyMilestoneWizard({ milestone, scenarios, onClose }) { function CopyMilestoneWizard({ milestone, scenarios, onClose }) {
const [selectedScenarios, setSelectedScenarios] = useState([]); const [selectedScenarios, setSelectedScenarios] = useState([]);
@ -641,7 +682,6 @@ export default function ScenarioContainer({
}); });
if (!res.ok) throw new Error('Failed to copy milestone'); if (!res.ok) throw new Error('Failed to copy milestone');
onClose(); onClose();
// Optionally reload
window.location.reload(); window.location.reload();
} catch (err) { } catch (err) {
console.error('Error copying milestone:', err); console.error('Error copying milestone:', err);
@ -673,7 +713,6 @@ export default function ScenarioContainer({
<p> <p>
Milestone: <strong>{milestone.title}</strong> Milestone: <strong>{milestone.title}</strong>
</p> </p>
{scenarios.map((s) => ( {scenarios.map((s) => (
<div key={s.id}> <div key={s.id}>
<label> <label>
@ -686,7 +725,6 @@ export default function ScenarioContainer({
</label> </label>
</div> </div>
))} ))}
<div style={{ marginTop: '1rem' }}> <div style={{ marginTop: '1rem' }}>
<Button onClick={onClose} style={{ marginRight: '0.5rem' }}> <Button onClick={onClose} style={{ marginRight: '0.5rem' }}>
Cancel Cancel
@ -699,11 +737,11 @@ export default function ScenarioContainer({
} }
/************************************************************* /*************************************************************
* 7) RENDER * 8) RENDER
*************************************************************/ *************************************************************/
return ( return (
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}> <div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
{/* (A) scenario dropdown */} {/* scenario dropdown */}
<select <select
style={{ marginBottom: '0.5rem', width: '100%' }} style={{ marginBottom: '0.5rem', width: '100%' }}
value={localScenario?.id || ''} value={localScenario?.id || ''}
@ -717,7 +755,6 @@ export default function ScenarioContainer({
))} ))}
</select> </select>
{/* If localScenario is selected => show UI */}
{localScenario && ( {localScenario && (
<> <>
<h4>{localScenario.scenario_title || localScenario.career_name}</h4> <h4>{localScenario.scenario_title || localScenario.career_name}</h4>
@ -727,7 +764,6 @@ export default function ScenarioContainer({
End: {localScenario.projected_end_date} End: {localScenario.projected_end_date}
</p> </p>
{/* Simulation length */}
<div style={{ margin: '0.5rem 0' }}> <div style={{ margin: '0.5rem 0' }}>
<label>Simulation Length (years): </label> <label>Simulation Length (years): </label>
<input <input
@ -744,8 +780,7 @@ export default function ScenarioContainer({
{projectionData.length > 0 && ( {projectionData.length > 0 && (
<div style={{ marginTop: '0.5rem' }}> <div style={{ marginTop: '0.5rem' }}>
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br />
<br />
<strong>Final Retirement:</strong>{' '} <strong>Final Retirement:</strong>{' '}
{Math.round( {Math.round(
projectionData[projectionData.length - 1].retirementSavings projectionData[projectionData.length - 1].retirementSavings
@ -757,9 +792,10 @@ export default function ScenarioContainer({
+ New Milestone + New Milestone
</Button> </Button>
{/* AI-Suggested Milestones */}
<AISuggestedMilestones <AISuggestedMilestones
career={localScenario.career_name || localScenario.scenario_title || ''} career={
localScenario.career_name || localScenario.scenario_title || ''
}
careerPathId={localScenario.id} careerPathId={localScenario.id}
authFetch={authFetch} authFetch={authFetch}
activeView="Financial" activeView="Financial"
@ -779,33 +815,46 @@ export default function ScenarioContainer({
</Button> </Button>
</div> </div>
{/* The inline form for milestone creation/edit */} {/* The milestone form */}
{showForm && ( {showForm && (
<div className="form border p-2 my-2"> <div className="form border p-2 my-2">
<h4>{editingMilestone ? 'Edit Milestone' : 'New Milestone'}</h4> <h4>
{editingMilestone ? 'Edit Milestone' : 'New Milestone'}
</h4>
<input <input
type="text" type="text"
placeholder="Title" placeholder="Title"
value={newMilestone.title} value={newMilestone.title}
onChange={(e) => setNewMilestone({ ...newMilestone, title: e.target.value })} onChange={(e) =>
setNewMilestone({ ...newMilestone, title: e.target.value })
}
/> />
<input <input
type="text" type="text"
placeholder="Description" placeholder="Description"
value={newMilestone.description} value={newMilestone.description}
onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })} onChange={(e) =>
setNewMilestone({
...newMilestone,
description: e.target.value
})
}
/> />
<input <input
type="date" type="date"
placeholder="Milestone Date" placeholder="Milestone Date"
value={newMilestone.date} value={newMilestone.date}
onChange={(e) => setNewMilestone({ ...newMilestone, date: e.target.value })} onChange={(e) =>
setNewMilestone({ ...newMilestone, date: e.target.value })
}
/> />
<input <input
type="number" type="number"
placeholder="Progress (%)" placeholder="Progress (%)"
value={newMilestone.progress === 0 ? '' : newMilestone.progress} value={
newMilestone.progress === 0 ? '' : newMilestone.progress
}
onChange={(e) => onChange={(e) =>
setNewMilestone((prev) => ({ setNewMilestone((prev) => ({
...prev, ...prev,
@ -815,19 +864,33 @@ export default function ScenarioContainer({
/> />
{/* Impacts sub-form */} {/* Impacts sub-form */}
<div style={{ border: '1px solid #ccc', padding: '1rem', marginTop: '1rem' }}> <div
style={{
border: '1px solid #ccc',
padding: '1rem',
marginTop: '1rem'
}}
>
<h5>Financial Impacts</h5> <h5>Financial Impacts</h5>
{newMilestone.impacts.map((imp, idx) => ( {newMilestone.impacts.map((imp, idx) => (
<div <div
key={idx} key={idx}
style={{ border: '1px solid #bbb', margin: '0.5rem', padding: '0.5rem' }} style={{
border: '1px solid #bbb',
margin: '0.5rem',
padding: '0.5rem'
}}
> >
{imp.id && <p style={{ fontSize: '0.8rem' }}>ID: {imp.id}</p>} {imp.id && (
<p style={{ fontSize: '0.8rem' }}>ID: {imp.id}</p>
)}
<div> <div>
<label>Type: </label> <label>Type: </label>
<select <select
value={imp.impact_type} value={imp.impact_type}
onChange={(e) => updateImpact(idx, 'impact_type', e.target.value)} onChange={(e) =>
updateImpact(idx, 'impact_type', e.target.value)
}
> >
<option value="ONE_TIME">One-Time</option> <option value="ONE_TIME">One-Time</option>
<option value="MONTHLY">Monthly</option> <option value="MONTHLY">Monthly</option>
@ -837,7 +900,9 @@ export default function ScenarioContainer({
<label>Direction: </label> <label>Direction: </label>
<select <select
value={imp.direction} value={imp.direction}
onChange={(e) => updateImpact(idx, 'direction', e.target.value)} onChange={(e) =>
updateImpact(idx, 'direction', e.target.value)
}
> >
<option value="add">Add (Income)</option> <option value="add">Add (Income)</option>
<option value="subtract">Subtract (Expense)</option> <option value="subtract">Subtract (Expense)</option>
@ -848,7 +913,9 @@ export default function ScenarioContainer({
<input <input
type="number" type="number"
value={imp.amount} value={imp.amount}
onChange={(e) => updateImpact(idx, 'amount', e.target.value)} onChange={(e) =>
updateImpact(idx, 'amount', e.target.value)
}
/> />
</div> </div>
<div> <div>
@ -856,7 +923,9 @@ export default function ScenarioContainer({
<input <input
type="date" type="date"
value={imp.start_date || ''} value={imp.start_date || ''}
onChange={(e) => updateImpact(idx, 'start_date', e.target.value)} onChange={(e) =>
updateImpact(idx, 'start_date', e.target.value)
}
/> />
</div> </div>
{imp.impact_type === 'MONTHLY' && ( {imp.impact_type === 'MONTHLY' && (
@ -865,7 +934,9 @@ export default function ScenarioContainer({
<input <input
type="date" type="date"
value={imp.end_date || ''} value={imp.end_date || ''}
onChange={(e) => updateImpact(idx, 'end_date', e.target.value)} onChange={(e) =>
updateImpact(idx, 'end_date', e.target.value)
}
/> />
</div> </div>
)} )}
@ -907,18 +978,24 @@ export default function ScenarioContainer({
</div> </div>
)} )}
{/* Render existing milestones + tasks + copy wizard, etc. */} {/* Render existing milestones */}
{milestones.map((m) => { {milestones.map((m) => {
// tasks
const tasks = m.tasks || []; const tasks = m.tasks || [];
return ( return (
<div <div
key={m.id} key={m.id}
style={{ border: '1px solid #ccc', marginTop: '1rem', padding: '0.5rem' }} style={{
border: '1px solid #ccc',
marginTop: '1rem',
padding: '0.5rem'
}}
> >
<h5>{m.title}</h5> <h5>{m.title}</h5>
{m.description && <p>{m.description}</p>} {m.description && <p>{m.description}</p>}
<p> <p>
<strong>Date:</strong> {m.date} <strong>Progress:</strong> {m.progress}% <strong>Date:</strong> {m.date} {' '}
<strong>Progress:</strong> {m.progress}%
</p> </p>
{/* tasks list */} {/* tasks list */}
@ -928,26 +1005,31 @@ export default function ScenarioContainer({
<li key={t.id}> <li key={t.id}>
<strong>{t.title}</strong> <strong>{t.title}</strong>
{t.description ? ` - ${t.description}` : ''} {t.description ? ` - ${t.description}` : ''}
{t.due_date ? ` (Due: ${t.due_date})` : ''} {t.due_date ? ` (Due: ${t.due_date})` : ''}{' '}
<Button
style={{ marginLeft: '0.5rem' }}
onClick={() => handleEditTask(m.id, t)}
>
Edit
</Button>
<Button
style={{ marginLeft: '0.5rem', color: 'red' }}
onClick={() => deleteTask(t.id)}
>
Delete
</Button>
</li> </li>
))} ))}
</ul> </ul>
)} )}
<Button <Button
onClick={() => { onClick={() => handleAddTask(m.id)}
setShowTaskForm(showTaskForm === m.id ? null : m.id); style={{ marginRight: '0.5rem' }}
setNewTask({ title: '', description: '', due_date: '' });
}}
> >
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'} + Task
</Button>
<Button
style={{ marginLeft: '0.5rem' }}
onClick={() => handleEditMilestone(m)}
>
Edit
</Button> </Button>
<Button onClick={() => handleEditMilestone(m)}>Edit</Button>
<Button <Button
style={{ marginLeft: '0.5rem' }} style={{ marginLeft: '0.5rem' }}
onClick={() => setCopyWizardMilestone(m)} onClick={() => setCopyWizardMilestone(m)}
@ -961,34 +1043,63 @@ export default function ScenarioContainer({
Delete Delete
</Button> </Button>
{/* Task form */} {/* If this is the milestone whose tasks we're editing => show the task form */}
{showTaskForm === m.id && ( {showTaskForm === m.id && (
<div style={{ marginTop: '0.5rem' }}> <div style={{ marginTop: '0.5rem', border: '1px solid #aaa', padding: '0.5rem' }}>
<h5>{editingTask.id ? 'Edit Task' : 'New Task'}</h5>
<input <input
type="text" type="text"
placeholder="Task Title" placeholder="Task Title"
value={newTask.title} value={editingTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })} onChange={(e) =>
setEditingTask({ ...editingTask, title: e.target.value })
}
/> />
<input <input
type="text" type="text"
placeholder="Task Description" placeholder="Task Description"
value={newTask.description} value={editingTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })} onChange={(e) =>
setEditingTask({
...editingTask,
description: e.target.value
})
}
/> />
<input <input
type="date" type="date"
value={newTask.due_date} value={editingTask.due_date}
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })} onChange={(e) =>
setEditingTask({
...editingTask,
due_date: e.target.value
})
}
/> />
<Button onClick={() => addTask(m.id)}>Save Task</Button> <Button onClick={() => saveTask(m.id)}>
{editingTask.id ? 'Update' : 'Add'} Task
</Button>
<Button
style={{ marginLeft: '0.5rem' }}
onClick={() => {
setShowTaskForm(null);
setEditingTask({
id: null,
title: '',
description: '',
due_date: ''
});
}}
>
Cancel
</Button>
</div> </div>
)} )}
</div> </div>
); );
})} })}
{/* (B) Show the scenario edit modal if needed */} {/* Scenario edit modal */}
<ScenarioEditModal <ScenarioEditModal
show={showEditModal} show={showEditModal}
onClose={() => setShowEditModal(false)} onClose={() => setShowEditModal(false)}
@ -996,7 +1107,7 @@ export default function ScenarioContainer({
collegeProfile={editingScenarioData.collegeProfile} collegeProfile={editingScenarioData.collegeProfile}
/> />
{/* The copy wizard if copying a milestone */} {/* Copy wizard */}
{copyWizardMilestone && ( {copyWizardMilestone && (
<CopyMilestoneWizard <CopyMilestoneWizard
milestone={copyWizardMilestone} milestone={copyWizardMilestone}

View File

@ -85,7 +85,6 @@ function calculateLoanPayment(principal, annualRate, years) {
***************************************************/ ***************************************************/
export function simulateFinancialProjection(userProfile) { export function simulateFinancialProjection(userProfile) {
// 1) Show userProfile at the start // 1) Show userProfile at the start
console.log("simulateFinancialProjection() called with userProfile:", userProfile);
/*************************************************** /***************************************************
* 1) DESTRUCTURE USER PROFILE * 1) DESTRUCTURE USER PROFILE
@ -349,29 +348,15 @@ export function simulateFinancialProjection(userProfile) {
if (nowExitingCollege) { if (nowExitingCollege) {
// recalc monthlyLoanPayment with the current loanBalance // recalc monthlyLoanPayment with the current loanBalance
monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, loanTerm); monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, loanTerm);
console.log(
`== Exiting deferral at monthIndex=${monthIndex}, ` +
`loanBalance=${loanBalance}, new monthlyLoanPayment=${monthlyLoanPayment}`
);
} }
// sum up all monthly expenses // sum up all monthly expenses
let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth + extraImpactsThisMonth; 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) { if (stillInCollege && loanDeferralUntilGraduation) {
// accumulate interest only // accumulate interest only
const interestForMonth = loanBalance * (interestRate / 100 / 12); const interestForMonth = loanBalance * (interestRate / 100 / 12);
loanBalance += interestForMonth; loanBalance += interestForMonth;
console.log(` (deferral) interest added=${interestForMonth.toFixed(2)}, loanBalAfter=${loanBalance.toFixed(2)}`);
} else { } else {
// pay principal // pay principal
if (loanBalance > 0) { if (loanBalance > 0) {
@ -380,11 +365,6 @@ export function simulateFinancialProjection(userProfile) {
const principalForMonth = Math.min(loanBalance, totalThisMonth - interestForMonth); const principalForMonth = Math.min(loanBalance, totalThisMonth - interestForMonth);
loanBalance = Math.max(loanBalance - principalForMonth, 0); loanBalance = Math.max(loanBalance - principalForMonth, 0);
totalMonthlyExpenses += totalThisMonth; totalMonthlyExpenses += totalThisMonth;
console.log(
` (payment) interest=${interestForMonth.toFixed(2)}, principal=${principalForMonth.toFixed(2)}, ` +
`loanBalAfter=${loanBalance.toFixed(2)}`
);
} }
} }
@ -467,9 +447,6 @@ export function simulateFinancialProjection(userProfile) {
loanPaidOffMonth = scenarioStartClamped.clone().add(maxMonths, 'months').format('YYYY-MM'); loanPaidOffMonth = scenarioStartClamped.clone().add(maxMonths, 'months').format('YYYY-MM');
} }
console.log("End of simulation: finalLoanBalance=", loanBalance.toFixed(2),
"loanPaidOffMonth=", loanPaidOffMonth);
return { return {
projectionData, projectionData,
loanPaidOffMonth, loanPaidOffMonth,

Binary file not shown.