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 React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
export default function MilestoneTimeline({
|
export default function MilestoneTimeline({
|
||||||
careerPathId,
|
careerPathId,
|
||||||
authFetch,
|
authFetch,
|
||||||
@ -12,6 +12,7 @@ export default function MilestoneTimeline({
|
|||||||
}) {
|
}) {
|
||||||
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
|
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
|
||||||
|
|
||||||
|
// We'll keep your existing milestone form state, tasks, copy wizard, etc.
|
||||||
const [newMilestone, setNewMilestone] = useState({
|
const [newMilestone, setNewMilestone] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
@ -25,16 +26,14 @@ export default function MilestoneTimeline({
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingMilestone, setEditingMilestone] = useState(null);
|
const [editingMilestone, setEditingMilestone] = useState(null);
|
||||||
|
|
||||||
// For tasks
|
|
||||||
const [showTaskForm, setShowTaskForm] = useState(null);
|
const [showTaskForm, setShowTaskForm] = useState(null);
|
||||||
const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' });
|
const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' });
|
||||||
|
|
||||||
// The copy wizard
|
|
||||||
const [scenarios, setScenarios] = useState([]);
|
const [scenarios, setScenarios] = useState([]);
|
||||||
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 1) HELPER: Add or remove an impact from newMilestone
|
// 1) Financial Impacts sub-form helpers (no change)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
function addNewImpact() {
|
function addNewImpact() {
|
||||||
setNewMilestone((prev) => ({
|
setNewMilestone((prev) => ({
|
||||||
@ -67,7 +66,7 @@ export default function MilestoneTimeline({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 2) fetchMilestones => local state
|
// 2) Fetch milestones => store in "milestones[Career]" / "milestones[Financial]"
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
const fetchMilestones = useCallback(async () => {
|
const fetchMilestones = useCallback(async () => {
|
||||||
if (!careerPathId) return;
|
if (!careerPathId) return;
|
||||||
@ -101,7 +100,7 @@ export default function MilestoneTimeline({
|
|||||||
}, [fetchMilestones]);
|
}, [fetchMilestones]);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 3) Load Scenarios for copy wizard
|
// 3) Load all scenarios for the copy wizard
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadScenarios() {
|
async function loadScenarios() {
|
||||||
@ -254,7 +253,7 @@ export default function MilestoneTimeline({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-fetch or update local
|
// Re-fetch
|
||||||
await fetchMilestones();
|
await fetchMilestones();
|
||||||
|
|
||||||
// reset form
|
// reset form
|
||||||
@ -306,7 +305,7 @@ export default function MilestoneTimeline({
|
|||||||
const createdTask = await res.json();
|
const createdTask = await res.json();
|
||||||
console.log('Task created:', createdTask);
|
console.log('Task created:', createdTask);
|
||||||
|
|
||||||
// Re-fetch so the timeline shows the new task
|
// Re-fetch so the list shows the new task
|
||||||
await fetchMilestones();
|
await fetchMilestones();
|
||||||
|
|
||||||
setNewTask({ title: '', description: '', due_date: '' });
|
setNewTask({ title: '', description: '', due_date: '' });
|
||||||
@ -317,7 +316,7 @@ export default function MilestoneTimeline({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 7) Copy Wizard => now with brute force refresh
|
// 7) Copy Wizard
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
function CopyMilestoneWizard({ milestone, scenarios, onClose, authFetch }) {
|
function CopyMilestoneWizard({ milestone, scenarios, onClose, authFetch }) {
|
||||||
const [selectedScenarios, setSelectedScenarios] = useState([]);
|
const [selectedScenarios, setSelectedScenarios] = useState([]);
|
||||||
@ -342,7 +341,6 @@ export default function MilestoneTimeline({
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to copy milestone');
|
if (!res.ok) throw new Error('Failed to copy milestone');
|
||||||
|
|
||||||
// Brute force page refresh
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -357,7 +355,6 @@ export default function MilestoneTimeline({
|
|||||||
<p>
|
<p>
|
||||||
Milestone: <strong>{milestone.title}</strong>
|
Milestone: <strong>{milestone.title}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{scenarios.map((s) => (
|
{scenarios.map((s) => (
|
||||||
<div key={s.id}>
|
<div key={s.id}>
|
||||||
<label>
|
<label>
|
||||||
@ -366,11 +363,10 @@ export default function MilestoneTimeline({
|
|||||||
checked={selectedScenarios.includes(s.id)}
|
checked={selectedScenarios.includes(s.id)}
|
||||||
onChange={() => toggleScenario(s.id)}
|
onChange={() => toggleScenario(s.id)}
|
||||||
/>
|
/>
|
||||||
{s.career_name}
|
{s.career_name || s.scenario_title || '(untitled)'}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div style={{ marginTop: '1rem' }}>
|
<div style={{ marginTop: '1rem' }}>
|
||||||
<Button onClick={onClose} style={{ marginRight: '0.5rem' }}>
|
<Button onClick={onClose} style={{ marginRight: '0.5rem' }}>
|
||||||
Cancel
|
Cancel
|
||||||
@ -383,12 +379,12 @@ export default function MilestoneTimeline({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 8) handleDelete => also brute force refresh
|
// 8) Delete Milestone
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
async function handleDeleteMilestone(m) {
|
async function handleDeleteMilestone(m) {
|
||||||
if (m.is_universal === 1) {
|
if (m.is_universal === 1) {
|
||||||
const userChoice = window.confirm(
|
const userChoice = window.confirm(
|
||||||
'This milestone is universal. OK => remove from ALL scenarios, Cancel => remove only from this scenario.'
|
'This milestone is universal. OK => remove from ALL scenarios, Cancel => only remove from this scenario.'
|
||||||
);
|
);
|
||||||
if (userChoice) {
|
if (userChoice) {
|
||||||
// delete from all
|
// delete from all
|
||||||
@ -421,7 +417,7 @@ export default function MilestoneTimeline({
|
|||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
if (!delRes.ok) {
|
if (!delRes.ok) {
|
||||||
console.error('Failed to delete single milestone:', delRes.status);
|
console.error('Failed to delete milestone:', delRes.status);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error removing milestone from scenario:', err);
|
console.error('Error removing milestone from scenario:', err);
|
||||||
@ -429,37 +425,14 @@ export default function MilestoneTimeline({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 9) Render the timeline
|
// 9) RENDER: remove the "timeline" code, show a list instead
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
const allMilestonesCombined = [...milestones.Career, ...milestones.Financial];
|
// Combined array if you want to show them all in one list
|
||||||
const lastDate = allMilestonesCombined.reduce((latest, m) => {
|
const allMilestones = [...milestones.Career, ...milestones.Financial];
|
||||||
const d = new Date(m.date);
|
|
||||||
return d > latest ? d : latest;
|
|
||||||
}, today);
|
|
||||||
|
|
||||||
function calcPosition(dateString) {
|
|
||||||
const start = today.getTime();
|
|
||||||
const end = lastDate.getTime();
|
|
||||||
const dateVal = new Date(dateString).getTime();
|
|
||||||
if (end === start) return 0;
|
|
||||||
const ratio = (dateVal - start) / (end - start);
|
|
||||||
return Math.min(Math.max(ratio * 100, 0), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="milestone-timeline">
|
<div className="milestone-timeline" style={{ padding: '1rem' }}>
|
||||||
<div className="view-selector">
|
{/* “+ New Milestone” toggles the same form as before */}
|
||||||
{['Career', 'Financial'].map((view) => (
|
|
||||||
<Button
|
|
||||||
key={view}
|
|
||||||
className={activeView === view ? 'active' : ''}
|
|
||||||
onClick={() => setActiveView(view)}
|
|
||||||
>
|
|
||||||
{view}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (showForm) {
|
if (showForm) {
|
||||||
@ -480,13 +453,15 @@ export default function MilestoneTimeline({
|
|||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
style={{ marginBottom: '0.5rem' }}
|
||||||
>
|
>
|
||||||
{showForm ? 'Cancel' : '+ New Milestone'}
|
{showForm ? 'Cancel' : '+ New Milestone'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* CREATE/EDIT FORM */}
|
{/* If showForm => the same create/edit form */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="form">
|
<div className="border p-2 my-2">
|
||||||
|
<h4>{editingMilestone ? 'Edit Milestone' : 'New Milestone'}</h4>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
@ -508,92 +483,82 @@ export default function MilestoneTimeline({
|
|||||||
type="number"
|
type="number"
|
||||||
placeholder="Progress (%)"
|
placeholder="Progress (%)"
|
||||||
value={newMilestone.progress === 0 ? '' : newMilestone.progress}
|
value={newMilestone.progress === 0 ? '' : newMilestone.progress}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const val = e.target.value === '' ? 0 : parseInt(e.target.value, 10);
|
setNewMilestone((prev) => ({
|
||||||
setNewMilestone((prev) => ({ ...prev, progress: val }));
|
...prev,
|
||||||
}}
|
progress: parseInt(e.target.value || '0', 10)
|
||||||
|
}))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* If “Financial” => show impacts */}
|
||||||
{activeView === 'Financial' && (
|
{activeView === 'Financial' && (
|
||||||
<div>
|
<div style={{ border: '1px solid #ccc', padding: '1rem', marginTop: '1rem' }}>
|
||||||
<input
|
<h5>Financial Impacts</h5>
|
||||||
type="number"
|
{newMilestone.impacts.map((imp, idx) => (
|
||||||
placeholder="Full New Salary (e.g., 70000)"
|
<div
|
||||||
value={newMilestone.newSalary}
|
key={idx}
|
||||||
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })}
|
style={{ border: '1px solid #bbb', margin: '0.5rem', padding: '0.5rem' }}
|
||||||
/>
|
>
|
||||||
<p>Enter the full new salary after the milestone occurs.</p>
|
{imp.id && <p style={{ fontSize: '0.8rem' }}>ID: {imp.id}</p>}
|
||||||
|
<div>
|
||||||
<div className="impacts-section border p-2 mt-3">
|
<label>Type: </label>
|
||||||
<h4>Financial Impacts</h4>
|
<select
|
||||||
{newMilestone.impacts.map((imp, idx) => (
|
value={imp.impact_type}
|
||||||
<div key={idx} className="impact-item border p-2 my-2">
|
onChange={(e) => updateImpact(idx, 'impact_type', e.target.value)}
|
||||||
{imp.id && <p className="text-xs text-gray-500">Impact ID: {imp.id}</p>}
|
>
|
||||||
|
<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>
|
<div>
|
||||||
<label>Type: </label>
|
<label>End Date: </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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={imp.start_date || ''}
|
value={imp.end_date || ''}
|
||||||
onChange={(e) => updateImpact(idx, 'start_date', e.target.value)}
|
onChange={(e) => updateImpact(idx, 'end_date', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{imp.impact_type === 'MONTHLY' && (
|
<Button
|
||||||
<div>
|
style={{ marginLeft: '0.5rem', color: 'red' }}
|
||||||
<label>End Date (blank if indefinite): </label>
|
onClick={() => removeImpact(idx)}
|
||||||
<input
|
>
|
||||||
type="date"
|
Remove
|
||||||
value={imp.end_date || ''}
|
</Button>
|
||||||
onChange={(e) => updateImpact(idx, 'end_date', e.target.value || '')}
|
</div>
|
||||||
/>
|
))}
|
||||||
</div>
|
<Button onClick={addNewImpact}>+ Add Impact</Button>
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* universal checkbox */}
|
|
||||||
<div style={{ marginTop: '1rem' }}>
|
<div style={{ marginTop: '1rem' }}>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
@ -605,101 +570,104 @@ export default function MilestoneTimeline({
|
|||||||
isUniversal: e.target.checked ? 1 : 0
|
isUniversal: e.target.checked ? 1 : 0
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>{' '}
|
||||||
{' '}Apply this milestone to all scenarios?
|
Apply this milestone to all scenarios
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={saveMilestone} style={{ marginTop: '1rem' }}>
|
<div style={{ marginTop: '1rem' }}>
|
||||||
{editingMilestone ? 'Update' : 'Add'} Milestone
|
<Button onClick={saveMilestone}>
|
||||||
</Button>
|
{editingMilestone ? 'Update' : 'Add'} Milestone
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TIMELINE VISUAL */}
|
{/* *** REPLACEMENT FOR THE OLD “TIMELINE VISUAL” *** */}
|
||||||
<div className="milestone-timeline-container">
|
{/* Instead of a horizontal timeline, we list them in a simple vertical list. */}
|
||||||
<div className="milestone-timeline-line" />
|
{Object.keys(milestones).map((typeKey) =>
|
||||||
{milestones[activeView].map((m) => {
|
milestones[typeKey].map((m) => {
|
||||||
const leftPos = calcPosition(m.date);
|
const tasks = m.tasks || [];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="milestone-timeline-post"
|
style={{ border: '1px solid #ccc', marginTop: '1rem', padding: '0.5rem' }}
|
||||||
style={{ left: `${leftPos}%` }}
|
|
||||||
>
|
>
|
||||||
<div className="milestone-timeline-dot" onClick={() => handleEditMilestone(m)} />
|
<h5>{m.title}</h5>
|
||||||
<div className="milestone-content">
|
{m.description && <p>{m.description}</p>}
|
||||||
<div className="title">{m.title}</div>
|
<p>
|
||||||
{m.description && <p>{m.description}</p>}
|
<strong>Date:</strong> {m.date} — <strong>Progress:</strong> {m.progress}%
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="progress-bar">
|
{/* tasks list */}
|
||||||
<div className="progress" style={{ width: `${m.progress}%` }} />
|
{tasks.length > 0 && (
|
||||||
</div>
|
<ul>
|
||||||
<div className="date">{m.date}</div>
|
{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 */}
|
<Button
|
||||||
{m.tasks && m.tasks.length > 0 && (
|
onClick={() => {
|
||||||
<ul>
|
setShowTaskForm(showTaskForm === m.id ? null : m.id);
|
||||||
{m.tasks.map((t) => (
|
setNewTask({ title: '', description: '', due_date: '' });
|
||||||
<li key={t.id}>
|
}}
|
||||||
<strong>{t.title}</strong>
|
style={{ marginRight: '0.5rem' }}
|
||||||
{t.description ? ` - ${t.description}` : ''}
|
>
|
||||||
{t.due_date ? ` (Due: ${t.due_date})` : ''}
|
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
|
||||||
</li>
|
</Button>
|
||||||
))}
|
<Button onClick={() => handleEditMilestone(m)}>Edit</Button>
|
||||||
</ul>
|
<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
|
{/* The "Add Task" form if showTaskForm === m.id */}
|
||||||
onClick={() => {
|
{showTaskForm === m.id && (
|
||||||
setShowTaskForm(showTaskForm === m.id ? null : m.id);
|
<div
|
||||||
setNewTask({ title: '', description: '', due_date: '' });
|
style={{ marginTop: '0.5rem', border: '1px solid #aaa', padding: '0.5rem' }}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
|
<h5>New Task</h5>
|
||||||
</Button>
|
<input
|
||||||
|
type="text"
|
||||||
<div style={{ marginTop: '0.5rem' }}>
|
placeholder="Task Title"
|
||||||
<Button onClick={() => handleEditMilestone(m)}>Edit</Button>
|
value={newTask.title}
|
||||||
<Button style={{ marginLeft: '0.5rem' }} onClick={() => setCopyWizardMilestone(m)}>
|
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
||||||
Copy
|
/>
|
||||||
</Button>
|
<input
|
||||||
<Button
|
type="text"
|
||||||
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
|
placeholder="Task Description"
|
||||||
onClick={() => handleDeleteMilestone(m)}
|
value={newTask.description}
|
||||||
>
|
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||||
Delete
|
/>
|
||||||
</Button>
|
<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>
|
||||||
|
)}
|
||||||
{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>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* Copy wizard if open */}
|
||||||
{copyWizardMilestone && (
|
{copyWizardMilestone && (
|
||||||
<CopyMilestoneWizard
|
<CopyMilestoneWizard
|
||||||
milestone={copyWizardMilestone}
|
milestone={copyWizardMilestone}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// src/components/MilestoneTracker.js
|
// src/components/MilestoneTracker.js
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
@ -18,13 +18,18 @@ import { Button } from './ui/button.js';
|
|||||||
import authFetch from '../utils/authFetch.js';
|
import authFetch from '../utils/authFetch.js';
|
||||||
import CareerSelectDropdown from './CareerSelectDropdown.js';
|
import CareerSelectDropdown from './CareerSelectDropdown.js';
|
||||||
import CareerSearch from './CareerSearch.js';
|
import CareerSearch from './CareerSearch.js';
|
||||||
|
|
||||||
|
// Keep MilestoneTimeline for +Add Milestone & tasks CRUD
|
||||||
import MilestoneTimeline from './MilestoneTimeline.js';
|
import MilestoneTimeline from './MilestoneTimeline.js';
|
||||||
|
|
||||||
import AISuggestedMilestones from './AISuggestedMilestones.js';
|
import AISuggestedMilestones from './AISuggestedMilestones.js';
|
||||||
import ScenarioEditModal from './ScenarioEditModal.js';
|
import ScenarioEditModal from './ScenarioEditModal.js';
|
||||||
import './MilestoneTracker.css'; // keeps your local styles
|
|
||||||
import './MilestoneTimeline.css'; // keeps your local styles
|
import './MilestoneTracker.css';
|
||||||
|
import './MilestoneTimeline.css';
|
||||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||||||
|
|
||||||
|
// Register Chart + annotation plugin
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
LineElement,
|
LineElement,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@ -47,29 +52,23 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
|
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
|
||||||
const [careerPathId, setCareerPathId] = useState(null);
|
const [careerPathId, setCareerPathId] = useState(null);
|
||||||
const [existingCareerPaths, setExistingCareerPaths] = useState([]);
|
const [existingCareerPaths, setExistingCareerPaths] = useState([]);
|
||||||
const [activeView, setActiveView] = useState("Career");
|
const [activeView, setActiveView] = useState('Career');
|
||||||
|
|
||||||
// Real user snapshot
|
|
||||||
const [financialProfile, setFinancialProfile] = useState(null);
|
const [financialProfile, setFinancialProfile] = useState(null);
|
||||||
|
|
||||||
// Scenario row (with planned_* overrides)
|
|
||||||
const [scenarioRow, setScenarioRow] = useState(null);
|
const [scenarioRow, setScenarioRow] = useState(null);
|
||||||
|
|
||||||
// scenario's collegeProfile row
|
|
||||||
const [collegeProfile, setCollegeProfile] = useState(null);
|
const [collegeProfile, setCollegeProfile] = useState(null);
|
||||||
|
|
||||||
// Simulation results
|
// We will store the scenario’s milestones in state so we can build annotation lines
|
||||||
|
const [scenarioMilestones, setScenarioMilestones] = useState([]);
|
||||||
|
|
||||||
const [projectionData, setProjectionData] = useState([]);
|
const [projectionData, setProjectionData] = useState([]);
|
||||||
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
||||||
|
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
||||||
// Possibly let user type the simulation length
|
|
||||||
const [simulationYearsInput, setSimulationYearsInput] = useState("20");
|
|
||||||
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
|
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
|
||||||
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
|
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
|
||||||
|
|
||||||
// Possibly loaded from location.state
|
|
||||||
const {
|
const {
|
||||||
projectionData: initialProjectionData = [],
|
projectionData: initialProjectionData = [],
|
||||||
loanPayoffMonth: initialLoanPayoffMonth = null
|
loanPayoffMonth: initialLoanPayoffMonth = null
|
||||||
@ -90,7 +89,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
setSelectedCareer(fromPopout);
|
setSelectedCareer(fromPopout);
|
||||||
setCareerPathId(fromPopout.career_path_id);
|
setCareerPathId(fromPopout.career_path_id);
|
||||||
} else if (!selectedCareer) {
|
} else if (!selectedCareer) {
|
||||||
// fallback to latest
|
// fallback: fetch the 'latest' scenario
|
||||||
const latest = await authFetch(`${apiURL}/premium/career-profile/latest`);
|
const latest = await authFetch(`${apiURL}/premium/career-profile/latest`);
|
||||||
if (latest && latest.ok) {
|
if (latest && latest.ok) {
|
||||||
const latestData = await latest.json();
|
const latestData = await latest.json();
|
||||||
@ -121,6 +120,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
if (!careerPathId) {
|
if (!careerPathId) {
|
||||||
setScenarioRow(null);
|
setScenarioRow(null);
|
||||||
setCollegeProfile(null);
|
setCollegeProfile(null);
|
||||||
|
setScenarioMilestones([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,7 +136,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCollege() {
|
async function fetchCollege() {
|
||||||
const colRes = await authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`);
|
const colRes = await authFetch(
|
||||||
|
`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`
|
||||||
|
);
|
||||||
if (!colRes?.ok) {
|
if (!colRes?.ok) {
|
||||||
setCollegeProfile(null);
|
setCollegeProfile(null);
|
||||||
return;
|
return;
|
||||||
@ -150,28 +152,32 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
}, [careerPathId, apiURL]);
|
}, [careerPathId, apiURL]);
|
||||||
|
|
||||||
// --------------------------------------------------
|
// --------------------------------------------------
|
||||||
// 3) Once we have (financialProfile, scenarioRow, collegeProfile), run initial simulation
|
// 3) Once scenarioRow + collegeProfile + financialProfile => run simulation
|
||||||
|
// + fetch milestones for annotation lines
|
||||||
// --------------------------------------------------
|
// --------------------------------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!financialProfile || !scenarioRow || !collegeProfile) return;
|
if (!financialProfile || !scenarioRow || !collegeProfile) return;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
// 1) load milestones for scenario
|
// fetch milestones for this scenario
|
||||||
const milRes = await authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`);
|
const milRes = await authFetch(
|
||||||
|
`${apiURL}/premium/milestones?careerPathId=${careerPathId}`
|
||||||
|
);
|
||||||
if (!milRes.ok) {
|
if (!milRes.ok) {
|
||||||
console.error('Failed to fetch initial milestones for scenario', careerPathId);
|
console.error('Failed to fetch milestones for scenario', careerPathId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const milestonesData = await milRes.json();
|
const milestonesData = await milRes.json();
|
||||||
const allMilestones = milestonesData.milestones || [];
|
const allMilestones = milestonesData.milestones || [];
|
||||||
|
setScenarioMilestones(allMilestones); // store them for annotation lines
|
||||||
|
|
||||||
// 2) fetch impacts for each milestone
|
// fetch impacts for each
|
||||||
const impactPromises = allMilestones.map(m =>
|
const impactPromises = allMilestones.map((m) =>
|
||||||
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
|
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
|
||||||
.then(r => r.ok ? r.json() : null)
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.then(data => data?.impacts || [])
|
.then((data) => data?.impacts || [])
|
||||||
.catch(err => {
|
.catch((err) => {
|
||||||
console.warn('Error fetching impacts for milestone', m.id, err);
|
console.warn('Error fetching impacts for milestone', m.id, err);
|
||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
@ -181,9 +187,11 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
...m,
|
...m,
|
||||||
impacts: impactsForEach[i] || []
|
impacts: impactsForEach[i] || []
|
||||||
}));
|
}));
|
||||||
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts);
|
|
||||||
|
|
||||||
// 3) Build the merged profile
|
// flatten all
|
||||||
|
const allImpacts = milestonesWithImpacts.flatMap((m) => m.impacts);
|
||||||
|
|
||||||
|
// mergedProfile
|
||||||
const mergedProfile = buildMergedProfile(
|
const mergedProfile = buildMergedProfile(
|
||||||
financialProfile,
|
financialProfile,
|
||||||
scenarioRow,
|
scenarioRow,
|
||||||
@ -192,36 +200,25 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
simulationYears
|
simulationYears
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4) run the simulation
|
|
||||||
const { projectionData: pData, loanPaidOffMonth: payoff } =
|
const { projectionData: pData, loanPaidOffMonth: payoff } =
|
||||||
simulateFinancialProjection(mergedProfile);
|
simulateFinancialProjection(mergedProfile);
|
||||||
|
|
||||||
// 5) If you track cumulative net
|
|
||||||
let cumu = mergedProfile.emergencySavings || 0;
|
let cumu = mergedProfile.emergencySavings || 0;
|
||||||
const finalData = pData.map(mo => {
|
const finalData = pData.map((mo) => {
|
||||||
cumu += (mo.netSavings || 0);
|
cumu += mo.netSavings || 0;
|
||||||
return { ...mo, cumulativeNetSavings: cumu };
|
return { ...mo, cumulativeNetSavings: cumu };
|
||||||
});
|
});
|
||||||
|
|
||||||
setProjectionData(finalData);
|
setProjectionData(finalData);
|
||||||
setLoanPayoffMonth(payoff);
|
setLoanPayoffMonth(payoff);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error in initial scenario simulation:', err);
|
console.error('Error in scenario simulation:', err);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [
|
}, [financialProfile, scenarioRow, collegeProfile, careerPathId, apiURL, simulationYears]);
|
||||||
financialProfile,
|
|
||||||
scenarioRow,
|
|
||||||
collegeProfile,
|
|
||||||
simulationYears,
|
|
||||||
careerPathId,
|
|
||||||
apiURL
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Merges the real snapshot w/ scenario overrides + milestones
|
|
||||||
function buildMergedProfile(finProf, scenRow, colProf, milestoneImpacts, simYears) {
|
function buildMergedProfile(finProf, scenRow, colProf, milestoneImpacts, simYears) {
|
||||||
return {
|
return {
|
||||||
// Real snapshot fallback
|
|
||||||
currentSalary: finProf.current_salary || 0,
|
currentSalary: finProf.current_salary || 0,
|
||||||
monthlyExpenses:
|
monthlyExpenses:
|
||||||
scenRow.planned_monthly_expenses ?? finProf.monthly_expenses ?? 0,
|
scenRow.planned_monthly_expenses ?? finProf.monthly_expenses ?? 0,
|
||||||
@ -248,7 +245,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
additionalIncome:
|
additionalIncome:
|
||||||
scenRow.planned_additional_income ?? finProf.additional_income ?? 0,
|
scenRow.planned_additional_income ?? finProf.additional_income ?? 0,
|
||||||
|
|
||||||
// College stuff
|
// college
|
||||||
studentLoanAmount: colProf.existing_college_debt || 0,
|
studentLoanAmount: colProf.existing_college_debt || 0,
|
||||||
interestRate: colProf.interest_rate || 5,
|
interestRate: colProf.interest_rate || 5,
|
||||||
loanTerm: colProf.loan_term || 10,
|
loanTerm: colProf.loan_term || 10,
|
||||||
@ -261,122 +258,93 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
colProf.college_enrollment_status === 'currently_enrolled' ||
|
colProf.college_enrollment_status === 'currently_enrolled' ||
|
||||||
colProf.college_enrollment_status === 'prospective_student',
|
colProf.college_enrollment_status === 'prospective_student',
|
||||||
gradDate: colProf.expected_graduation || null,
|
gradDate: colProf.expected_graduation || null,
|
||||||
programType: colProf.program_type,
|
programType: colProf.program_type || null,
|
||||||
creditHoursPerYear: colProf.credit_hours_per_year || 0,
|
creditHoursPerYear: colProf.credit_hours_per_year || 0,
|
||||||
hoursCompleted: colProf.hours_completed || 0,
|
hoursCompleted: colProf.hours_completed || 0,
|
||||||
programLength: colProf.program_length || 0,
|
programLength: colProf.program_length || 0,
|
||||||
expectedSalary: colProf.expected_salary || finProf.current_salary || 0,
|
expectedSalary: colProf.expected_salary || finProf.current_salary || 0,
|
||||||
|
|
||||||
// Additional
|
// scenario horizon
|
||||||
startDate: new Date().toISOString(),
|
startDate: new Date().toISOString(),
|
||||||
simulationYears: simYears,
|
simulationYears: simYears,
|
||||||
|
|
||||||
// Milestone Impacts
|
|
||||||
milestoneImpacts: milestoneImpacts || []
|
milestoneImpacts: milestoneImpacts || []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// If you want to re-run simulation after any milestone changes:
|
||||||
// 4) reSimulate => after milestone changes or user toggles something
|
|
||||||
// ------------------------------------------------------
|
|
||||||
const reSimulate = async () => {
|
const reSimulate = async () => {
|
||||||
if (!careerPathId) return;
|
// Put your logic to re-fetch scenario + milestones, then re-run sim
|
||||||
try {
|
|
||||||
// 1) fetch everything again
|
|
||||||
const [finResp, scenResp, colResp, milResp] = await Promise.all([
|
|
||||||
authFetch(`${apiURL}/premium/financial-profile`),
|
|
||||||
authFetch(`${apiURL}/premium/career-profile/${careerPathId}`),
|
|
||||||
authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`),
|
|
||||||
authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`)
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!finResp.ok || !scenResp.ok || !colResp.ok || !milResp.ok) {
|
|
||||||
console.error(
|
|
||||||
'One reSimulate fetch failed:',
|
|
||||||
finResp.status,
|
|
||||||
scenResp.status,
|
|
||||||
colResp.status,
|
|
||||||
milResp.status
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updatedFinancial, updatedScenario, updatedCollege, milData] =
|
|
||||||
await Promise.all([
|
|
||||||
finResp.json(),
|
|
||||||
scenResp.json(),
|
|
||||||
colResp.json(),
|
|
||||||
milResp.json()
|
|
||||||
]);
|
|
||||||
|
|
||||||
const allMilestones = milData.milestones || [];
|
|
||||||
const impactsPromises = allMilestones.map(m =>
|
|
||||||
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
|
|
||||||
.then(r => r.ok ? r.json() : null)
|
|
||||||
.then(data => data?.impacts || [])
|
|
||||||
.catch(err => {
|
|
||||||
console.warn('Impact fetch err for milestone', m.id, err);
|
|
||||||
return [];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const impactsForEach = await Promise.all(impactsPromises);
|
|
||||||
const milestonesWithImpacts = allMilestones.map((m, i) => ({
|
|
||||||
...m,
|
|
||||||
impacts: impactsForEach[i] || []
|
|
||||||
}));
|
|
||||||
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts);
|
|
||||||
|
|
||||||
// 2) Build merged
|
|
||||||
const mergedProfile = buildMergedProfile(
|
|
||||||
updatedFinancial,
|
|
||||||
updatedScenario,
|
|
||||||
updatedCollege,
|
|
||||||
allImpacts,
|
|
||||||
simulationYears
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3) run
|
|
||||||
const { projectionData: newProjData, loanPaidOffMonth: payoff } =
|
|
||||||
simulateFinancialProjection(mergedProfile);
|
|
||||||
|
|
||||||
// 4) cumulative
|
|
||||||
let csum = mergedProfile.emergencySavings || 0;
|
|
||||||
const finalData = newProjData.map(mo => {
|
|
||||||
csum += (mo.netSavings || 0);
|
|
||||||
return { ...mo, cumulativeNetSavings: csum };
|
|
||||||
});
|
|
||||||
|
|
||||||
setProjectionData(finalData);
|
|
||||||
setLoanPayoffMonth(payoff);
|
|
||||||
|
|
||||||
// also store updated scenario, financial, college
|
|
||||||
setFinancialProfile(updatedFinancial);
|
|
||||||
setScenarioRow(updatedScenario);
|
|
||||||
setCollegeProfile(updatedCollege);
|
|
||||||
|
|
||||||
console.log('Re-simulated after milestone update', { mergedProfile, finalData });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error in reSimulate:', err);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// handle user typing simulation length
|
// handle user typing simulation length
|
||||||
const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value);
|
const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value);
|
||||||
const handleSimulationYearsBlur = () => {
|
const handleSimulationYearsBlur = () => {
|
||||||
if (!simulationYearsInput.trim()) {
|
if (!simulationYearsInput.trim()) {
|
||||||
setSimulationYearsInput("20");
|
setSimulationYearsInput('20');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Logging
|
// Build annotation lines from scenarioMilestones
|
||||||
console.log(
|
const milestoneAnnotationLines = {};
|
||||||
'First 5 items of projectionData:',
|
scenarioMilestones.forEach((m) => {
|
||||||
Array.isArray(projectionData) ? projectionData.slice(0, 5) : 'none'
|
if (!m.date) return;
|
||||||
);
|
const d = new Date(m.date);
|
||||||
|
if (isNaN(d)) return;
|
||||||
|
|
||||||
|
const year = d.getUTCFullYear();
|
||||||
|
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const short = `${year}-${month}`;
|
||||||
|
|
||||||
|
if (!projectionData.some((p) => p.month === short)) return;
|
||||||
|
|
||||||
|
milestoneAnnotationLines[`milestone_${m.id}`] = {
|
||||||
|
type: 'line',
|
||||||
|
xMin: short,
|
||||||
|
xMax: short,
|
||||||
|
borderColor: 'orange',
|
||||||
|
borderWidth: 2,
|
||||||
|
label: {
|
||||||
|
display: true,
|
||||||
|
content: m.title || 'Milestone',
|
||||||
|
color: 'orange',
|
||||||
|
position: 'end'
|
||||||
|
},
|
||||||
|
// If you want them clickable:
|
||||||
|
onClick: () => {
|
||||||
|
console.log('Clicked milestone line => open editing for', m.title);
|
||||||
|
// e.g. open the MilestoneTimeline's edit feature, or do something
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we also show a line for payoff:
|
||||||
|
const annotationConfig = {};
|
||||||
|
if (loanPayoffMonth) {
|
||||||
|
annotationConfig.loanPaidOffLine = {
|
||||||
|
type: 'line',
|
||||||
|
xMin: loanPayoffMonth,
|
||||||
|
xMax: loanPayoffMonth,
|
||||||
|
borderColor: 'rgba(255, 206, 86, 1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [6, 6],
|
||||||
|
label: {
|
||||||
|
display: true,
|
||||||
|
content: 'Loan Paid Off',
|
||||||
|
position: 'end',
|
||||||
|
backgroundColor: 'rgba(255, 206, 86, 0.8)',
|
||||||
|
color: '#000',
|
||||||
|
font: { size: 12 },
|
||||||
|
rotation: 0,
|
||||||
|
yAdjust: -10
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const allAnnotations = { ...milestoneAnnotationLines, ...annotationConfig };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-6">
|
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-6">
|
||||||
{/* Career Select */}
|
{/* 1) Career dropdown */}
|
||||||
<CareerSelectDropdown
|
<CareerSelectDropdown
|
||||||
existingCareerPaths={existingCareerPaths}
|
existingCareerPaths={existingCareerPaths}
|
||||||
selectedCareer={selectedCareer}
|
selectedCareer={selectedCareer}
|
||||||
@ -388,16 +356,17 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
authFetch={authFetch}
|
authFetch={authFetch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Milestone Timeline */}
|
{/* 2) We keep MilestoneTimeline for tasks, +Add Milestone button, etc. */}
|
||||||
<MilestoneTimeline
|
<MilestoneTimeline
|
||||||
|
// e.g. pass the scenario ID
|
||||||
careerPathId={careerPathId}
|
careerPathId={careerPathId}
|
||||||
authFetch={authFetch}
|
authFetch={authFetch}
|
||||||
activeView={activeView}
|
activeView="Career"
|
||||||
setActiveView={setActiveView}
|
onMilestoneUpdated={() => {
|
||||||
onMilestoneUpdated={reSimulate}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* AI-Suggested Milestones */}
|
{/* 3) AI-Suggested Milestones */}
|
||||||
<AISuggestedMilestones
|
<AISuggestedMilestones
|
||||||
career={selectedCareer?.career_name}
|
career={selectedCareer?.career_name}
|
||||||
careerPathId={careerPathId}
|
careerPathId={careerPathId}
|
||||||
@ -406,7 +375,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
projectionData={projectionData}
|
projectionData={projectionData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Chart Section */}
|
{/* 4) The main chart with annotation lines */}
|
||||||
{projectionData.length > 0 && (
|
{projectionData.length > 0 && (
|
||||||
<div className="bg-white p-4 rounded shadow space-y-4">
|
<div className="bg-white p-4 rounded shadow space-y-4">
|
||||||
<h3 className="text-lg font-semibold">Financial Projection</h3>
|
<h3 className="text-lg font-semibold">Financial Projection</h3>
|
||||||
@ -449,30 +418,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
plugins: {
|
plugins: {
|
||||||
legend: { position: 'bottom' },
|
legend: { position: 'bottom' },
|
||||||
tooltip: { mode: 'index', intersect: false },
|
tooltip: { mode: 'index', intersect: false },
|
||||||
annotation: loanPayoffMonth
|
annotation: {
|
||||||
? {
|
annotations: allAnnotations
|
||||||
annotations: {
|
}
|
||||||
loanPaidOffLine: {
|
|
||||||
type: 'line',
|
|
||||||
xMin: loanPayoffMonth,
|
|
||||||
xMax: loanPayoffMonth,
|
|
||||||
borderColor: 'rgba(255, 206, 86, 1)',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderDash: [6, 6],
|
|
||||||
label: {
|
|
||||||
display: true,
|
|
||||||
content: 'Loan Paid Off',
|
|
||||||
position: 'end',
|
|
||||||
backgroundColor: 'rgba(255, 206, 86, 0.8)',
|
|
||||||
color: '#000',
|
|
||||||
font: { size: 12 },
|
|
||||||
rotation: 0,
|
|
||||||
yAdjust: -10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
@ -484,29 +432,35 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
{loanPayoffMonth && (
|
||||||
|
<p className="font-semibold text-sm">
|
||||||
|
Loan Paid Off at: <span className="text-yellow-600">{loanPayoffMonth}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Simulation Length Input */}
|
{/* 5) Simulation length input */}
|
||||||
<div className="space-x-2">
|
<div className="space-x-2">
|
||||||
<label className="font-medium">Simulation Length (years):</label>
|
<label className="font-medium">Simulation Length (years):</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={simulationYearsInput}
|
value={simulationYearsInput}
|
||||||
onChange={handleSimulationYearsChange}
|
onChange={(e) => setSimulationYearsInput(e.target.value)}
|
||||||
onBlur={handleSimulationYearsBlur}
|
onBlur={handleSimulationYearsBlur}
|
||||||
className="border rounded p-1 w-16"
|
className="border rounded p-1 w-16"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Career Search */}
|
{/* 6) Career Search, scenario edit modal, etc. */}
|
||||||
<CareerSearch
|
<CareerSearch
|
||||||
onCareerSelected={(careerObj) => {
|
onCareerSelected={(careerObj) => {
|
||||||
setPendingCareerForModal(careerObj.title);
|
setPendingCareerForModal(careerObj.title);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<ScenarioEditModal
|
<ScenarioEditModal
|
||||||
show={showEditModal}
|
show={showEditModal}
|
||||||
onClose={() => setShowEditModal(false)}
|
onClose={() => setShowEditModal(false)}
|
||||||
@ -518,11 +472,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
authFetch={authFetch}
|
authFetch={authFetch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Confirm new career scenario */}
|
|
||||||
{pendingCareerForModal && (
|
{pendingCareerForModal && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Example action
|
|
||||||
console.log('User confirmed new career path:', pendingCareerForModal);
|
console.log('User confirmed new career path:', pendingCareerForModal);
|
||||||
setPendingCareerForModal(null);
|
setPendingCareerForModal(null);
|
||||||
}}
|
}}
|
||||||
|
@ -4,42 +4,29 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { Chart as ChartJS } from 'chart.js';
|
import { Chart as ChartJS } from 'chart.js';
|
||||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||||
import { Button } from './ui/button.js'; // <-- Universal Button
|
|
||||||
|
import { Button } from './ui/button.js'; // universal Button
|
||||||
import authFetch from '../utils/authFetch.js';
|
import authFetch from '../utils/authFetch.js';
|
||||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||||||
import AISuggestedMilestones from './AISuggestedMilestones.js';
|
import AISuggestedMilestones from './AISuggestedMilestones.js';
|
||||||
import ScenarioEditModal from './ScenarioEditModal.js';
|
import ScenarioEditModal from './ScenarioEditModal.js';
|
||||||
|
|
||||||
// Register the annotation plugin
|
// Register the annotation plugin globally
|
||||||
ChartJS.register(annotationPlugin);
|
ChartJS.register(annotationPlugin);
|
||||||
|
|
||||||
/**
|
|
||||||
* ScenarioContainer
|
|
||||||
* -----------------
|
|
||||||
* This component:
|
|
||||||
* 1) Lets the user pick a scenario (via <select>), or uses the provided `scenario` prop.
|
|
||||||
* 2) Loads the collegeProfile + milestones/impacts for that scenario.
|
|
||||||
* 3) Merges scenario + user financial data + milestone impacts → runs `simulateFinancialProjection`.
|
|
||||||
* 4) Shows a chart of net savings / retirement / loan balances over time.
|
|
||||||
* 5) Allows milestone CRUD (create, edit, delete, copy).
|
|
||||||
* 6) Offers “Clone” / “Delete” scenario callbacks from the parent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default function ScenarioContainer({
|
export default function ScenarioContainer({
|
||||||
scenario, // The scenario row from career_paths
|
scenario,
|
||||||
financialProfile, // The user’s overall financial snapshot
|
financialProfile,
|
||||||
onRemove, // Callback for deleting scenario
|
onRemove,
|
||||||
onClone, // Callback for cloning scenario
|
onClone,
|
||||||
onEdit // (Optional) If you want a scenario editing callback
|
onEdit
|
||||||
}) {
|
}) {
|
||||||
/*************************************************************
|
/*************************************************************
|
||||||
* 1) SCENARIO DROPDOWN: Load, store, and let user pick
|
* 1) Scenario Dropdown
|
||||||
*************************************************************/
|
*************************************************************/
|
||||||
const [allScenarios, setAllScenarios] = useState([]);
|
const [allScenarios, setAllScenarios] = useState([]);
|
||||||
// We keep a local copy of `scenario` so user can switch from the dropdown
|
|
||||||
const [localScenario, setLocalScenario] = useState(scenario || null);
|
const [localScenario, setLocalScenario] = useState(scenario || null);
|
||||||
|
|
||||||
// (A) On mount, fetch all scenarios to populate the <select>
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadScenarios() {
|
async function loadScenarios() {
|
||||||
try {
|
try {
|
||||||
@ -56,40 +43,32 @@ export default function ScenarioContainer({
|
|||||||
loadScenarios();
|
loadScenarios();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// (B) If parent changes the `scenario` prop, update local
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalScenario(scenario || null);
|
setLocalScenario(scenario || null);
|
||||||
}, [scenario]);
|
}, [scenario]);
|
||||||
|
|
||||||
// (C) <select> handler for picking a scenario from dropdown
|
|
||||||
function handleScenarioSelect(e) {
|
function handleScenarioSelect(e) {
|
||||||
const chosenId = e.target.value;
|
const chosenId = e.target.value;
|
||||||
const found = allScenarios.find((s) => s.id === chosenId);
|
const found = allScenarios.find((s) => s.id === chosenId);
|
||||||
if (found) {
|
setLocalScenario(found || null);
|
||||||
setLocalScenario(found);
|
|
||||||
} else {
|
|
||||||
setLocalScenario(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*************************************************************
|
/*************************************************************
|
||||||
* 2) COLLEGE PROFILE + MILESTONES + IMPACTS
|
* 2) College Profile + Milestones
|
||||||
*************************************************************/
|
*************************************************************/
|
||||||
const [collegeProfile, setCollegeProfile] = useState(null);
|
const [collegeProfile, setCollegeProfile] = useState(null);
|
||||||
const [milestones, setMilestones] = useState([]);
|
const [milestones, setMilestones] = useState([]);
|
||||||
const [impactsByMilestone, setImpactsByMilestone] = useState({});
|
const [impactsByMilestone, setImpactsByMilestone] = useState({});
|
||||||
|
|
||||||
// We'll also track a scenario edit modal
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [editingScenarioData, setEditingScenarioData] = useState({
|
const [editingScenarioData, setEditingScenarioData] = useState({
|
||||||
scenario: null,
|
scenario: null,
|
||||||
collegeProfile: null
|
collegeProfile: null
|
||||||
});
|
});
|
||||||
|
|
||||||
// (A) Load the college profile for the selected scenario
|
// load the college profile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!localScenario?.id) {
|
if (!localScenario?.id) {
|
||||||
// if no scenario selected, clear the collegeProfile
|
|
||||||
setCollegeProfile(null);
|
setCollegeProfile(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -99,7 +78,6 @@ export default function ScenarioContainer({
|
|||||||
const res = await authFetch(url);
|
const res = await authFetch(url);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// in some setups, the endpoint returns an array, in others an object
|
|
||||||
setCollegeProfile(Array.isArray(data) ? data[0] || {} : data);
|
setCollegeProfile(Array.isArray(data) ? data[0] || {} : data);
|
||||||
} else {
|
} else {
|
||||||
setCollegeProfile({});
|
setCollegeProfile({});
|
||||||
@ -112,7 +90,7 @@ export default function ScenarioContainer({
|
|||||||
loadCollegeProfile();
|
loadCollegeProfile();
|
||||||
}, [localScenario]);
|
}, [localScenario]);
|
||||||
|
|
||||||
// (B) Load milestones for localScenario (and each milestone’s impacts)
|
// load milestones (and each milestone's impacts)
|
||||||
const fetchMilestones = useCallback(async () => {
|
const fetchMilestones = useCallback(async () => {
|
||||||
if (!localScenario?.id) {
|
if (!localScenario?.id) {
|
||||||
setMilestones([]);
|
setMilestones([]);
|
||||||
@ -120,7 +98,9 @@ export default function ScenarioContainer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/premium/milestones?careerPathId=${localScenario.id}`);
|
const res = await authFetch(
|
||||||
|
`/api/premium/milestones?careerPathId=${localScenario.id}`
|
||||||
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error('Failed fetching milestones. Status:', res.status);
|
console.error('Failed fetching milestones. Status:', res.status);
|
||||||
return;
|
return;
|
||||||
@ -132,7 +112,9 @@ export default function ScenarioContainer({
|
|||||||
// For each milestone => fetch impacts
|
// For each milestone => fetch impacts
|
||||||
const impactsData = {};
|
const impactsData = {};
|
||||||
for (const m of mils) {
|
for (const m of mils) {
|
||||||
const iRes = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
|
const iRes = await authFetch(
|
||||||
|
`/api/premium/milestone-impacts?milestone_id=${m.id}`
|
||||||
|
);
|
||||||
if (iRes.ok) {
|
if (iRes.ok) {
|
||||||
const iData = await iRes.json();
|
const iData = await iRes.json();
|
||||||
impactsData[m.id] = iData.impacts || [];
|
impactsData[m.id] = iData.impacts || [];
|
||||||
@ -141,7 +123,6 @@ export default function ScenarioContainer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setImpactsByMilestone(impactsData);
|
setImpactsByMilestone(impactsData);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching milestones:', err);
|
console.error('Error fetching milestones:', err);
|
||||||
}
|
}
|
||||||
@ -152,17 +133,16 @@ export default function ScenarioContainer({
|
|||||||
}, [fetchMilestones]);
|
}, [fetchMilestones]);
|
||||||
|
|
||||||
/*************************************************************
|
/*************************************************************
|
||||||
* 3) MERGE & RUN SIMULATION => projectionData
|
* 3) Run Simulation
|
||||||
*************************************************************/
|
*************************************************************/
|
||||||
const [projectionData, setProjectionData] = useState([]);
|
const [projectionData, setProjectionData] = useState([]);
|
||||||
const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null);
|
const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null);
|
||||||
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Wait until we have localScenario + collegeProfile + financialProfile
|
|
||||||
if (!financialProfile || !localScenario?.id || !collegeProfile) return;
|
if (!financialProfile || !localScenario?.id || !collegeProfile) return;
|
||||||
|
|
||||||
// Gather all milestoneImpacts
|
// gather all milestoneImpacts
|
||||||
let allImpacts = [];
|
let allImpacts = [];
|
||||||
Object.keys(impactsByMilestone).forEach((mId) => {
|
Object.keys(impactsByMilestone).forEach((mId) => {
|
||||||
allImpacts = allImpacts.concat(impactsByMilestone[mId]);
|
allImpacts = allImpacts.concat(impactsByMilestone[mId]);
|
||||||
@ -170,19 +150,19 @@ export default function ScenarioContainer({
|
|||||||
|
|
||||||
const simYears = parseInt(simulationYearsInput, 10) || 20;
|
const simYears = parseInt(simulationYearsInput, 10) || 20;
|
||||||
|
|
||||||
// Build mergedProfile from scenario + user financial + college + milestone
|
// Merge scenario + user financial + college + milestone
|
||||||
const mergedProfile = {
|
const mergedProfile = {
|
||||||
// base user data
|
|
||||||
currentSalary: financialProfile.current_salary || 0,
|
currentSalary: financialProfile.current_salary || 0,
|
||||||
|
|
||||||
monthlyExpenses:
|
monthlyExpenses:
|
||||||
localScenario.planned_monthly_expenses ?? financialProfile.monthly_expenses ?? 0,
|
localScenario.planned_monthly_expenses ??
|
||||||
|
financialProfile.monthly_expenses ??
|
||||||
|
0,
|
||||||
monthlyDebtPayments:
|
monthlyDebtPayments:
|
||||||
localScenario.planned_monthly_debt_payments ?? financialProfile.monthly_debt_payments ?? 0,
|
localScenario.planned_monthly_debt_payments ??
|
||||||
|
financialProfile.monthly_debt_payments ??
|
||||||
|
0,
|
||||||
retirementSavings: financialProfile.retirement_savings ?? 0,
|
retirementSavings: financialProfile.retirement_savings ?? 0,
|
||||||
emergencySavings: financialProfile.emergency_fund ?? 0,
|
emergencySavings: financialProfile.emergency_fund ?? 0,
|
||||||
|
|
||||||
monthlyRetirementContribution:
|
monthlyRetirementContribution:
|
||||||
localScenario.planned_monthly_retirement_contribution ??
|
localScenario.planned_monthly_retirement_contribution ??
|
||||||
financialProfile.retirement_contribution ??
|
financialProfile.retirement_contribution ??
|
||||||
@ -191,7 +171,6 @@ export default function ScenarioContainer({
|
|||||||
localScenario.planned_monthly_emergency_contribution ??
|
localScenario.planned_monthly_emergency_contribution ??
|
||||||
financialProfile.emergency_contribution ??
|
financialProfile.emergency_contribution ??
|
||||||
0,
|
0,
|
||||||
|
|
||||||
surplusEmergencyAllocation:
|
surplusEmergencyAllocation:
|
||||||
localScenario.planned_surplus_emergency_pct ??
|
localScenario.planned_surplus_emergency_pct ??
|
||||||
financialProfile.extra_cash_emergency_pct ??
|
financialProfile.extra_cash_emergency_pct ??
|
||||||
@ -200,11 +179,12 @@ export default function ScenarioContainer({
|
|||||||
localScenario.planned_surplus_retirement_pct ??
|
localScenario.planned_surplus_retirement_pct ??
|
||||||
financialProfile.extra_cash_retirement_pct ??
|
financialProfile.extra_cash_retirement_pct ??
|
||||||
50,
|
50,
|
||||||
|
|
||||||
additionalIncome:
|
additionalIncome:
|
||||||
localScenario.planned_additional_income ?? financialProfile.additional_income ?? 0,
|
localScenario.planned_additional_income ??
|
||||||
|
financialProfile.additional_income ??
|
||||||
|
0,
|
||||||
|
|
||||||
// college-related
|
// college
|
||||||
studentLoanAmount: collegeProfile.existing_college_debt || 0,
|
studentLoanAmount: collegeProfile.existing_college_debt || 0,
|
||||||
interestRate: collegeProfile.interest_rate || 5,
|
interestRate: collegeProfile.interest_rate || 5,
|
||||||
loanTerm: collegeProfile.loan_term || 10,
|
loanTerm: collegeProfile.loan_term || 10,
|
||||||
@ -213,33 +193,30 @@ export default function ScenarioContainer({
|
|||||||
annualFinancialAid: collegeProfile.annual_financial_aid || 0,
|
annualFinancialAid: collegeProfile.annual_financial_aid || 0,
|
||||||
calculatedTuition: collegeProfile.tuition || 0,
|
calculatedTuition: collegeProfile.tuition || 0,
|
||||||
extraPayment: collegeProfile.extra_payment || 0,
|
extraPayment: collegeProfile.extra_payment || 0,
|
||||||
|
|
||||||
inCollege:
|
inCollege:
|
||||||
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
|
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
|
||||||
collegeProfile.college_enrollment_status === 'prospective_student',
|
collegeProfile.college_enrollment_status === 'prospective_student',
|
||||||
|
|
||||||
gradDate: collegeProfile.expected_graduation || null,
|
gradDate: collegeProfile.expected_graduation || null,
|
||||||
programType: collegeProfile.program_type || null,
|
programType: collegeProfile.program_type || null,
|
||||||
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
|
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
|
||||||
hoursCompleted: collegeProfile.hours_completed || 0,
|
hoursCompleted: collegeProfile.hours_completed || 0,
|
||||||
programLength: collegeProfile.program_length || 0,
|
programLength: collegeProfile.program_length || 0,
|
||||||
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary || 0,
|
expectedSalary:
|
||||||
|
collegeProfile.expected_salary || financialProfile.current_salary || 0,
|
||||||
|
|
||||||
// scenario date + simulation horizon
|
// scenario horizon
|
||||||
startDate: localScenario.start_date || new Date().toISOString(),
|
startDate: localScenario.start_date || new Date().toISOString(),
|
||||||
simulationYears: simYears,
|
simulationYears: simYears,
|
||||||
|
|
||||||
// milestone impacts
|
|
||||||
milestoneImpacts: allImpacts
|
milestoneImpacts: allImpacts
|
||||||
};
|
};
|
||||||
|
|
||||||
// Run the simulation
|
const { projectionData, loanPaidOffMonth } =
|
||||||
const { projectionData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile);
|
simulateFinancialProjection(mergedProfile);
|
||||||
|
|
||||||
// Optionally add a "cumulativeNetSavings" for display
|
|
||||||
let cumulative = mergedProfile.emergencySavings || 0;
|
let cumulative = mergedProfile.emergencySavings || 0;
|
||||||
const finalData = projectionData.map((monthRow) => {
|
const finalData = projectionData.map((monthRow) => {
|
||||||
cumulative += (monthRow.netSavings || 0);
|
cumulative += monthRow.netSavings || 0;
|
||||||
return { ...monthRow, cumulativeNetSavings: cumulative };
|
return { ...monthRow, cumulativeNetSavings: cumulative };
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -263,28 +240,41 @@ export default function ScenarioContainer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*************************************************************
|
/*************************************************************
|
||||||
* 4) CHART: build data, handle milestone markers
|
* 4) Chart + Annotations
|
||||||
*************************************************************/
|
*************************************************************/
|
||||||
// x-axis labels (e.g. "2025-01", "2025-02", etc.)
|
|
||||||
const chartLabels = projectionData.map((p) => p.month);
|
const chartLabels = projectionData.map((p) => p.month);
|
||||||
|
|
||||||
// dataset arrays
|
|
||||||
const netSavingsData = projectionData.map((p) => p.cumulativeNetSavings || 0);
|
const netSavingsData = projectionData.map((p) => p.cumulativeNetSavings || 0);
|
||||||
const retData = projectionData.map((p) => p.retirementSavings || 0);
|
const retData = projectionData.map((p) => p.retirementSavings || 0);
|
||||||
const loanData = projectionData.map((p) => p.loanBalance || 0);
|
const loanData = projectionData.map((p) => p.loanBalance || 0);
|
||||||
|
|
||||||
// milestone markers => we find index by matching YYYY-MM
|
const milestoneAnnotations = milestones
|
||||||
function getLabelIndexForMilestone(m) {
|
|
||||||
if (!m.date) return -1;
|
|
||||||
const short = m.date.slice(0, 7); // "YYYY-MM"
|
|
||||||
return chartLabels.indexOf(short);
|
|
||||||
}
|
|
||||||
|
|
||||||
const milestonePoints = milestones
|
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const xIndex = getLabelIndexForMilestone(m);
|
if (!m.date) return null;
|
||||||
if (xIndex < 0) return null;
|
const d = new Date(m.date);
|
||||||
return { x: xIndex, y: 0, milestoneObj: m };
|
if (isNaN(d)) return null;
|
||||||
|
|
||||||
|
const year = d.getUTCFullYear();
|
||||||
|
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const short = `${year}-${month}`;
|
||||||
|
|
||||||
|
if (!chartLabels.includes(short)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'line',
|
||||||
|
xMin: short,
|
||||||
|
xMax: short,
|
||||||
|
borderColor: 'orange',
|
||||||
|
borderWidth: 2,
|
||||||
|
label: {
|
||||||
|
display: true,
|
||||||
|
content: m.title || 'Milestone',
|
||||||
|
color: 'orange',
|
||||||
|
position: 'end'
|
||||||
|
},
|
||||||
|
milestoneObj: m,
|
||||||
|
onClick: () => handleEditMilestone(m)
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
@ -308,47 +298,24 @@ export default function ScenarioContainer({
|
|||||||
data: loanData,
|
data: loanData,
|
||||||
borderColor: 'red',
|
borderColor: 'red',
|
||||||
fill: false
|
fill: false
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Milestones',
|
|
||||||
data: milestonePoints,
|
|
||||||
showLine: false,
|
|
||||||
pointStyle: 'triangle',
|
|
||||||
pointRadius: 8,
|
|
||||||
borderColor: 'orange',
|
|
||||||
backgroundColor: 'orange'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleChartClick(evt, elements, chart) {
|
|
||||||
if (!elements || elements.length === 0) return;
|
|
||||||
const { datasetIndex, index } = elements[0];
|
|
||||||
const ds = chartData.datasets[datasetIndex];
|
|
||||||
if (ds.label === 'Milestones') {
|
|
||||||
const clickedPoint = ds.data[index]; // e.g. { x, y, milestoneObj }
|
|
||||||
const milestone = clickedPoint.milestoneObj;
|
|
||||||
handleEditMilestone(milestone);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const chartOptions = {
|
const chartOptions = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
scales: {
|
scales: {
|
||||||
x: { type: 'category' },
|
x: { type: 'category' },
|
||||||
y: { title: { display: true, text: 'Amount ($)' } }
|
y: { title: { display: true, text: 'Amount ($)' } }
|
||||||
},
|
},
|
||||||
onClick: handleChartClick,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
|
annotation: {
|
||||||
|
annotations: milestoneAnnotations
|
||||||
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function (context) {
|
label: (context) =>
|
||||||
if (context.dataset.label === 'Milestones') {
|
`${context.dataset.label}: ${context.formattedValue}`
|
||||||
const { milestoneObj } = context.raw;
|
|
||||||
return milestoneObj.title || '(Untitled milestone)';
|
|
||||||
}
|
|
||||||
return `${context.dataset.label}: ${context.formattedValue}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -372,11 +339,18 @@ export default function ScenarioContainer({
|
|||||||
|
|
||||||
// tasks
|
// tasks
|
||||||
const [showTaskForm, setShowTaskForm] = useState(null);
|
const [showTaskForm, setShowTaskForm] = useState(null);
|
||||||
const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' });
|
// We'll track a separate "editingTask" so we can fill in the form
|
||||||
|
const [editingTask, setEditingTask] = useState({
|
||||||
|
id: null,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
due_date: ''
|
||||||
|
});
|
||||||
|
|
||||||
// copy wizard
|
// copy wizard
|
||||||
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
||||||
|
|
||||||
|
// create new milestone
|
||||||
function handleNewMilestone() {
|
function handleNewMilestone() {
|
||||||
setEditingMilestone(null);
|
setEditingMilestone(null);
|
||||||
setNewMilestone({
|
setNewMilestone({
|
||||||
@ -399,7 +373,9 @@ export default function ScenarioContainer({
|
|||||||
setImpactsToDelete([]);
|
setImpactsToDelete([]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const impRes = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
|
const impRes = await authFetch(
|
||||||
|
`/api/premium/milestone-impacts?milestone_id=${m.id}`
|
||||||
|
);
|
||||||
if (impRes.ok) {
|
if (impRes.ok) {
|
||||||
const data = await impRes.json();
|
const data = await impRes.json();
|
||||||
const fetchedImpacts = data.impacts || [];
|
const fetchedImpacts = data.impacts || [];
|
||||||
@ -431,7 +407,13 @@ export default function ScenarioContainer({
|
|||||||
...prev,
|
...prev,
|
||||||
impacts: [
|
impacts: [
|
||||||
...prev.impacts,
|
...prev.impacts,
|
||||||
{ impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' }
|
{
|
||||||
|
impact_type: 'ONE_TIME',
|
||||||
|
direction: 'subtract',
|
||||||
|
amount: 0,
|
||||||
|
start_date: '',
|
||||||
|
end_date: ''
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@ -456,7 +438,6 @@ export default function ScenarioContainer({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// create or update milestone
|
|
||||||
async function saveMilestone() {
|
async function saveMilestone() {
|
||||||
if (!localScenario?.id) return;
|
if (!localScenario?.id) return;
|
||||||
|
|
||||||
@ -466,14 +447,16 @@ export default function ScenarioContainer({
|
|||||||
const method = editingMilestone ? 'PUT' : 'POST';
|
const method = editingMilestone ? 'PUT' : 'POST';
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
milestone_type: 'Financial', // or "Career" if your scenario is about career
|
milestone_type: 'Financial',
|
||||||
title: newMilestone.title,
|
title: newMilestone.title,
|
||||||
description: newMilestone.description,
|
description: newMilestone.description,
|
||||||
date: newMilestone.date,
|
date: newMilestone.date,
|
||||||
career_path_id: localScenario.id,
|
career_path_id: localScenario.id,
|
||||||
progress: newMilestone.progress,
|
progress: newMilestone.progress,
|
||||||
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
|
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
|
||||||
new_salary: newMilestone.newSalary ? parseFloat(newMilestone.newSalary) : null,
|
new_salary: newMilestone.newSalary
|
||||||
|
? parseFloat(newMilestone.newSalary)
|
||||||
|
: null,
|
||||||
is_universal: newMilestone.isUniversal || 0
|
is_universal: newMilestone.isUniversal || 0
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -490,9 +473,11 @@ export default function ScenarioContainer({
|
|||||||
}
|
}
|
||||||
const savedMilestone = await res.json();
|
const savedMilestone = await res.json();
|
||||||
|
|
||||||
// handle impacts (delete old, upsert new)
|
// handle impacts
|
||||||
for (const id of impactsToDelete) {
|
for (const id of impactsToDelete) {
|
||||||
await authFetch(`/api/premium/milestone-impacts/${id}`, { method: 'DELETE' });
|
await authFetch(`/api/premium/milestone-impacts/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (let i = 0; i < newMilestone.impacts.length; i++) {
|
for (let i = 0; i < newMilestone.impacts.length; i++) {
|
||||||
const imp = newMilestone.impacts[i];
|
const imp = newMilestone.impacts[i];
|
||||||
@ -505,14 +490,12 @@ export default function ScenarioContainer({
|
|||||||
end_date: imp.end_date || null
|
end_date: imp.end_date || null
|
||||||
};
|
};
|
||||||
if (imp.id) {
|
if (imp.id) {
|
||||||
// update existing
|
|
||||||
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
|
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(impPayload)
|
body: JSON.stringify(impPayload)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// create new
|
|
||||||
await authFetch('/api/premium/milestone-impacts', {
|
await authFetch('/api/premium/milestone-impacts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -521,7 +504,7 @@ export default function ScenarioContainer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// done => re-fetch
|
// re-fetch
|
||||||
await fetchMilestones();
|
await fetchMilestones();
|
||||||
|
|
||||||
// reset form
|
// reset form
|
||||||
@ -543,7 +526,6 @@ export default function ScenarioContainer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete milestone => if universal, ask user if removing from all or just this scenario
|
|
||||||
async function handleDeleteMilestone(m) {
|
async function handleDeleteMilestone(m) {
|
||||||
if (m.is_universal === 1) {
|
if (m.is_universal === 1) {
|
||||||
const userChoice = window.confirm(
|
const userChoice = window.confirm(
|
||||||
@ -551,7 +533,9 @@ export default function ScenarioContainer({
|
|||||||
);
|
);
|
||||||
if (userChoice) {
|
if (userChoice) {
|
||||||
try {
|
try {
|
||||||
await authFetch(`/api/premium/milestones/${m.id}/all`, { method: 'DELETE' });
|
await authFetch(`/api/premium/milestones/${m.id}/all`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error removing universal milestone from all:', err);
|
console.error('Error removing universal milestone from all:', err);
|
||||||
}
|
}
|
||||||
@ -572,29 +556,86 @@ export default function ScenarioContainer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// tasks
|
/*************************************************************
|
||||||
async function addTask(milestoneId) {
|
* 6) TASK CRUD
|
||||||
|
*************************************************************/
|
||||||
|
// 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 {
|
try {
|
||||||
const payload = {
|
let url = '/api/premium/tasks';
|
||||||
milestone_id: milestoneId,
|
let method = 'POST';
|
||||||
title: newTask.title,
|
if (editingTask.id) {
|
||||||
description: newTask.description,
|
url = `/api/premium/tasks/${editingTask.id}`;
|
||||||
due_date: newTask.due_date
|
method = 'PUT';
|
||||||
};
|
}
|
||||||
const res = await authFetch('/api/premium/tasks', {
|
|
||||||
method: 'POST',
|
const res = await authFetch(url, {
|
||||||
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
alert('Failed to add task');
|
alert('Failed to save task');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await fetchMilestones();
|
await fetchMilestones();
|
||||||
setNewTask({ title: '', description: '', due_date: '' });
|
|
||||||
setShowTaskForm(null);
|
setShowTaskForm(null);
|
||||||
|
setEditingTask({
|
||||||
|
id: null,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
due_date: ''
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error adding task:', err);
|
console.error('Error saving task:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask(taskId) {
|
||||||
|
if (!taskId) return;
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/premium/tasks/${taskId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Failed to delete task');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchMilestones();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting task:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -616,7 +657,7 @@ export default function ScenarioContainer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*************************************************************
|
/*************************************************************
|
||||||
* 6) COPY WIZARD
|
* 7) COPY WIZARD
|
||||||
*************************************************************/
|
*************************************************************/
|
||||||
function CopyMilestoneWizard({ milestone, scenarios, onClose }) {
|
function CopyMilestoneWizard({ milestone, scenarios, onClose }) {
|
||||||
const [selectedScenarios, setSelectedScenarios] = useState([]);
|
const [selectedScenarios, setSelectedScenarios] = useState([]);
|
||||||
@ -641,7 +682,6 @@ export default function ScenarioContainer({
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to copy milestone');
|
if (!res.ok) throw new Error('Failed to copy milestone');
|
||||||
onClose();
|
onClose();
|
||||||
// Optionally reload
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error copying milestone:', err);
|
console.error('Error copying milestone:', err);
|
||||||
@ -673,7 +713,6 @@ export default function ScenarioContainer({
|
|||||||
<p>
|
<p>
|
||||||
Milestone: <strong>{milestone.title}</strong>
|
Milestone: <strong>{milestone.title}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{scenarios.map((s) => (
|
{scenarios.map((s) => (
|
||||||
<div key={s.id}>
|
<div key={s.id}>
|
||||||
<label>
|
<label>
|
||||||
@ -686,7 +725,6 @@ export default function ScenarioContainer({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div style={{ marginTop: '1rem' }}>
|
<div style={{ marginTop: '1rem' }}>
|
||||||
<Button onClick={onClose} style={{ marginRight: '0.5rem' }}>
|
<Button onClick={onClose} style={{ marginRight: '0.5rem' }}>
|
||||||
Cancel
|
Cancel
|
||||||
@ -699,11 +737,11 @@ export default function ScenarioContainer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*************************************************************
|
/*************************************************************
|
||||||
* 7) RENDER
|
* 8) RENDER
|
||||||
*************************************************************/
|
*************************************************************/
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
|
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
|
||||||
{/* (A) scenario dropdown */}
|
{/* scenario dropdown */}
|
||||||
<select
|
<select
|
||||||
style={{ marginBottom: '0.5rem', width: '100%' }}
|
style={{ marginBottom: '0.5rem', width: '100%' }}
|
||||||
value={localScenario?.id || ''}
|
value={localScenario?.id || ''}
|
||||||
@ -717,7 +755,6 @@ export default function ScenarioContainer({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* If localScenario is selected => show UI */}
|
|
||||||
{localScenario && (
|
{localScenario && (
|
||||||
<>
|
<>
|
||||||
<h4>{localScenario.scenario_title || localScenario.career_name}</h4>
|
<h4>{localScenario.scenario_title || localScenario.career_name}</h4>
|
||||||
@ -727,7 +764,6 @@ export default function ScenarioContainer({
|
|||||||
End: {localScenario.projected_end_date}
|
End: {localScenario.projected_end_date}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Simulation length */}
|
|
||||||
<div style={{ margin: '0.5rem 0' }}>
|
<div style={{ margin: '0.5rem 0' }}>
|
||||||
<label>Simulation Length (years): </label>
|
<label>Simulation Length (years): </label>
|
||||||
<input
|
<input
|
||||||
@ -744,8 +780,7 @@ export default function ScenarioContainer({
|
|||||||
|
|
||||||
{projectionData.length > 0 && (
|
{projectionData.length > 0 && (
|
||||||
<div style={{ marginTop: '0.5rem' }}>
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'}
|
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br />
|
||||||
<br />
|
|
||||||
<strong>Final Retirement:</strong>{' '}
|
<strong>Final Retirement:</strong>{' '}
|
||||||
{Math.round(
|
{Math.round(
|
||||||
projectionData[projectionData.length - 1].retirementSavings
|
projectionData[projectionData.length - 1].retirementSavings
|
||||||
@ -757,9 +792,10 @@ export default function ScenarioContainer({
|
|||||||
+ New Milestone
|
+ New Milestone
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* AI-Suggested Milestones */}
|
|
||||||
<AISuggestedMilestones
|
<AISuggestedMilestones
|
||||||
career={localScenario.career_name || localScenario.scenario_title || ''}
|
career={
|
||||||
|
localScenario.career_name || localScenario.scenario_title || ''
|
||||||
|
}
|
||||||
careerPathId={localScenario.id}
|
careerPathId={localScenario.id}
|
||||||
authFetch={authFetch}
|
authFetch={authFetch}
|
||||||
activeView="Financial"
|
activeView="Financial"
|
||||||
@ -779,33 +815,46 @@ export default function ScenarioContainer({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The inline form for milestone creation/edit */}
|
{/* The milestone form */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="form border p-2 my-2">
|
<div className="form border p-2 my-2">
|
||||||
<h4>{editingMilestone ? 'Edit Milestone' : 'New Milestone'}</h4>
|
<h4>
|
||||||
|
{editingMilestone ? 'Edit Milestone' : 'New Milestone'}
|
||||||
|
</h4>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
value={newMilestone.title}
|
value={newMilestone.title}
|
||||||
onChange={(e) => setNewMilestone({ ...newMilestone, title: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setNewMilestone({ ...newMilestone, title: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
value={newMilestone.description}
|
value={newMilestone.description}
|
||||||
onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setNewMilestone({
|
||||||
|
...newMilestone,
|
||||||
|
description: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
placeholder="Milestone Date"
|
placeholder="Milestone Date"
|
||||||
value={newMilestone.date}
|
value={newMilestone.date}
|
||||||
onChange={(e) => setNewMilestone({ ...newMilestone, date: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setNewMilestone({ ...newMilestone, date: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Progress (%)"
|
placeholder="Progress (%)"
|
||||||
value={newMilestone.progress === 0 ? '' : newMilestone.progress}
|
value={
|
||||||
|
newMilestone.progress === 0 ? '' : newMilestone.progress
|
||||||
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewMilestone((prev) => ({
|
setNewMilestone((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@ -815,19 +864,33 @@ export default function ScenarioContainer({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Impacts sub-form */}
|
{/* Impacts sub-form */}
|
||||||
<div style={{ border: '1px solid #ccc', padding: '1rem', marginTop: '1rem' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
padding: '1rem',
|
||||||
|
marginTop: '1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h5>Financial Impacts</h5>
|
<h5>Financial Impacts</h5>
|
||||||
{newMilestone.impacts.map((imp, idx) => (
|
{newMilestone.impacts.map((imp, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
style={{ border: '1px solid #bbb', margin: '0.5rem', padding: '0.5rem' }}
|
style={{
|
||||||
|
border: '1px solid #bbb',
|
||||||
|
margin: '0.5rem',
|
||||||
|
padding: '0.5rem'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{imp.id && <p style={{ fontSize: '0.8rem' }}>ID: {imp.id}</p>}
|
{imp.id && (
|
||||||
|
<p style={{ fontSize: '0.8rem' }}>ID: {imp.id}</p>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label>Type: </label>
|
<label>Type: </label>
|
||||||
<select
|
<select
|
||||||
value={imp.impact_type}
|
value={imp.impact_type}
|
||||||
onChange={(e) => updateImpact(idx, 'impact_type', e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateImpact(idx, 'impact_type', e.target.value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="ONE_TIME">One-Time</option>
|
<option value="ONE_TIME">One-Time</option>
|
||||||
<option value="MONTHLY">Monthly</option>
|
<option value="MONTHLY">Monthly</option>
|
||||||
@ -837,7 +900,9 @@ export default function ScenarioContainer({
|
|||||||
<label>Direction: </label>
|
<label>Direction: </label>
|
||||||
<select
|
<select
|
||||||
value={imp.direction}
|
value={imp.direction}
|
||||||
onChange={(e) => updateImpact(idx, 'direction', e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateImpact(idx, 'direction', e.target.value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="add">Add (Income)</option>
|
<option value="add">Add (Income)</option>
|
||||||
<option value="subtract">Subtract (Expense)</option>
|
<option value="subtract">Subtract (Expense)</option>
|
||||||
@ -848,7 +913,9 @@ export default function ScenarioContainer({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={imp.amount}
|
value={imp.amount}
|
||||||
onChange={(e) => updateImpact(idx, 'amount', e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateImpact(idx, 'amount', e.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -856,7 +923,9 @@ export default function ScenarioContainer({
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={imp.start_date || ''}
|
value={imp.start_date || ''}
|
||||||
onChange={(e) => updateImpact(idx, 'start_date', e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateImpact(idx, 'start_date', e.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{imp.impact_type === 'MONTHLY' && (
|
{imp.impact_type === 'MONTHLY' && (
|
||||||
@ -865,7 +934,9 @@ export default function ScenarioContainer({
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={imp.end_date || ''}
|
value={imp.end_date || ''}
|
||||||
onChange={(e) => updateImpact(idx, 'end_date', e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateImpact(idx, 'end_date', e.target.value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -907,18 +978,24 @@ export default function ScenarioContainer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Render existing milestones + tasks + copy wizard, etc. */}
|
{/* Render existing milestones */}
|
||||||
{milestones.map((m) => {
|
{milestones.map((m) => {
|
||||||
|
// tasks
|
||||||
const tasks = m.tasks || [];
|
const tasks = m.tasks || [];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
style={{ border: '1px solid #ccc', marginTop: '1rem', padding: '0.5rem' }}
|
style={{
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '0.5rem'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<h5>{m.title}</h5>
|
<h5>{m.title}</h5>
|
||||||
{m.description && <p>{m.description}</p>}
|
{m.description && <p>{m.description}</p>}
|
||||||
<p>
|
<p>
|
||||||
<strong>Date:</strong> {m.date} — <strong>Progress:</strong> {m.progress}%
|
<strong>Date:</strong> {m.date} —{' '}
|
||||||
|
<strong>Progress:</strong> {m.progress}%
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* tasks list */}
|
{/* tasks list */}
|
||||||
@ -928,26 +1005,31 @@ export default function ScenarioContainer({
|
|||||||
<li key={t.id}>
|
<li key={t.id}>
|
||||||
<strong>{t.title}</strong>
|
<strong>{t.title}</strong>
|
||||||
{t.description ? ` - ${t.description}` : ''}
|
{t.description ? ` - ${t.description}` : ''}
|
||||||
{t.due_date ? ` (Due: ${t.due_date})` : ''}
|
{t.due_date ? ` (Due: ${t.due_date})` : ''}{' '}
|
||||||
|
<Button
|
||||||
|
style={{ marginLeft: '0.5rem' }}
|
||||||
|
onClick={() => handleEditTask(m.id, t)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
style={{ marginLeft: '0.5rem', color: 'red' }}
|
||||||
|
onClick={() => deleteTask(t.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => handleAddTask(m.id)}
|
||||||
setShowTaskForm(showTaskForm === m.id ? null : m.id);
|
style={{ marginRight: '0.5rem' }}
|
||||||
setNewTask({ title: '', description: '', due_date: '' });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
|
+ Task
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
style={{ marginLeft: '0.5rem' }}
|
|
||||||
onClick={() => handleEditMilestone(m)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => handleEditMilestone(m)}>Edit</Button>
|
||||||
<Button
|
<Button
|
||||||
style={{ marginLeft: '0.5rem' }}
|
style={{ marginLeft: '0.5rem' }}
|
||||||
onClick={() => setCopyWizardMilestone(m)}
|
onClick={() => setCopyWizardMilestone(m)}
|
||||||
@ -961,34 +1043,63 @@ export default function ScenarioContainer({
|
|||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Task form */}
|
{/* If this is the milestone whose tasks we're editing => show the task form */}
|
||||||
{showTaskForm === m.id && (
|
{showTaskForm === m.id && (
|
||||||
<div style={{ marginTop: '0.5rem' }}>
|
<div style={{ marginTop: '0.5rem', border: '1px solid #aaa', padding: '0.5rem' }}>
|
||||||
|
<h5>{editingTask.id ? 'Edit Task' : 'New Task'}</h5>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Task Title"
|
placeholder="Task Title"
|
||||||
value={newTask.title}
|
value={editingTask.title}
|
||||||
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditingTask({ ...editingTask, title: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Task Description"
|
placeholder="Task Description"
|
||||||
value={newTask.description}
|
value={editingTask.description}
|
||||||
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditingTask({
|
||||||
|
...editingTask,
|
||||||
|
description: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={newTask.due_date}
|
value={editingTask.due_date}
|
||||||
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditingTask({
|
||||||
|
...editingTask,
|
||||||
|
due_date: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => addTask(m.id)}>Save Task</Button>
|
<Button onClick={() => saveTask(m.id)}>
|
||||||
|
{editingTask.id ? 'Update' : 'Add'} Task
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
style={{ marginLeft: '0.5rem' }}
|
||||||
|
onClick={() => {
|
||||||
|
setShowTaskForm(null);
|
||||||
|
setEditingTask({
|
||||||
|
id: null,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
due_date: ''
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* (B) Show the scenario edit modal if needed */}
|
{/* Scenario edit modal */}
|
||||||
<ScenarioEditModal
|
<ScenarioEditModal
|
||||||
show={showEditModal}
|
show={showEditModal}
|
||||||
onClose={() => setShowEditModal(false)}
|
onClose={() => setShowEditModal(false)}
|
||||||
@ -996,7 +1107,7 @@ export default function ScenarioContainer({
|
|||||||
collegeProfile={editingScenarioData.collegeProfile}
|
collegeProfile={editingScenarioData.collegeProfile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* The copy wizard if copying a milestone */}
|
{/* Copy wizard */}
|
||||||
{copyWizardMilestone && (
|
{copyWizardMilestone && (
|
||||||
<CopyMilestoneWizard
|
<CopyMilestoneWizard
|
||||||
milestone={copyWizardMilestone}
|
milestone={copyWizardMilestone}
|
||||||
|
@ -85,7 +85,6 @@ function calculateLoanPayment(principal, annualRate, years) {
|
|||||||
***************************************************/
|
***************************************************/
|
||||||
export function simulateFinancialProjection(userProfile) {
|
export function simulateFinancialProjection(userProfile) {
|
||||||
// 1) Show userProfile at the start
|
// 1) Show userProfile at the start
|
||||||
console.log("simulateFinancialProjection() called with userProfile:", userProfile);
|
|
||||||
|
|
||||||
/***************************************************
|
/***************************************************
|
||||||
* 1) DESTRUCTURE USER PROFILE
|
* 1) DESTRUCTURE USER PROFILE
|
||||||
@ -349,29 +348,15 @@ export function simulateFinancialProjection(userProfile) {
|
|||||||
if (nowExitingCollege) {
|
if (nowExitingCollege) {
|
||||||
// recalc monthlyLoanPayment with the current loanBalance
|
// recalc monthlyLoanPayment with the current loanBalance
|
||||||
monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, loanTerm);
|
monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, loanTerm);
|
||||||
console.log(
|
|
||||||
`== Exiting deferral at monthIndex=${monthIndex}, ` +
|
|
||||||
`loanBalance=${loanBalance}, new monthlyLoanPayment=${monthlyLoanPayment}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sum up all monthly expenses
|
// sum up all monthly expenses
|
||||||
let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth + extraImpactsThisMonth;
|
let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth + extraImpactsThisMonth;
|
||||||
|
|
||||||
// (UPDATED) console log includes inCollege + stillInCollege + loanDeferral
|
|
||||||
console.log(
|
|
||||||
`Month ${monthIndex}, date=${currentSimDate.format('YYYY-MM')} => ` +
|
|
||||||
`inCollege=${inCollege}, stillInCollege=${stillInCollege}, ` +
|
|
||||||
`loanDeferralUntilGrad=${loanDeferralUntilGraduation}, ` +
|
|
||||||
`loanBalBefore=${loanBalance.toFixed(2)}, ` +
|
|
||||||
`monthlyLoanPayment=${monthlyLoanPayment.toFixed(2)}, extraPayment=${extraPayment}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (stillInCollege && loanDeferralUntilGraduation) {
|
if (stillInCollege && loanDeferralUntilGraduation) {
|
||||||
// accumulate interest only
|
// accumulate interest only
|
||||||
const interestForMonth = loanBalance * (interestRate / 100 / 12);
|
const interestForMonth = loanBalance * (interestRate / 100 / 12);
|
||||||
loanBalance += interestForMonth;
|
loanBalance += interestForMonth;
|
||||||
console.log(` (deferral) interest added=${interestForMonth.toFixed(2)}, loanBalAfter=${loanBalance.toFixed(2)}`);
|
|
||||||
} else {
|
} else {
|
||||||
// pay principal
|
// pay principal
|
||||||
if (loanBalance > 0) {
|
if (loanBalance > 0) {
|
||||||
@ -380,11 +365,6 @@ export function simulateFinancialProjection(userProfile) {
|
|||||||
const principalForMonth = Math.min(loanBalance, totalThisMonth - interestForMonth);
|
const principalForMonth = Math.min(loanBalance, totalThisMonth - interestForMonth);
|
||||||
loanBalance = Math.max(loanBalance - principalForMonth, 0);
|
loanBalance = Math.max(loanBalance - principalForMonth, 0);
|
||||||
totalMonthlyExpenses += totalThisMonth;
|
totalMonthlyExpenses += totalThisMonth;
|
||||||
|
|
||||||
console.log(
|
|
||||||
` (payment) interest=${interestForMonth.toFixed(2)}, principal=${principalForMonth.toFixed(2)}, ` +
|
|
||||||
`loanBalAfter=${loanBalance.toFixed(2)}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -467,9 +447,6 @@ export function simulateFinancialProjection(userProfile) {
|
|||||||
loanPaidOffMonth = scenarioStartClamped.clone().add(maxMonths, 'months').format('YYYY-MM');
|
loanPaidOffMonth = scenarioStartClamped.clone().add(maxMonths, 'months').format('YYYY-MM');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("End of simulation: finalLoanBalance=", loanBalance.toFixed(2),
|
|
||||||
"loanPaidOffMonth=", loanPaidOffMonth);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectionData,
|
projectionData,
|
||||||
loanPaidOffMonth,
|
loanPaidOffMonth,
|
||||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user