UX/UI changes to MilestoneTracker/MilestoneTimeline.js and ScenarioContainer for Tasks.
This commit is contained in:
parent
ce53afb3d1
commit
ff7ab9f775
@ -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}
|
||||
|
@ -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 scenario’s 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);
|
||||
}}
|
||||
|
@ -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 user’s 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 milestone’s 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}
|
||||
|
@ -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}`
|
||||
);
|
||||
let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth + extraImpactsThisMonth;
|
||||
|
||||
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,
|
||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user