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 { Button } from './ui/button.js';
const today = new Date();
export default function MilestoneTimeline({
careerPathId,
authFetch,
@ -12,6 +12,7 @@ export default function MilestoneTimeline({
}) {
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
// We'll keep your existing milestone form state, tasks, copy wizard, etc.
const [newMilestone, setNewMilestone] = useState({
title: '',
description: '',
@ -25,16 +26,14 @@ export default function MilestoneTimeline({
const [showForm, setShowForm] = useState(false);
const [editingMilestone, setEditingMilestone] = useState(null);
// For tasks
const [showTaskForm, setShowTaskForm] = useState(null);
const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' });
// The copy wizard
const [scenarios, setScenarios] = useState([]);
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
// ------------------------------------------------------------------
// 1) HELPER: Add or remove an impact from newMilestone
// 1) Financial Impacts sub-form helpers (no change)
// ------------------------------------------------------------------
function addNewImpact() {
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 () => {
if (!careerPathId) return;
@ -101,7 +100,7 @@ export default function MilestoneTimeline({
}, [fetchMilestones]);
// ------------------------------------------------------------------
// 3) Load Scenarios for copy wizard
// 3) Load all scenarios for the copy wizard
// ------------------------------------------------------------------
useEffect(() => {
async function loadScenarios() {
@ -254,7 +253,7 @@ export default function MilestoneTimeline({
}
}
// Re-fetch or update local
// Re-fetch
await fetchMilestones();
// reset form
@ -306,7 +305,7 @@ export default function MilestoneTimeline({
const createdTask = await res.json();
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();
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 }) {
const [selectedScenarios, setSelectedScenarios] = useState([]);
@ -342,7 +341,6 @@ export default function MilestoneTimeline({
});
if (!res.ok) throw new Error('Failed to copy milestone');
// Brute force page refresh
window.location.reload();
onClose();
} catch (err) {
@ -357,7 +355,6 @@ export default function MilestoneTimeline({
<p>
Milestone: <strong>{milestone.title}</strong>
</p>
{scenarios.map((s) => (
<div key={s.id}>
<label>
@ -366,11 +363,10 @@ export default function MilestoneTimeline({
checked={selectedScenarios.includes(s.id)}
onChange={() => toggleScenario(s.id)}
/>
{s.career_name}
{s.career_name || s.scenario_title || '(untitled)'}
</label>
</div>
))}
<div style={{ marginTop: '1rem' }}>
<Button onClick={onClose} style={{ marginRight: '0.5rem' }}>
Cancel
@ -383,12 +379,12 @@ export default function MilestoneTimeline({
}
// ------------------------------------------------------------------
// 8) handleDelete => also brute force refresh
// 8) Delete Milestone
// ------------------------------------------------------------------
async function handleDeleteMilestone(m) {
if (m.is_universal === 1) {
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) {
// delete from all
@ -421,7 +417,7 @@ export default function MilestoneTimeline({
method: 'DELETE'
});
if (!delRes.ok) {
console.error('Failed to delete single milestone:', delRes.status);
console.error('Failed to delete milestone:', delRes.status);
}
} catch (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];
const lastDate = allMilestonesCombined.reduce((latest, m) => {
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);
}
// Combined array if you want to show them all in one list
const allMilestones = [...milestones.Career, ...milestones.Financial];
return (
<div className="milestone-timeline">
<div className="view-selector">
{['Career', 'Financial'].map((view) => (
<Button
key={view}
className={activeView === view ? 'active' : ''}
onClick={() => setActiveView(view)}
>
{view}
</Button>
))}
</div>
<div className="milestone-timeline" style={{ padding: '1rem' }}>
{/* “+ New Milestone” toggles the same form as before */}
<Button
onClick={() => {
if (showForm) {
@ -480,13 +453,15 @@ export default function MilestoneTimeline({
setShowForm(true);
}
}}
style={{ marginBottom: '0.5rem' }}
>
{showForm ? 'Cancel' : '+ New Milestone'}
</Button>
{/* CREATE/EDIT FORM */}
{/* If showForm => the same create/edit form */}
{showForm && (
<div className="form">
<div className="border p-2 my-2">
<h4>{editingMilestone ? 'Edit Milestone' : 'New Milestone'}</h4>
<input
type="text"
placeholder="Title"
@ -508,92 +483,82 @@ export default function MilestoneTimeline({
type="number"
placeholder="Progress (%)"
value={newMilestone.progress === 0 ? '' : newMilestone.progress}
onChange={(e) => {
const val = e.target.value === '' ? 0 : parseInt(e.target.value, 10);
setNewMilestone((prev) => ({ ...prev, progress: val }));
}}
onChange={(e) =>
setNewMilestone((prev) => ({
...prev,
progress: parseInt(e.target.value || '0', 10)
}))
}
/>
{/* If “Financial” => show impacts */}
{activeView === 'Financial' && (
<div>
<input
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) => (
<div key={idx} className="impact-item border p-2 my-2">
{imp.id && <p className="text-xs text-gray-500">Impact ID: {imp.id}</p>}
<div style={{ border: '1px solid #ccc', padding: '1rem', marginTop: '1rem' }}>
<h5>Financial Impacts</h5>
{newMilestone.impacts.map((imp, idx) => (
<div
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>
<label>Type: </label>
<select
value={imp.impact_type}
onChange={(e) => updateImpact(idx, 'impact_type', e.target.value)}
>
<option value="ONE_TIME">One-Time</option>
<option value="MONTHLY">Monthly</option>
</select>
</div>
<div>
<label>Direction: </label>
<select
value={imp.direction}
onChange={(e) => updateImpact(idx, 'direction', e.target.value)}
>
<option value="add">Add (Income)</option>
<option value="subtract">Subtract (Expense)</option>
</select>
</div>
<div>
<label>Amount: </label>
<input
type="number"
value={imp.amount}
onChange={(e) => updateImpact(idx, 'amount', e.target.value)}
/>
</div>
<div>
<label>Start Date: </label>
<input
type="date"
value={imp.start_date || ''}
onChange={(e) => updateImpact(idx, 'start_date', e.target.value)}
/>
</div>
{imp.impact_type === 'MONTHLY' && (
<div>
<label>Type: </label>
<select
value={imp.impact_type}
onChange={(e) => updateImpact(idx, 'impact_type', e.target.value)}
>
<option value="ONE_TIME">One-Time</option>
<option value="MONTHLY">Monthly</option>
</select>
</div>
<div>
<label>Direction: </label>
<select
value={imp.direction}
onChange={(e) => updateImpact(idx, 'direction', e.target.value)}
>
<option value="add">Add (Income)</option>
<option value="subtract">Subtract (Expense)</option>
</select>
</div>
<div>
<label>Amount: </label>
<input
type="number"
value={imp.amount}
onChange={(e) => updateImpact(idx, 'amount', e.target.value)}
/>
</div>
<div>
<label>Start Date:</label>
<label>End Date: </label>
<input
type="date"
value={imp.start_date || ''}
onChange={(e) => updateImpact(idx, 'start_date', e.target.value)}
value={imp.end_date || ''}
onChange={(e) => updateImpact(idx, 'end_date', e.target.value)}
/>
</div>
{imp.impact_type === 'MONTHLY' && (
<div>
<label>End Date (blank if indefinite): </label>
<input
type="date"
value={imp.end_date || ''}
onChange={(e) => updateImpact(idx, 'end_date', e.target.value || '')}
/>
</div>
)}
<Button className="text-red-500 mt-2" onClick={() => removeImpact(idx)}>
Remove Impact
</Button>
</div>
))}
<Button onClick={addNewImpact} className="bg-gray-200 px-2 py-1 mt-2">
+ Add Impact
</Button>
</div>
)}
<Button
style={{ marginLeft: '0.5rem', color: 'red' }}
onClick={() => removeImpact(idx)}
>
Remove
</Button>
</div>
))}
<Button onClick={addNewImpact}>+ Add Impact</Button>
</div>
)}
{/* universal checkbox */}
<div style={{ marginTop: '1rem' }}>
<label>
<input
@ -605,101 +570,104 @@ export default function MilestoneTimeline({
isUniversal: e.target.checked ? 1 : 0
}))
}
/>
{' '}Apply this milestone to all scenarios?
/>{' '}
Apply this milestone to all scenarios
</label>
</div>
<Button onClick={saveMilestone} style={{ marginTop: '1rem' }}>
{editingMilestone ? 'Update' : 'Add'} Milestone
</Button>
<div style={{ marginTop: '1rem' }}>
<Button onClick={saveMilestone}>
{editingMilestone ? 'Update' : 'Add'} Milestone
</Button>
</div>
</div>
)}
{/* TIMELINE VISUAL */}
<div className="milestone-timeline-container">
<div className="milestone-timeline-line" />
{milestones[activeView].map((m) => {
const leftPos = calcPosition(m.date);
{/* *** REPLACEMENT FOR THE OLD “TIMELINE VISUAL” *** */}
{/* Instead of a horizontal timeline, we list them in a simple vertical list. */}
{Object.keys(milestones).map((typeKey) =>
milestones[typeKey].map((m) => {
const tasks = m.tasks || [];
return (
<div
key={m.id}
className="milestone-timeline-post"
style={{ left: `${leftPos}%` }}
style={{ border: '1px solid #ccc', marginTop: '1rem', padding: '0.5rem' }}
>
<div className="milestone-timeline-dot" onClick={() => handleEditMilestone(m)} />
<div className="milestone-content">
<div className="title">{m.title}</div>
{m.description && <p>{m.description}</p>}
<h5>{m.title}</h5>
{m.description && <p>{m.description}</p>}
<p>
<strong>Date:</strong> {m.date} <strong>Progress:</strong> {m.progress}%
</p>
<div className="progress-bar">
<div className="progress" style={{ width: `${m.progress}%` }} />
</div>
<div className="date">{m.date}</div>
{/* tasks list */}
{tasks.length > 0 && (
<ul>
{tasks.map((t) => (
<li key={t.id}>
<strong>{t.title}</strong>
{t.description ? ` - ${t.description}` : ''}
{t.due_date ? ` (Due: ${t.due_date})` : ''}{' '}
{/* If you'd like to add “Edit”/“Delete” for tasks, replicate scenario container logic */}
</li>
))}
</ul>
)}
{/* Tasks */}
{m.tasks && m.tasks.length > 0 && (
<ul>
{m.tasks.map((t) => (
<li key={t.id}>
<strong>{t.title}</strong>
{t.description ? ` - ${t.description}` : ''}
{t.due_date ? ` (Due: ${t.due_date})` : ''}
</li>
))}
</ul>
)}
<Button
onClick={() => {
setShowTaskForm(showTaskForm === m.id ? null : m.id);
setNewTask({ title: '', description: '', due_date: '' });
}}
style={{ marginRight: '0.5rem' }}
>
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
</Button>
<Button onClick={() => handleEditMilestone(m)}>Edit</Button>
<Button
style={{ marginLeft: '0.5rem' }}
onClick={() => setCopyWizardMilestone(m)}
>
Copy
</Button>
<Button
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
onClick={() => handleDeleteMilestone(m)}
>
Delete
</Button>
<Button
onClick={() => {
setShowTaskForm(showTaskForm === m.id ? null : m.id);
setNewTask({ title: '', description: '', due_date: '' });
}}
{/* The "Add Task" form if showTaskForm === m.id */}
{showTaskForm === m.id && (
<div
style={{ marginTop: '0.5rem', border: '1px solid #aaa', padding: '0.5rem' }}
>
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
</Button>
<div style={{ marginTop: '0.5rem' }}>
<Button onClick={() => handleEditMilestone(m)}>Edit</Button>
<Button style={{ marginLeft: '0.5rem' }} onClick={() => setCopyWizardMilestone(m)}>
Copy
</Button>
<Button
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
onClick={() => handleDeleteMilestone(m)}
>
Delete
</Button>
<h5>New Task</h5>
<input
type="text"
placeholder="Task Title"
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
/>
<input
type="text"
placeholder="Task Description"
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
/>
<input
type="date"
value={newTask.due_date}
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
/>
<Button onClick={() => addTask(m.id)}>Save Task</Button>
</div>
{showTaskForm === m.id && (
<div className="task-form" style={{ marginTop: '0.5rem' }}>
<input
type="text"
placeholder="Task Title"
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
/>
<input
type="text"
placeholder="Task Description"
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
/>
<input
type="date"
value={newTask.due_date}
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
/>
<Button onClick={() => addTask(m.id)}>Save Task</Button>
</div>
)}
</div>
)}
</div>
);
})}
</div>
})
)}
{/* Copy wizard if open */}
{copyWizardMilestone && (
<CopyMilestoneWizard
milestone={copyWizardMilestone}

View File

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

View File

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

View File

@ -85,7 +85,6 @@ function calculateLoanPayment(principal, annualRate, years) {
***************************************************/
export function simulateFinancialProjection(userProfile) {
// 1) Show userProfile at the start
console.log("simulateFinancialProjection() called with userProfile:", userProfile);
/***************************************************
* 1) DESTRUCTURE USER PROFILE
@ -349,29 +348,15 @@ export function simulateFinancialProjection(userProfile) {
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 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) {
@ -380,11 +365,6 @@ export function simulateFinancialProjection(userProfile) {
const principalForMonth = Math.min(loanBalance, totalThisMonth - interestForMonth);
loanBalance = Math.max(loanBalance - principalForMonth, 0);
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');
}
console.log("End of simulation: finalLoanBalance=", loanBalance.toFixed(2),
"loanPaidOffMonth=", loanPaidOffMonth);
return {
projectionData,
loanPaidOffMonth,

Binary file not shown.