Added wizard functionality to milestones for copy/delete. Still need to add global refresh.

This commit is contained in:
Josh 2025-04-23 13:53:18 +00:00
parent eed3767172
commit 23ac7260ab
7 changed files with 1174 additions and 645 deletions

View File

@ -92,7 +92,30 @@ app.get('/api/premium/career-profile/all', authenticatePremiumUser, async (req,
} }
}); });
// GET a single career profile (scenario) by ID
app.get('/api/premium/career-profile/:careerPathId', authenticatePremiumUser, async (req, res) => {
const { careerPathId } = req.params;
try {
const row = await db.get(`
SELECT *
FROM career_paths
WHERE id = ?
AND user_id = ?
`, [careerPathId, req.userId]);
if (!row) {
return res.status(404).json({ error: 'Career path (scenario) not found or not yours.' });
}
res.json(row);
} catch (error) {
console.error('Error fetching single career profile:', error);
res.status(500).json({ error: 'Failed to fetch career profile by ID.' });
}
});
// POST a new career profile // POST a new career profile
// server3.js
app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res) => { app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res) => {
const { const {
career_name, career_name,
@ -100,10 +123,18 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
start_date, start_date,
projected_end_date, projected_end_date,
college_enrollment_status, college_enrollment_status,
currently_working currently_working,
// NEW planned columns
planned_monthly_expenses,
planned_monthly_debt_payments,
planned_monthly_retirement_contribution,
planned_monthly_emergency_contribution,
planned_surplus_emergency_pct,
planned_surplus_retirement_pct,
planned_additional_income
} = req.body; } = req.body;
// If you need to ensure the user gave us a career_name:
if (!career_name) { if (!career_name) {
return res.status(400).json({ error: 'career_name is required.' }); return res.status(400).json({ error: 'career_name is required.' });
} }
@ -112,6 +143,8 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
const newCareerPathId = uuidv4(); const newCareerPathId = uuidv4();
const now = new Date().toISOString(); const now = new Date().toISOString();
// Insert or update row in career_paths. We rely on ON CONFLICT(user_id, career_name).
// If you want a different conflict target, change accordingly.
await db.run(` await db.run(`
INSERT INTO career_paths ( INSERT INTO career_paths (
id, id,
@ -122,10 +155,21 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
projected_end_date, projected_end_date,
college_enrollment_status, college_enrollment_status,
currently_working, currently_working,
planned_monthly_expenses,
planned_monthly_debt_payments,
planned_monthly_retirement_contribution,
planned_monthly_emergency_contribution,
planned_surplus_emergency_pct,
planned_surplus_retirement_pct,
planned_additional_income,
created_at, created_at,
updated_at updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?,
?, ?)
ON CONFLICT(user_id, career_name) ON CONFLICT(user_id, career_name)
DO UPDATE SET DO UPDATE SET
status = excluded.status, status = excluded.status,
@ -133,22 +177,41 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
projected_end_date = excluded.projected_end_date, projected_end_date = excluded.projected_end_date,
college_enrollment_status = excluded.college_enrollment_status, college_enrollment_status = excluded.college_enrollment_status,
currently_working = excluded.currently_working, currently_working = excluded.currently_working,
planned_monthly_expenses = excluded.planned_monthly_expenses,
planned_monthly_debt_payments = excluded.planned_monthly_debt_payments,
planned_monthly_retirement_contribution = excluded.planned_monthly_retirement_contribution,
planned_monthly_emergency_contribution = excluded.planned_monthly_emergency_contribution,
planned_surplus_emergency_pct = excluded.planned_surplus_emergency_pct,
planned_surplus_retirement_pct = excluded.planned_surplus_retirement_pct,
planned_additional_income = excluded.planned_additional_income,
updated_at = ? updated_at = ?
`, [ `, [
newCareerPathId, // id newCareerPathId,
req.userId, // user_id req.userId,
career_name, // career_name career_name,
status || 'planned', // status (if null, default to 'planned') status || 'planned',
start_date || now, start_date || now,
projected_end_date || null, projected_end_date || null,
college_enrollment_status || null, college_enrollment_status || null,
currently_working || null, currently_working || null,
// new planned columns
planned_monthly_expenses ?? null,
planned_monthly_debt_payments ?? null,
planned_monthly_retirement_contribution ?? null,
planned_monthly_emergency_contribution ?? null,
planned_surplus_emergency_pct ?? null,
planned_surplus_retirement_pct ?? null,
planned_additional_income ?? null,
now, // created_at now, // created_at
now, // updated_at on initial insert now, // updated_at
now // updated_at on conflict now // updated_at on conflict
]); ]);
// Optionally fetch the row's ID after upsert // Optionally fetch the row's ID (or entire row) after upsert:
const result = await db.get(` const result = await db.get(`
SELECT id SELECT id
FROM career_paths FROM career_paths
@ -166,7 +229,11 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
} }
}); });
/* ------------------------------------------------------------------
Milestone ENDPOINTS
------------------------------------------------------------------ */
// CREATE one or more milestones
app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) => { app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) => {
try { try {
const body = req.body; const body = req.body;
@ -183,12 +250,12 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
career_path_id, career_path_id,
progress, progress,
status, status,
new_salary new_salary,
is_universal
} = m; } = m;
// Validate some required fields // Validate some required fields
if (!milestone_type || !title || !date || !career_path_id) { if (!milestone_type || !title || !date || !career_path_id) {
// Optionally handle partial errors, but let's do a quick check
return res.status(400).json({ return res.status(400).json({
error: 'One or more milestones missing required fields', error: 'One or more milestones missing required fields',
details: m details: m
@ -210,9 +277,10 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
progress, progress,
status, status,
new_salary, new_salary,
is_universal,
created_at, created_at,
updated_at updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [ `, [
id, id,
req.userId, req.userId,
@ -224,6 +292,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
progress || 0, progress || 0,
status || 'planned', status || 'planned',
new_salary || null, new_salary || null,
is_universal ? 1 : 0, // store 1 or 0
now, now,
now now
]); ]);
@ -239,6 +308,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
progress: progress || 0, progress: progress || 0,
status: status || 'planned', status: status || 'planned',
new_salary: new_salary || null, new_salary: new_salary || null,
is_universal: is_universal ? 1 : 0,
tasks: [] tasks: []
}); });
} }
@ -246,7 +316,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
return res.status(201).json(createdMilestones); return res.status(201).json(createdMilestones);
} }
// CASE 2: Handle single milestone (the old logic) // CASE 2: Single milestone creation
const { const {
milestone_type, milestone_type,
title, title,
@ -255,7 +325,8 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
career_path_id, career_path_id,
progress, progress,
status, status,
new_salary new_salary,
is_universal
} = body; } = body;
if (!milestone_type || !title || !date || !career_path_id) { if (!milestone_type || !title || !date || !career_path_id) {
@ -280,9 +351,10 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
progress, progress,
status, status,
new_salary, new_salary,
is_universal,
created_at, created_at,
updated_at updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [ `, [
id, id,
req.userId, req.userId,
@ -294,6 +366,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
progress || 0, progress || 0,
status || 'planned', status || 'planned',
new_salary || null, new_salary || null,
is_universal ? 1 : 0,
now, now,
now now
]); ]);
@ -310,6 +383,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
progress: progress || 0, progress: progress || 0,
status: status || 'planned', status: status || 'planned',
new_salary: new_salary || null, new_salary: new_salary || null,
is_universal: is_universal ? 1 : 0,
tasks: [] tasks: []
}; };
@ -320,6 +394,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
} }
}); });
// UPDATE an existing milestone
app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (req, res) => { app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (req, res) => {
try { try {
const { milestoneId } = req.params; const { milestoneId } = req.params;
@ -331,7 +406,8 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
career_path_id, career_path_id,
progress, progress,
status, status,
new_salary new_salary,
is_universal
} = req.body; } = req.body;
// Check if milestone exists and belongs to user // Check if milestone exists and belongs to user
@ -346,8 +422,21 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
return res.status(404).json({ error: 'Milestone not found or not yours.' }); return res.status(404).json({ error: 'Milestone not found or not yours.' });
} }
// Update
const now = new Date().toISOString(); const now = new Date().toISOString();
// Merge fields with existing if not provided
const finalMilestoneType = milestone_type || existing.milestone_type;
const finalTitle = title || existing.title;
const finalDesc = description || existing.description;
const finalDate = date || existing.date;
const finalCareerPath = career_path_id || existing.career_path_id;
const finalProgress = progress != null ? progress : existing.progress;
const finalStatus = status || existing.status;
const finalSalary = new_salary != null ? new_salary : existing.new_salary;
const finalIsUniversal =
is_universal != null ? (is_universal ? 1 : 0) : existing.is_universal;
// Update row
await db.run(` await db.run(`
UPDATE milestones UPDATE milestones
SET SET
@ -359,19 +448,23 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
progress = ?, progress = ?,
status = ?, status = ?,
new_salary = ?, new_salary = ?,
is_universal = ?,
updated_at = ? updated_at = ?
WHERE id = ? WHERE id = ?
AND user_id = ?
`, [ `, [
milestone_type || existing.milestone_type, finalMilestoneType,
title || existing.title, finalTitle,
description || existing.description, finalDesc,
date || existing.date, finalDate,
career_path_id || existing.career_path_id, finalCareerPath,
progress != null ? progress : existing.progress, finalProgress,
status || existing.status, finalStatus,
new_salary != null ? new_salary : existing.new_salary, finalSalary,
finalIsUniversal,
now, now,
milestoneId milestoneId,
req.userId
]); ]);
// Return the updated record with tasks // Return the updated record with tasks
@ -400,11 +493,44 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
} }
}); });
// GET all milestones for a given careerPathId
app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => { app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => {
const { careerPathId } = req.query; const { careerPathId } = req.query;
try { try {
// 1. Fetch the milestones for this user + path // if user wants universal=1 only, e.g. careerPathId=universal
if (careerPathId === 'universal') {
// For example, fetch all is_universal=1 for the user:
const universalRows = await db.all(`
SELECT *
FROM milestones
WHERE user_id = ?
AND is_universal = 1
`, [req.userId]);
// attach tasks if needed
const milestoneIds = universalRows.map(m => m.id);
let tasksByMilestone = {};
if (milestoneIds.length > 0) {
const tasks = await db.all(`
SELECT *
FROM tasks
WHERE milestone_id IN (${milestoneIds.map(() => '?').join(',')})
`, milestoneIds);
tasksByMilestone = tasks.reduce((acc, t) => {
if (!acc[t.milestone_id]) acc[t.milestone_id] = [];
acc[t.milestone_id].push(t);
return acc;
}, {});
}
const uniMils = universalRows.map(m => ({
...m,
tasks: tasksByMilestone[m.id] || []
}));
return res.json({ milestones: uniMils });
}
// else fetch by careerPathId
const milestones = await db.all(` const milestones = await db.all(`
SELECT * SELECT *
FROM milestones FROM milestones
@ -412,7 +538,6 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
AND career_path_id = ? AND career_path_id = ?
`, [req.userId, careerPathId]); `, [req.userId, careerPathId]);
// 2. For each milestone, fetch tasks
const milestoneIds = milestones.map(m => m.id); const milestoneIds = milestones.map(m => m.id);
let tasksByMilestone = {}; let tasksByMilestone = {};
if (milestoneIds.length > 0) { if (milestoneIds.length > 0) {
@ -429,7 +554,6 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
}, {}); }, {});
} }
// 3. Attach tasks to each milestone object
const milestonesWithTasks = milestones.map(m => ({ const milestonesWithTasks = milestones.map(m => ({
...m, ...m,
tasks: tasksByMilestone[m.id] || [] tasks: tasksByMilestone[m.id] || []
@ -442,6 +566,259 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
} }
}); });
// COPY an existing milestone to other scenarios
app.post('/api/premium/milestone/copy', authenticatePremiumUser, async (req, res) => {
try {
const { milestoneId, scenarioIds } = req.body;
if (!milestoneId || !Array.isArray(scenarioIds) || scenarioIds.length === 0) {
return res.status(400).json({ error: 'Missing milestoneId or scenarioIds.' });
}
// 1) Fetch the original
const original = await db.get(`
SELECT *
FROM milestones
WHERE id = ?
AND user_id = ?
`, [milestoneId, req.userId]);
if (!original) {
return res.status(404).json({ error: 'Milestone not found or not owned by user.' });
}
// 2) Force is_universal=1 on the original
if (original.is_universal !== 1) {
await db.run(`
UPDATE milestones
SET is_universal = 1
WHERE id = ?
AND user_id = ?
`, [ milestoneId, req.userId ]);
// Also refresh "original" object if you want
original.is_universal = 1;
}
// 3) If no origin_milestone_id, set it
let originId = original.origin_milestone_id || original.id;
if (!original.origin_milestone_id) {
await db.run(`
UPDATE milestones
SET origin_milestone_id = ?
WHERE id = ?
AND user_id = ?
`, [ originId, milestoneId, req.userId ]);
}
// 4) fetch tasks & impacts
const tasks = await db.all(`
SELECT *
FROM tasks
WHERE milestone_id = ?
`, [milestoneId]);
const impacts = await db.all(`
SELECT *
FROM milestone_impacts
WHERE milestone_id = ?
`, [milestoneId]);
const now = new Date().toISOString();
const copiesCreated = [];
for (let scenarioId of scenarioIds) {
if (scenarioId === original.career_path_id) {
continue;
}
const newMilestoneId = uuidv4();
// Always set isUniversal=1 on copies
const isUniversal = 1;
await db.run(`
INSERT INTO milestones (
id,
user_id,
career_path_id,
milestone_type,
title,
description,
date,
progress,
status,
new_salary,
is_universal,
origin_milestone_id,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
newMilestoneId,
req.userId,
scenarioId,
original.milestone_type,
original.title,
original.description,
original.date,
original.progress,
original.status,
original.new_salary,
isUniversal,
originId,
now,
now
]);
// copy tasks
for (let t of tasks) {
const newTaskId = uuidv4();
await db.run(`
INSERT INTO tasks (
id,
milestone_id,
user_id,
title,
description,
due_date,
status,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 'not_started', ?, ?)
`, [
newTaskId,
newMilestoneId,
req.userId,
t.title,
t.description,
t.due_date || null,
now,
now
]);
}
// copy impacts
for (let imp of impacts) {
const newImpactId = uuidv4();
await db.run(`
INSERT INTO milestone_impacts (
id,
milestone_id,
impact_type,
direction,
amount,
start_date,
end_date,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
newImpactId,
newMilestoneId,
imp.impact_type,
imp.direction,
imp.amount,
imp.start_date || null,
imp.end_date || null,
now,
now
]);
}
copiesCreated.push(newMilestoneId);
}
return res.json({
originalId: milestoneId,
origin_milestone_id: originId,
copiesCreated
});
} catch (err) {
console.error('Error copying milestone:', err);
res.status(500).json({ error: 'Failed to copy milestone.' });
}
});
// DELETE milestone from ALL scenarios
app.delete('/api/premium/milestones/:milestoneId/all', authenticatePremiumUser, async (req, res) => {
const { milestoneId } = req.params;
try {
// 1) Fetch the milestone
const existing = await db.get(`
SELECT id, user_id, origin_milestone_id
FROM milestones
WHERE id = ?
AND user_id = ?
`, [milestoneId, req.userId]);
if (!existing) {
return res.status(404).json({ error: 'Milestone not found or not owned by user.' });
}
const originId = existing.origin_milestone_id || existing.id;
// 2) Delete all copies referencing that origin
await db.run(`
DELETE FROM milestones
WHERE user_id = ?
AND origin_milestone_id = ?
`, [req.userId, originId]);
// Also delete the original if it doesn't store itself in origin_milestone_id
await db.run(`
DELETE FROM milestones
WHERE user_id = ?
AND id = ?
AND origin_milestone_id IS NULL
`, [req.userId, originId]);
res.json({ message: 'Deleted from all scenarios' });
} catch (err) {
console.error('Error deleting milestone from all scenarios:', err);
res.status(500).json({ error: 'Failed to delete milestone from all scenarios.' });
}
});
// DELETE milestone from this scenario only
app.delete('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (req, res) => {
const { milestoneId } = req.params;
try {
// 1) check user ownership
const existing = await db.get(`
SELECT id, user_id
FROM milestones
WHERE id = ?
AND user_id = ?
`, [milestoneId, req.userId]);
if (!existing) {
return res.status(404).json({ error: 'Milestone not found or not owned by user.' });
}
// 2) Delete the single row
await db.run(`
DELETE FROM milestones
WHERE id = ?
AND user_id = ?
`, [milestoneId, req.userId]);
// optionally also remove tasks + impacts if you want
// e.g.:
// await db.run('DELETE FROM tasks WHERE milestone_id = ?', [milestoneId]);
// await db.run('DELETE FROM milestone_impacts WHERE milestone_id = ?', [milestoneId]);
res.json({ message: 'Milestone deleted from this scenario.' });
} catch (err) {
console.error('Error deleting single milestone:', err);
res.status(500).json({ error: 'Failed to delete milestone.' });
}
});
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
FINANCIAL PROFILES (Renamed emergency_contribution) FINANCIAL PROFILES (Renamed emergency_contribution)
------------------------------------------------------------------ */ ------------------------------------------------------------------ */

View File

@ -13,6 +13,9 @@ import MilestoneTracker from "./components/MilestoneTracker.js";
import Paywall from "./components/Paywall.js"; import Paywall from "./components/Paywall.js";
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js'; import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
// NEW: import your MultiScenarioView component
import MultiScenarioView from './components/MultiScenarioView.js';
import './App.css'; import './App.css';
function App() { function App() {
@ -21,7 +24,13 @@ function App() {
const [isAuthenticated, setIsAuthenticated] = useState(() => !!localStorage.getItem('token')); const [isAuthenticated, setIsAuthenticated] = useState(() => !!localStorage.getItem('token'));
const premiumPaths = ['/milestone-tracker', '/paywall', '/financial-profile']; // Any paths that are specifically “premium” (where you might not want to show an Upgrade CTA).
const premiumPaths = [
'/milestone-tracker',
'/paywall',
'/financial-profile',
'/multi-scenario', // ADDED here so the CTA is hidden on the multi-scenario page
];
const showPremiumCTA = !premiumPaths.includes(location.pathname); const showPremiumCTA = !premiumPaths.includes(location.pathname);
@ -55,11 +64,14 @@ function App() {
<Route path="/financial-profile" element={<FinancialProfileForm />} /> <Route path="/financial-profile" element={<FinancialProfileForm />} />
<Route path="/premium-onboarding" element={<OnboardingContainer />} /> <Route path="/premium-onboarding" element={<OnboardingContainer />} />
{/* NEW multi-scenario route */}
<Route path="/multi-scenario" element={<MultiScenarioView />} />
</> </>
)} )}
<Route path="*" element={<Navigate to="/signin" />} /> <Route path="*" element={<Navigate to="/signin" />} />
</Routes> </Routes>
<SessionExpiredHandler /> <SessionExpiredHandler />
</div> </div>
); );

View File

@ -3,21 +3,27 @@ import React, { useEffect, useState, useCallback } from 'react';
const today = new Date(); const today = new Date();
const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView, onMilestoneUpdated }) => { const MilestoneTimeline = ({
careerPathId,
authFetch,
activeView,
setActiveView,
onMilestoneUpdated // optional callback if you want the parent to be notified of changes
}) => {
const [milestones, setMilestones] = useState({ Career: [], Financial: [] }); const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
// The "new or edit" milestone form state // "new or edit" milestone form data
const [newMilestone, setNewMilestone] = useState({ const [newMilestone, setNewMilestone] = useState({
title: '', title: '',
description: '', description: '',
date: '', date: '',
progress: 0, progress: 0,
newSalary: '', newSalary: '',
// Each impact can have: { id?, impact_type, direction, amount, start_date, end_date } impacts: [],
impacts: [] isUniversal: 0
}); });
// We track which existing impacts the user removed, so we can DELETE them // We'll track which existing impacts are removed so we can do a DELETE if needed
const [impactsToDelete, setImpactsToDelete] = useState([]); const [impactsToDelete, setImpactsToDelete] = useState([]);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@ -27,10 +33,77 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
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: '' });
/** // For the Copy wizard
* Fetch all milestones (and their tasks) for this careerPathId. const [scenarios, setScenarios] = useState([]);
* Then categorize them by milestone_type: 'Career' or 'Financial'. const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
*/
// ------------------------------------------------------------------
// 1) Impact Helper Functions (define them first to avoid scoping errors)
// ------------------------------------------------------------------
// Insert a new blank impact into newMilestone.impacts
const addNewImpact = () => {
setNewMilestone(prev => ({
...prev,
impacts: [
...prev.impacts,
{
impact_type: 'ONE_TIME',
direction: 'subtract',
amount: 0,
start_date: '',
end_date: ''
}
]
}));
};
// Remove an impact from newMilestone.impacts
const removeImpact = (idx) => {
setNewMilestone(prev => {
const newImpacts = [...prev.impacts];
const removed = newImpacts[idx];
if (removed.id) {
// queue up for DB DELETE
setImpactsToDelete(old => [...old, removed.id]);
}
newImpacts.splice(idx, 1);
return { ...prev, impacts: newImpacts };
});
};
// Update a specific impact property
const updateImpact = (idx, field, value) => {
setNewMilestone(prev => {
const newImpacts = [...prev.impacts];
newImpacts[idx] = { ...newImpacts[idx], [field]: value };
return { ...prev, impacts: newImpacts };
});
};
// ------------------------------------------------------------------
// 2) Load scenarios (for copy wizard)
// ------------------------------------------------------------------
useEffect(() => {
async function loadScenarios() {
try {
const res = await authFetch('/api/premium/career-profile/all');
if (res.ok) {
const data = await res.json();
setScenarios(data.careerPaths || []);
} else {
console.error('Failed to load scenarios. Status:', res.status);
}
} catch (err) {
console.error('Error loading scenarios for copy wizard:', err);
}
}
loadScenarios();
}, [authFetch]);
// ------------------------------------------------------------------
// 3) Fetch milestones for the current scenario
// ------------------------------------------------------------------
const fetchMilestones = useCallback(async () => { const fetchMilestones = useCallback(async () => {
if (!careerPathId) return; if (!careerPathId) return;
try { try {
@ -41,12 +114,12 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
} }
const data = await res.json(); const data = await res.json();
if (!data.milestones) { if (!data.milestones) {
console.warn('No milestones field in response:', data); console.warn('No milestones returned:', data);
return; return;
} }
const categorized = { Career: [], Financial: [] }; const categorized = { Career: [], Financial: [] };
data.milestones.forEach((m) => { data.milestones.forEach(m => {
if (categorized[m.milestone_type]) { if (categorized[m.milestone_type]) {
categorized[m.milestone_type].push(m); categorized[m.milestone_type].push(m);
} else { } else {
@ -64,57 +137,52 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
fetchMilestones(); fetchMilestones();
}, [fetchMilestones]); }, [fetchMilestones]);
/** // ------------------------------------------------------------------
* Async function to edit an existing milestone. // 4) "Edit" an existing milestone => load impacts
* Fetch its impacts, populate newMilestone, show the form. // ------------------------------------------------------------------
*/
const handleEditMilestone = async (m) => { const handleEditMilestone = async (m) => {
try { try {
// Reset impactsToDelete whenever we edit a new milestone
setImpactsToDelete([]); setImpactsToDelete([]);
// Fetch existing impacts for milestone "m"
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`); const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
if (!res.ok) { if (!res.ok) {
console.error('Failed to fetch milestone impacts, status:', res.status); console.error('Failed to fetch milestone impacts. Status:', res.status);
throw new Error(`HTTP ${res.status}`); return;
} }
const data = await res.json(); const data = await res.json();
const fetchedImpacts = data.impacts || []; const fetchedImpacts = data.impacts || [];
// Populate the newMilestone form
setNewMilestone({ setNewMilestone({
title: m.title || '', title: m.title || '',
description: m.description || '', description: m.description || '',
date: m.date || '', date: m.date || '',
progress: m.progress || 0, progress: m.progress || 0,
newSalary: m.new_salary || '', newSalary: m.new_salary || '',
impacts: fetchedImpacts.map((imp) => ({ impacts: fetchedImpacts.map(imp => ({
// If the DB row has id, we'll store it for PUT or DELETE
id: imp.id, id: imp.id,
impact_type: imp.impact_type || 'ONE_TIME', impact_type: imp.impact_type || 'ONE_TIME',
direction: imp.direction || 'subtract', direction: imp.direction || 'subtract',
amount: imp.amount || 0, amount: imp.amount || 0,
start_date: imp.start_date || '', start_date: imp.start_date || '',
end_date: imp.end_date || '' end_date: imp.end_date || ''
})) })),
isUniversal: m.is_universal ? 1 : 0
}); });
setEditingMilestone(m); setEditingMilestone(m);
setShowForm(true); setShowForm(true);
} catch (err) { } catch (err) {
console.error('Error in handleEditMilestone:', err); console.error('Error editing milestone:', err);
} }
}; };
/** // ------------------------------------------------------------------
* Create or update a milestone (plus handle impacts). // 5) Save (create or update) a milestone => handle impacts if needed
*/ // ------------------------------------------------------------------
const saveMilestone = async () => { const saveMilestone = async () => {
if (!activeView) return; if (!activeView) return;
// If editing, we do PUT; otherwise POST
const url = editingMilestone const url = editingMilestone
? `/api/premium/milestones/${editingMilestone.id}` ? `/api/premium/milestones/${editingMilestone.id}`
: `/api/premium/milestone`; : `/api/premium/milestone`;
@ -131,143 +199,126 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
new_salary: new_salary:
activeView === 'Financial' && newMilestone.newSalary activeView === 'Financial' && newMilestone.newSalary
? parseFloat(newMilestone.newSalary) ? parseFloat(newMilestone.newSalary)
: null : null,
is_universal: newMilestone.isUniversal || 0
}; };
try { try {
console.log('Sending request:', method, url, payload);
const res = await authFetch(url, { const res = await authFetch(url, {
method, 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) {
const errorData = await res.json(); const errData = await res.json();
console.error('Failed to save milestone:', errorData); console.error('Failed to save milestone:', errData);
alert(errorData.error || 'Error saving milestone'); alert(errData.error || 'Error saving milestone');
return; return;
} }
if (onMilestoneUpdated) onMilestoneUpdated();
const savedMilestone = await res.json(); const savedMilestone = await res.json();
console.log('Milestone saved/updated:', savedMilestone); console.log('Milestone saved/updated:', savedMilestone);
// If Financial, handle the "impacts" // If financial => handle impacts
if (activeView === 'Financial') { if (activeView === 'Financial') {
// 1) Delete impacts that user removed // 1) Delete old impacts
for (const impactId of impactsToDelete) { for (const impactId of impactsToDelete) {
if (impactId) { if (impactId) {
console.log('Deleting old impact', impactId);
const delRes = await authFetch(`/api/premium/milestone-impacts/${impactId}`, { const delRes = await authFetch(`/api/premium/milestone-impacts/${impactId}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (!delRes.ok) { if (!delRes.ok) {
console.error('Failed to delete old impact', impactId, await delRes.text()); console.error('Failed deleting old impact', impactId, await delRes.text());
} }
} }
} }
// 2) For each current impact in newMilestone.impacts // 2) Insert/Update new impacts
// We'll track the index so we can store the newly created ID if needed
for (let i = 0; i < newMilestone.impacts.length; i++) { for (let i = 0; i < newMilestone.impacts.length; i++) {
const impact = newMilestone.impacts[i]; const imp = newMilestone.impacts[i];
if (impact.id) { if (imp.id) {
// existing row => PUT // existing => PUT
const putPayload = { const putPayload = {
milestone_id: savedMilestone.id, milestone_id: savedMilestone.id,
impact_type: impact.impact_type, impact_type: imp.impact_type,
direction: impact.direction, direction: imp.direction,
amount: parseFloat(impact.amount) || 0, amount: parseFloat(imp.amount) || 0,
start_date: impact.start_date || null, start_date: imp.start_date || null,
end_date: impact.end_date || null end_date: imp.end_date || null
}; };
console.log('Updating milestone impact:', impact.id, putPayload); const impRes = await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
const impRes = await authFetch(`/api/premium/milestone-impacts/${impact.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(putPayload) body: JSON.stringify(putPayload)
}); });
if (!impRes.ok) { if (!impRes.ok) {
const errImp = await impRes.json(); const errImp = await impRes.json();
console.error('Failed to update milestone impact:', errImp); console.error('Failed updating existing impact:', errImp);
} else {
const updatedImpact = await impRes.json();
console.log('Updated Impact:', updatedImpact);
} }
} else { } else {
// [FIX HERE] If no id => POST to create new // new => POST
const impactPayload = { const postPayload = {
milestone_id: savedMilestone.id, milestone_id: savedMilestone.id,
impact_type: impact.impact_type, impact_type: imp.impact_type,
direction: impact.direction, direction: imp.direction,
amount: parseFloat(impact.amount) || 0, amount: parseFloat(imp.amount) || 0,
start_date: impact.start_date || null, start_date: imp.start_date || null,
end_date: impact.end_date || null end_date: imp.end_date || null
}; };
console.log('Creating milestone impact:', impactPayload);
const impRes = await authFetch('/api/premium/milestone-impacts', { const impRes = await authFetch('/api/premium/milestone-impacts', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(impactPayload) body: JSON.stringify(postPayload)
}); });
if (!impRes.ok) { if (!impRes.ok) {
const errImp = await impRes.json(); console.error('Failed creating new impact:', await impRes.text());
console.error('Failed to create milestone impact:', errImp);
} else {
const createdImpact = await impRes.json();
if (createdImpact && createdImpact.id) {
setNewMilestone(prev => {
const newImpacts = [...prev.impacts];
newImpacts[i] = { ...newImpacts[i], id: createdImpact.id };
return { ...prev, impacts: newImpacts };
});
}
} }
} }
} }
} }
// Update local state so we don't have to refetch everything // optional local state update to avoid re-fetch
setMilestones((prev) => { setMilestones((prev) => {
const updated = { ...prev }; const newState = { ...prev };
if (editingMilestone) { if (editingMilestone) {
updated[activeView] = updated[activeView].map((m) => newState[activeView] = newState[activeView].map(m =>
m.id === editingMilestone.id ? savedMilestone : m m.id === editingMilestone.id ? savedMilestone : m
); );
} else { } else {
updated[activeView].push(savedMilestone); newState[activeView].push(savedMilestone);
} }
return updated; return newState;
}); });
// Reset form // reset the form
setShowForm(false); setShowForm(false);
setEditingMilestone(null); setEditingMilestone(null);
// [FIX HERE] The next line ensures the updated or newly created impact IDs
// stay in the local state if the user tries to edit the milestone again
// in the same session.
setNewMilestone({ setNewMilestone({
title: '', title: '',
description: '', description: '',
date: '', date: '',
progress: 0, progress: 0,
newSalary: '', newSalary: '',
impacts: [] impacts: [],
isUniversal: 0
}); });
setImpactsToDelete([]); setImpactsToDelete([]);
// optionally re-fetch from DB
// await fetchMilestones();
if (onMilestoneUpdated) {
onMilestoneUpdated();
}
} catch (err) { } catch (err) {
console.error('Error saving milestone:', err); console.error('Error saving milestone:', err);
} }
}; };
/** // ------------------------------------------------------------------
* Add a new task to an existing milestone // 6) addTask => attach a new task to an existing milestone
*/ // ------------------------------------------------------------------
const addTask = async (milestoneId) => { const addTask = async (milestoneId) => {
try { try {
const taskPayload = { const taskPayload = {
@ -292,11 +343,11 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
const createdTask = await res.json(); const createdTask = await res.json();
console.log('Task created:', createdTask); console.log('Task created:', createdTask);
// Update the milestone's tasks in local state // update local state
setMilestones((prev) => { setMilestones((prev) => {
const newState = { ...prev }; const newState = { ...prev };
['Career', 'Financial'].forEach((category) => { ['Career', 'Financial'].forEach((cat) => {
newState[category] = newState[category].map((m) => { newState[cat] = newState[cat].map((m) => {
if (m.id === milestoneId) { if (m.id === milestoneId) {
return { return {
...m, ...m,
@ -316,7 +367,135 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
} }
}; };
// For timeline // ------------------------------------------------------------------
// 7) "Copy" wizard -> after copying => re-fetch or local update
// ------------------------------------------------------------------
function CopyMilestoneWizard({ milestone, scenarios, onClose, authFetch }) {
const [selectedScenarios, setSelectedScenarios] = useState([]);
if (!milestone) return null;
function toggleScenario(scenarioId) {
setSelectedScenarios(prev => {
if (prev.includes(scenarioId)) {
return prev.filter(id => id !== scenarioId);
} else {
return [...prev, scenarioId];
}
});
}
async function handleCopy() {
try {
const res = await authFetch('/api/premium/milestone/copy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
milestoneId: milestone.id,
scenarioIds: selectedScenarios
})
});
if (!res.ok) throw new Error('Failed to copy milestone');
const data = await res.json();
console.log('Copied milestone to new scenarios:', data);
onClose(); // close wizard
// re-fetch or update local
await fetchMilestones();
if (onMilestoneUpdated) {
onMilestoneUpdated();
}
} catch (err) {
console.error('Error copying milestone:', err);
}
}
return (
<div className="modal-backdrop">
<div className="modal-container">
<h3>Copy Milestone to Other Scenarios</h3>
<p>Milestone: <strong>{milestone.title}</strong></p>
{scenarios.map(s => (
<div key={s.id}>
<label>
<input
type="checkbox"
checked={selectedScenarios.includes(s.id)}
onChange={() => toggleScenario(s.id)}
/>
{s.career_name}
</label>
</div>
))}
<div style={{ marginTop: '1rem' }}>
<button onClick={onClose} style={{ marginRight: '0.5rem' }}>Cancel</button>
<button onClick={handleCopy}>Copy</button>
</div>
</div>
</div>
);
}
// ------------------------------------------------------------------
// 8) Delete milestone => single or all
// ------------------------------------------------------------------
async function handleDeleteMilestone(m) {
if (m.is_universal === 1) {
const userChoice = window.confirm(
'This milestone is universal. OK => remove from ALL scenarios, Cancel => remove only from this scenario.'
);
if (userChoice) {
// delete from all
try {
const delAll = await authFetch(`/api/premium/milestones/${m.id}/all`, {
method: 'DELETE'
});
if (!delAll.ok) {
console.error('Failed removing universal from all. Status:', delAll.status);
return;
}
// re-fetch
await fetchMilestones();
if (onMilestoneUpdated) {
onMilestoneUpdated();
}
} catch (err) {
console.error('Error deleting universal milestone from all:', err);
}
} else {
// remove from single scenario
await deleteSingleMilestone(m);
}
} else {
// normal => single scenario
await deleteSingleMilestone(m);
}
}
async function deleteSingleMilestone(m) {
try {
const delRes = await authFetch(`/api/premium/milestones/${m.id}`, { method: 'DELETE' });
if (!delRes.ok) {
console.error('Failed to delete single milestone:', delRes.status);
return;
}
// re-fetch
await fetchMilestones();
if (onMilestoneUpdated) {
onMilestoneUpdated();
}
} catch (err) {
console.error('Error removing milestone from scenario:', err);
}
}
// ------------------------------------------------------------------
// 9) Positioning in the timeline
// ------------------------------------------------------------------
const allMilestonesCombined = [...milestones.Career, ...milestones.Financial]; const allMilestonesCombined = [...milestones.Career, ...milestones.Financial];
const lastDate = allMilestonesCombined.reduce((latest, m) => { const lastDate = allMilestonesCombined.reduce((latest, m) => {
const d = new Date(m.date); const d = new Date(m.date);
@ -332,61 +511,13 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
return Math.min(Math.max(ratio * 100, 0), 100); return Math.min(Math.max(ratio * 100, 0), 100);
}; };
/** // ------------------------------------------------------------------
* Add a new empty impact (no id => new) // Render
*/ // ------------------------------------------------------------------
const addNewImpact = () => {
setNewMilestone((prev) => ({
...prev,
impacts: [
...prev.impacts,
{
// no 'id' => brand new
impact_type: 'ONE_TIME',
direction: 'subtract',
amount: 0,
start_date: '',
end_date: ''
}
]
}));
};
/**
* Remove an impact from the UI. If it had an `id`, track it in impactsToDelete for later DELETE call.
*/
const removeImpact = (idx) => {
setNewMilestone((prev) => {
const newImpacts = [...prev.impacts];
const removed = newImpacts[idx];
if (removed.id) {
setImpactsToDelete((old) => [...old, removed.id]);
}
newImpacts.splice(idx, 1);
return { ...prev, impacts: newImpacts };
});
};
const updateImpact = (idx, field, value) => {
setNewMilestone((prev) => {
const newImpacts = [...prev.impacts];
newImpacts[idx] = { ...newImpacts[idx], [field]: value };
return { ...prev, impacts: newImpacts };
});
};
if (!activeView || !milestones[activeView]) {
return (
<div className="milestone-timeline">
<p>Loading or no milestones in this view...</p>
</div>
);
}
return ( return (
<div className="milestone-timeline"> <div className="milestone-timeline">
<div className="view-selector"> <div className="view-selector">
{['Career', 'Financial'].map((view) => ( {['Career', 'Financial'].map(view => (
<button <button
key={view} key={view}
className={activeView === view ? 'active' : ''} className={activeView === view ? 'active' : ''}
@ -397,11 +528,11 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
))} ))}
</div> </div>
{/* New Milestone button */} {/* + New Milestone button */}
<button <button
onClick={() => { onClick={() => {
if (showForm) { if (showForm) {
// Cancel form // Cancel
setShowForm(false); setShowForm(false);
setEditingMilestone(null); setEditingMilestone(null);
setNewMilestone({ setNewMilestone({
@ -410,7 +541,8 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
date: '', date: '',
progress: 0, progress: 0,
newSalary: '', newSalary: '',
impacts: [] impacts: [],
isUniversal: 0
}); });
setImpactsToDelete([]); setImpactsToDelete([]);
} else { } else {
@ -439,7 +571,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
type="date" type="date"
placeholder="Milestone Date" placeholder="Milestone Date"
value={newMilestone.date} value={newMilestone.date}
onChange={(e) => setNewMilestone((prev) => ({ ...prev, date: e.target.value }))} onChange={(e) => setNewMilestone(prev => ({ ...prev, date: e.target.value }))}
/> />
<input <input
type="number" type="number"
@ -447,7 +579,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
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); const val = e.target.value === '' ? 0 : parseInt(e.target.value, 10);
setNewMilestone((prev) => ({ ...prev, progress: val })); setNewMilestone(prev => ({ ...prev, progress: val }));
}} }}
/> />
@ -455,7 +587,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
<div> <div>
<input <input
type="number" type="number"
placeholder="Full New Salary (e.g., 70000)" placeholder="Full New Salary (e.g. 70000)"
value={newMilestone.newSalary} value={newMilestone.newSalary}
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })} onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })}
/> />
@ -535,12 +667,30 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
</div> </div>
)} )}
<button onClick={saveMilestone}> {/* universal checkbox */}
<div style={{ marginTop: '1rem' }}>
<label>
<input
type="checkbox"
checked={!!newMilestone.isUniversal}
onChange={(e) =>
setNewMilestone(prev => ({
...prev,
isUniversal: e.target.checked ? 1 : 0
}))
}
/>
{' '}Apply this milestone to all scenarios?
</label>
</div>
<button onClick={saveMilestone} style={{ marginTop: '1rem' }}>
{editingMilestone ? 'Update' : 'Add'} Milestone {editingMilestone ? 'Update' : 'Add'} Milestone
</button> </button>
</div> </div>
)} )}
{/* Timeline */}
<div className="milestone-timeline-container"> <div className="milestone-timeline-container">
<div className="milestone-timeline-line" /> <div className="milestone-timeline-line" />
@ -586,6 +736,23 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'} {showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
</button> </button>
{/* Edit, Copy, Delete Buttons */}
<div style={{ marginTop: '0.5rem' }}>
<button onClick={() => handleEditMilestone(m)}>Edit</button>
<button
style={{ marginLeft: '0.5rem' }}
onClick={() => setCopyWizardMilestone(m)}
>
Copy
</button>
<button
style={{ marginLeft: '0.5rem', color: 'red' }}
onClick={() => handleDeleteMilestone(m)}
>
Delete
</button>
</div>
{showTaskForm === m.id && ( {showTaskForm === m.id && (
<div className="task-form"> <div className="task-form">
<input <input
@ -613,6 +780,17 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
); );
})} })}
</div> </div>
{/* CopyWizard modal if copying */}
{copyWizardMilestone && (
<CopyMilestoneWizard
milestone={copyWizardMilestone}
scenarios={scenarios}
onClose={() => setCopyWizardMilestone(null)}
authFetch={authFetch}
onMilestoneUpdated={onMilestoneUpdated}
/>
)}
</div> </div>
); );
}; };

View File

@ -40,27 +40,35 @@ ChartJS.register(
const MilestoneTracker = ({ selectedCareer: initialCareer }) => { const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const apiURL = process.env.REACT_APP_API_URL; const apiURL = process.env.REACT_APP_API_URL;
// ------------------------- // --------------------------------------------------
// State // State
// ------------------------- // --------------------------------------------------
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 [pendingCareerForModal, setPendingCareerForModal] = useState(null);
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) from GET /api/premium/career-profile/:careerPathId
const [scenarioRow, setScenarioRow] = useState(null);
// scenario's collegeProfile row
const [collegeProfile, setCollegeProfile] = useState(null); const [collegeProfile, setCollegeProfile] = useState(null);
// Simulation results
const [projectionData, setProjectionData] = useState([]); const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null); const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
// Possibly let user type the simulation length
const [simulationYearsInput, setSimulationYearsInput] = useState("20"); const [simulationYearsInput, setSimulationYearsInput] = useState("20");
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
// Possibly loaded from location.state // Possibly loaded from location.state
const { const {
@ -68,10 +76,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
loanPayoffMonth: initialLoanPayoffMonth = null loanPayoffMonth: initialLoanPayoffMonth = null
} = location.state || {}; } = location.state || {};
const simulationYears = parseInt(simulationYearsInput, 10) || 20; // --------------------------------------------------
// ------------------------- // 1) Fetch users scenario list + financialProfile
// 1. Fetch career paths + financialProfile on mount // --------------------------------------------------
// -------------------------
useEffect(() => { useEffect(() => {
const fetchCareerPaths = async () => { const fetchCareerPaths = async () => {
const res = await authFetch(`${apiURL}/premium/career-profile/all`); const res = await authFetch(`${apiURL}/premium/career-profile/all`);
@ -84,7 +91,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) {
// Try to fetch the latest // fallback to latest
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();
@ -98,7 +105,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
const fetchFinancialProfile = async () => { const fetchFinancialProfile = async () => {
const res = await authFetch(`${apiURL}/premium/financial-profile`); const res = await authFetch(`${apiURL}/premium/financial-profile`);
if (res && res.ok) { if (res?.ok) {
const data = await res.json(); const data = await res.json();
setFinancialProfile(data); setFinancialProfile(data);
} }
@ -108,256 +115,265 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
fetchFinancialProfile(); fetchFinancialProfile();
}, [apiURL, location.state, selectedCareer]); }, [apiURL, location.state, selectedCareer]);
// ------------------------- // --------------------------------------------------
// 2. Fetch the college profile for the selected careerPathId // 2) When careerPathId changes => fetch scenarioRow + collegeProfile
// ------------------------- // --------------------------------------------------
useEffect(() => { useEffect(() => {
if (!careerPathId) { if (!careerPathId) {
setScenarioRow(null);
setCollegeProfile(null); setCollegeProfile(null);
return; return;
} }
const fetchCollegeProfile = async () => { async function fetchScenario() {
const res = await authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`); const scenRes = await authFetch(`${apiURL}/premium/career-profile/${careerPathId}`);
if (!res || !res.ok) { if (scenRes.ok) {
const data = await scenRes.json();
setScenarioRow(data);
} else {
console.error('Failed to fetch scenario row:', scenRes.status);
setScenarioRow(null);
}
}
async function fetchCollege() {
const colRes = await authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`);
if (!colRes?.ok) {
setCollegeProfile(null); setCollegeProfile(null);
return; return;
} }
const data = await res.json(); const data = await colRes.json();
setCollegeProfile(data); setCollegeProfile(data);
}; }
fetchCollegeProfile(); fetchScenario();
fetchCollege();
}, [careerPathId, apiURL]); }, [careerPathId, apiURL]);
// ------------------------- // --------------------------------------------------
// 3. Initial simulation when profiles + career loaded // 3) Once we have (financialProfile, scenarioRow, collegeProfile),
// (But this does NOT update after milestone changes yet) // run initial simulation with the scenario's milestones + impacts
// ------------------------- // --------------------------------------------------
useEffect(() => { useEffect(() => {
if (!financialProfile || !collegeProfile || !selectedCareer || !careerPathId) return; if (!financialProfile || !scenarioRow || !collegeProfile) return;
// 1) Fetch the raw milestones for this careerPath
(async () => { (async () => {
try { try {
// 1) load milestones for 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'); console.error('Failed to fetch initial milestones for scenario', careerPathId);
return; return;
} }
const milestonesData = await milRes.json(); const milestonesData = await milRes.json();
const allMilestones = milestonesData.milestones || []; const allMilestones = milestonesData.milestones || [];
// 2) For each milestone, fetch impacts // 2) 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.error('Failed fetching impacts for milestone', m.id, err); console.warn('Error fetching impacts for milestone', m.id, err);
return []; return [];
}) })
); );
const impactsForEach = await Promise.all(impactPromises); const impactsForEach = await Promise.all(impactPromises);
const milestonesWithImpacts = allMilestones.map((m, i) => ({ const milestonesWithImpacts = allMilestones.map((m, i) => ({
...m, ...m,
impacts: impactsForEach[i] || [], impacts: impactsForEach[i] || []
})); }));
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts);
// 3) Flatten them // 3) Build the merged profile w/ scenario overrides
const allImpacts = milestonesWithImpacts.flatMap((m) => m.impacts || []); const mergedProfile = buildMergedProfile(
financialProfile,
scenarioRow,
collegeProfile,
allImpacts,
simulationYears
);
// 4) Build the mergedProfile (like you already do) // 4) run the simulation
const mergedProfile = { const { projectionData: pData, loanPaidOffMonth: payoff } =
// From financialProfile
currentSalary: financialProfile.current_salary || 0,
monthlyExpenses: financialProfile.monthly_expenses || 0,
monthlyDebtPayments: financialProfile.monthly_debt_payments || 0,
retirementSavings: financialProfile.retirement_savings || 0,
emergencySavings: financialProfile.emergency_fund || 0,
monthlyRetirementContribution: financialProfile.retirement_contribution || 0,
monthlyEmergencyContribution: financialProfile.emergency_contribution || 0,
surplusEmergencyAllocation: financialProfile.extra_cash_emergency_pct || 50,
surplusRetirementAllocation: financialProfile.extra_cash_retirement_pct || 50,
// From collegeProfile
studentLoanAmount: collegeProfile.existing_college_debt || 0,
interestRate: collegeProfile.interest_rate || 5,
loanTerm: collegeProfile.loan_term || 10,
loanDeferralUntilGraduation: !!collegeProfile.loan_deferral_until_graduation,
academicCalendar: collegeProfile.academic_calendar || 'monthly',
annualFinancialAid: collegeProfile.annual_financial_aid || 0,
calculatedTuition: collegeProfile.tuition || 0,
extraPayment: collegeProfile.extra_payment || 0,
inCollege:
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
collegeProfile.college_enrollment_status === 'prospective_student',
gradDate: collegeProfile.expected_graduation || null,
programType: collegeProfile.program_type,
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
hoursCompleted: collegeProfile.hours_completed || 0,
programLength: collegeProfile.program_length || 0,
startDate: new Date().toISOString(),
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary,
// The key: impacts
milestoneImpacts: allImpacts,
simulationYears,
};
// 5) Run the simulation
const { projectionData: initialProjData, loanPaidOffMonth: payoff } =
simulateFinancialProjection(mergedProfile); simulateFinancialProjection(mergedProfile);
let cumulativeSavings = mergedProfile.emergencySavings || 0; // 5) If you track cumulative net
const finalData = initialProjData.map((month) => { let cumu = mergedProfile.emergencySavings || 0;
cumulativeSavings += (month.netSavings || 0); const finalData = pData.map(mo => {
return { ...month, cumulativeNetSavings: cumulativeSavings }; cumu += (mo.netSavings || 0);
return { ...mo, cumulativeNetSavings: cumu };
}); });
setProjectionData(finalData); setProjectionData(finalData);
setLoanPayoffMonth(payoff); setLoanPayoffMonth(payoff);
} catch (err) { } catch (err) {
console.error('Error fetching initial milestones/impacts or simulating:', err); console.error('Error in initial scenario simulation:', err);
} }
})(); })();
}, [financialProfile, collegeProfile, simulationYears, selectedCareer, careerPathId]); }, [
financialProfile,
scenarioRow,
collegeProfile,
simulationYears,
careerPathId,
apiURL
]);
const handleSimulationYearsChange = (e) => { // Merges the real snapshot w/ scenario overrides + milestones
setSimulationYearsInput(e.target.value); // let user type partial/blank function buildMergedProfile(finProf, scenRow, colProf, milestoneImpacts, simYears) {
return {
// Real snapshot fallback
currentSalary: finProf.current_salary || 0,
monthlyExpenses:
scenRow.planned_monthly_expenses ?? finProf.monthly_expenses ?? 0,
monthlyDebtPayments:
scenRow.planned_monthly_debt_payments ?? finProf.monthly_debt_payments ?? 0,
retirementSavings: finProf.retirement_savings ?? 0,
emergencySavings: finProf.emergency_fund ?? 0,
monthlyRetirementContribution:
scenRow.planned_monthly_retirement_contribution ??
finProf.retirement_contribution ??
0,
monthlyEmergencyContribution:
scenRow.planned_monthly_emergency_contribution ??
finProf.emergency_contribution ??
0,
surplusEmergencyAllocation:
scenRow.planned_surplus_emergency_pct ??
finProf.extra_cash_emergency_pct ??
50,
surplusRetirementAllocation:
scenRow.planned_surplus_retirement_pct ??
finProf.extra_cash_retirement_pct ??
50,
additionalIncome:
scenRow.planned_additional_income ?? finProf.additional_income ?? 0,
// College stuff
studentLoanAmount: colProf.existing_college_debt || 0,
interestRate: colProf.interest_rate || 5,
loanTerm: colProf.loan_term || 10,
loanDeferralUntilGraduation: !!colProf.loan_deferral_until_graduation,
academicCalendar: colProf.academic_calendar || 'monthly',
annualFinancialAid: colProf.annual_financial_aid || 0,
calculatedTuition: colProf.tuition || 0,
extraPayment: colProf.extra_payment || 0,
inCollege:
colProf.college_enrollment_status === 'currently_enrolled' ||
colProf.college_enrollment_status === 'prospective_student',
gradDate: colProf.expected_graduation || null,
programType: colProf.program_type,
creditHoursPerYear: colProf.credit_hours_per_year || 0,
hoursCompleted: colProf.hours_completed || 0,
programLength: colProf.program_length || 0,
expectedSalary: colProf.expected_salary || finProf.current_salary || 0,
// Additional
startDate: new Date().toISOString(),
simulationYears: simYears,
// Milestone Impacts
milestoneImpacts: milestoneImpacts || []
}; };
const handleSimulationYearsBlur = () => {
// Optionally, onBlur you can “normalize” the value
// (e.g. if they left it blank, revert to "20").
if (simulationYearsInput.trim() === "") {
setSimulationYearsInput("20");
} }
};
// ------------------------------------------------- // ------------------------------------------------------
// 4. reSimulate() => re-fetch everything (financial, college, milestones & impacts), // 4) reSimulate => after milestone changes or user toggles something
// re-run the simulation. This is triggered AFTER user updates a milestone in MilestoneTimeline. // ------------------------------------------------------
// -------------------------------------------------
const reSimulate = async () => { const reSimulate = async () => {
if (!careerPathId) return; if (!careerPathId) return;
try { try {
// 1) Fetch financial + college + raw milestones // 1) fetch everything again
const [finResp, colResp, milResp] = await Promise.all([ const [finResp, scenResp, colResp, milResp] = await Promise.all([
authFetch(`${apiURL}/premium/financial-profile`), authFetch(`${apiURL}/premium/financial-profile`),
authFetch(`${apiURL}/premium/career-profile/${careerPathId}`),
authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`), authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`),
authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`) authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`)
]); ]);
if (!finResp.ok || !colResp.ok || !milResp.ok) { if (!finResp.ok || !scenResp.ok || !colResp.ok || !milResp.ok) {
console.error('One reSimulate fetch failed:', finResp.status, colResp.status, milResp.status); console.error(
'One reSimulate fetch failed:',
finResp.status,
scenResp.status,
colResp.status,
milResp.status
);
return; return;
} }
const [updatedFinancial, updatedCollege, milestonesData] = await Promise.all([ const [updatedFinancial, updatedScenario, updatedCollege, milData] =
await Promise.all([
finResp.json(), finResp.json(),
scenResp.json(),
colResp.json(), colResp.json(),
milResp.json() milResp.json()
]); ]);
// 2) For each milestone, fetch its impacts separately (if not already included) const allMilestones = milData.milestones || [];
const allMilestones = milestonesData.milestones || [];
const impactsPromises = allMilestones.map(m => const impactsPromises = 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.error('Failed fetching impacts for milestone', m.id, err); console.warn('Impact fetch err for milestone', m.id, err);
return []; return [];
}) })
); );
const impactsForEach = await Promise.all(impactsPromises); const impactsForEach = await Promise.all(impactsPromises);
// Merge them onto the milestone array if desired
const milestonesWithImpacts = allMilestones.map((m, i) => ({ const milestonesWithImpacts = allMilestones.map((m, i) => ({
...m, ...m,
impacts: impactsForEach[i] || [] impacts: impactsForEach[i] || []
})); }));
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts);
// Flatten or gather all impacts if your simulation function needs them // 2) Build merged
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts || []); const mergedProfile = buildMergedProfile(
updatedFinancial,
updatedScenario,
updatedCollege,
allImpacts,
simulationYears
);
// 3) Build mergedProfile // 3) run
const mergedProfile = {
// From updatedFinancial
currentSalary: updatedFinancial.current_salary || 0,
monthlyExpenses: updatedFinancial.monthly_expenses || 0,
monthlyDebtPayments: updatedFinancial.monthly_debt_payments || 0,
retirementSavings: updatedFinancial.retirement_savings || 0,
emergencySavings: updatedFinancial.emergency_fund || 0,
monthlyRetirementContribution: updatedFinancial.retirement_contribution || 0,
monthlyEmergencyContribution: updatedFinancial.emergency_contribution || 0,
surplusEmergencyAllocation: updatedFinancial.extra_cash_emergency_pct || 50,
surplusRetirementAllocation: updatedFinancial.extra_cash_retirement_pct || 50,
// From updatedCollege
studentLoanAmount: updatedCollege.existing_college_debt || 0,
interestRate: updatedCollege.interest_rate || 5,
loanTerm: updatedCollege.loan_term || 10,
loanDeferralUntilGraduation: !!updatedCollege.loan_deferral_until_graduation,
academicCalendar: updatedCollege.academic_calendar || 'monthly',
annualFinancialAid: updatedCollege.annual_financial_aid || 0,
calculatedTuition: updatedCollege.tuition || 0,
extraPayment: updatedCollege.extra_payment || 0,
inCollege:
updatedCollege.college_enrollment_status === 'currently_enrolled' ||
updatedCollege.college_enrollment_status === 'prospective_student',
gradDate: updatedCollege.expected_graduation || null,
programType: updatedCollege.program_type,
creditHoursPerYear: updatedCollege.credit_hours_per_year || 0,
hoursCompleted: updatedCollege.hours_completed || 0,
programLength: updatedCollege.program_length || 0,
startDate: new Date().toISOString(),
expectedSalary: updatedCollege.expected_salary || updatedFinancial.current_salary,
// The key: pass the impacts to the simulation if needed
milestoneImpacts: allImpacts
};
// 4) Re-run simulation
const { projectionData: newProjData, loanPaidOffMonth: payoff } = const { projectionData: newProjData, loanPaidOffMonth: payoff } =
simulateFinancialProjection(mergedProfile); simulateFinancialProjection(mergedProfile);
// 5) If you track cumulative net savings: // 4) cumulative
let cumulativeSavings = mergedProfile.emergencySavings || 0; let csum = mergedProfile.emergencySavings || 0;
const finalData = newProjData.map(month => { const finalData = newProjData.map(mo => {
cumulativeSavings += (month.netSavings || 0); csum += (mo.netSavings || 0);
return { ...month, cumulativeNetSavings: cumulativeSavings }; return { ...mo, cumulativeNetSavings: csum };
}); });
// 6) Update states => triggers chart refresh
setProjectionData(finalData); setProjectionData(finalData);
setLoanPayoffMonth(payoff); setLoanPayoffMonth(payoff);
// Optionally store the new profiles in state if you like // also store updated scenario, financial, college
setFinancialProfile(updatedFinancial); setFinancialProfile(updatedFinancial);
setScenarioRow(updatedScenario);
setCollegeProfile(updatedCollege); setCollegeProfile(updatedCollege);
console.log('Re-simulated after Milestone update!', { console.log('Re-simulated after milestone update', { mergedProfile, finalData });
mergedProfile,
milestonesWithImpacts
});
} catch (err) { } catch (err) {
console.error('Error in reSimulate:', err); console.error('Error in reSimulate:', err);
} }
}; };
// ... // handle user typing simulation length
// The rest of your component logic const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value);
// ... const handleSimulationYearsBlur = () => {
if (!simulationYearsInput.trim()) {
setSimulationYearsInput("20");
}
};
// Logging
console.log( console.log(
'First 5 items of projectionData:', 'First 5 items of projectionData:',
Array.isArray(projectionData) ? projectionData.slice(0, 5) : 'projectionData not yet available' Array.isArray(projectionData) ? projectionData.slice(0, 5) : 'none'
); );
return ( return (
@ -373,7 +389,6 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
authFetch={authFetch} authFetch={authFetch}
/> />
{/* Pass reSimulate as onMilestoneUpdated: */}
<MilestoneTimeline <MilestoneTimeline
careerPathId={careerPathId} careerPathId={careerPathId}
authFetch={authFetch} authFetch={authFetch}
@ -470,7 +485,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
</div> </div>
)} )}
<div> <div className="mt-4">
<label>Simulation Length (years): </label> <label>Simulation Length (years): </label>
<input <input
type="text" type="text"
@ -500,7 +515,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
{pendingCareerForModal && ( {pendingCareerForModal && (
<button <button
onClick={() => { onClick={() => {
// handleConfirmCareerSelection logic // Example Confirm
console.log('TODO: handleConfirmCareerSelection => new scenario?');
}} }}
> >
Confirm Career Change to {pendingCareerForModal} Confirm Career Change to {pendingCareerForModal}

View File

@ -2,8 +2,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
// Reuse your existing:
import ScenarioEditModal from './ScenarioEditModal.js'; import ScenarioEditModal from './ScenarioEditModal.js';
import MilestoneTimeline from './MilestoneTimeline.js'; import MilestoneTimeline from './MilestoneTimeline.js';
import AISuggestedMilestones from './AISuggestedMilestones.js'; import AISuggestedMilestones from './AISuggestedMilestones.js';
@ -14,9 +12,11 @@ export default function ScenarioContainer({
financialProfile, // single row, shared across user financialProfile, // single row, shared across user
onClone, onClone,
onRemove, onRemove,
onScenarioUpdated // callback to parent to store updated scenario data onScenarioUpdated
}) { }) {
const [localScenario, setLocalScenario] = useState(scenario);
const [collegeProfile, setCollegeProfile] = useState(null); const [collegeProfile, setCollegeProfile] = useState(null);
const [milestones, setMilestones] = useState([]); const [milestones, setMilestones] = useState([]);
const [universalMilestones, setUniversalMilestones] = useState([]); const [universalMilestones, setUniversalMilestones] = useState([]);
@ -25,12 +25,19 @@ export default function ScenarioContainer({
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
// Re-sync if parent updates scenario
useEffect(() => {
setLocalScenario(scenario);
}, [scenario]);
// 1) Fetch the college profile for this scenario // 1) Fetch the college profile for this scenario
useEffect(() => { useEffect(() => {
if (!scenario?.id) return; if (!localScenario?.id) return;
async function loadCollegeProfile() { async function loadCollegeProfile() {
try { try {
const res = await authFetch(`/api/premium/college-profile?careerPathId=${scenario.id}`); const res = await authFetch(
`/api/premium/college-profile?careerPathId=${localScenario.id}`
);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setCollegeProfile(data); setCollegeProfile(data);
@ -43,20 +50,16 @@ export default function ScenarioContainer({
} }
} }
loadCollegeProfile(); loadCollegeProfile();
}, [scenario]); }, [localScenario]);
// 2) Fetch scenarios milestones (where is_universal=0) + universal (is_universal=1) // 2) Fetch scenarios milestones (and universal)
useEffect(() => { useEffect(() => {
if (!scenario?.id) return; if (!localScenario?.id) return;
async function loadMilestones() { async function loadMilestones() {
try { try {
const [scenRes, uniRes] = await Promise.all([ const [scenRes, uniRes] = await Promise.all([
authFetch(`/api/premium/milestones?careerPathId=${scenario.id}`), authFetch(`/api/premium/milestones?careerPathId=${localScenario.id}`),
// for universal: we do an extra call with no careerPathId. authFetch(`/api/premium/milestones?careerPathId=universal`) // if you have that route
// But your current code always requires a careerPathId. So you might
// create a new endpoint /api/premium/milestones?is_universal=1 or something.
// We'll assume you have it:
authFetch(`/api/premium/milestones?careerPathId=universal`)
]); ]);
let scenarioData = scenRes.ok ? (await scenRes.json()) : { milestones: [] }; let scenarioData = scenRes.ok ? (await scenRes.json()) : { milestones: [] };
@ -69,26 +72,42 @@ export default function ScenarioContainer({
} }
} }
loadMilestones(); loadMilestones();
}, [scenario]); }, [localScenario]);
// 3) Whenever we have financialProfile + collegeProfile + milestones, run the simulation // 3) Merge real snapshot + scenario overrides => run simulation
useEffect(() => { useEffect(() => {
if (!financialProfile || !collegeProfile) return; if (!financialProfile || !collegeProfile) return;
// Merge them into the userProfile object for the simulator: // Merge the scenario's planned overrides if not null,
// else fallback to the real snapshot in financialProfile
const mergedProfile = { const mergedProfile = {
// Financial fields
currentSalary: financialProfile.current_salary || 0, currentSalary: financialProfile.current_salary || 0,
monthlyExpenses: financialProfile.monthly_expenses || 0, monthlyExpenses:
monthlyDebtPayments: financialProfile.monthly_debt_payments || 0, localScenario.planned_monthly_expenses ?? financialProfile.monthly_expenses ?? 0,
retirementSavings: financialProfile.retirement_savings || 0, monthlyDebtPayments:
emergencySavings: financialProfile.emergency_fund || 0, localScenario.planned_monthly_debt_payments ?? financialProfile.monthly_debt_payments ?? 0,
monthlyRetirementContribution: financialProfile.retirement_contribution || 0, retirementSavings: financialProfile.retirement_savings ?? 0,
monthlyEmergencyContribution: financialProfile.emergency_contribution || 0, emergencySavings: financialProfile.emergency_fund ?? 0,
surplusEmergencyAllocation: financialProfile.extra_cash_emergency_pct || 50, monthlyRetirementContribution:
surplusRetirementAllocation: financialProfile.extra_cash_retirement_pct || 50, localScenario.planned_monthly_retirement_contribution ??
financialProfile.retirement_contribution ??
0,
monthlyEmergencyContribution:
localScenario.planned_monthly_emergency_contribution ??
financialProfile.emergency_contribution ??
0,
surplusEmergencyAllocation:
localScenario.planned_surplus_emergency_pct ??
financialProfile.extra_cash_emergency_pct ??
50,
surplusRetirementAllocation:
localScenario.planned_surplus_retirement_pct ??
financialProfile.extra_cash_retirement_pct ??
50,
additionalIncome:
localScenario.planned_additional_income ?? financialProfile.additional_income ?? 0,
// College fields (scenario-based) // College fields
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,
@ -102,66 +121,45 @@ export default function ScenarioContainer({
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,
// We assume users baseline “inCollege” from the DB:
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',
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary || 0,
// If you store expected_salary in collegeProfile // Flatten scenario + universal milestoneImpacts
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary,
// Flatten the scenario + universal milestones impacts
milestoneImpacts: buildAllImpacts([...milestones, ...universalMilestones]) milestoneImpacts: buildAllImpacts([...milestones, ...universalMilestones])
}; };
const { projectionData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile); const { projectionData, loanPaidOffMonth } =
simulateFinancialProjection(mergedProfile);
setProjectionData(projectionData); setProjectionData(projectionData);
setLoanPaidOffMonth(loanPaidOffMonth); setLoanPaidOffMonth(loanPaidOffMonth);
}, [financialProfile, collegeProfile, milestones, universalMilestones]); }, [financialProfile, collegeProfile, localScenario, milestones, universalMilestones]);
// Helper: Flatten all milestone impacts into one array for the simulator
function buildAllImpacts(allMilestones) { function buildAllImpacts(allMilestones) {
let impacts = []; let impacts = [];
for (let m of allMilestones) { for (let m of allMilestones) {
// Possibly fetch m.impacts if you store them directly on the milestone
// or if you fetch them separately.
// If your code stores them as `m.impacts = [ { direction, amount, ... } ]`
if (m.impacts) { if (m.impacts) {
impacts.push(...m.impacts); impacts.push(...m.impacts);
} }
// If you also want a milestone that sets a new salary, handle that logic too // If new_salary logic is relevant, handle it here
// E.g., { impact_type: 'SALARY_CHANGE', start_date: m.date, newSalary: m.new_salary }
} }
return impacts; return impacts;
} }
// 4) Well display a single line chart with Net Savings (or cumulativeNetSavings) // Edit => open modal
const labels = projectionData.map((p) => p.month);
const netSavingsData = projectionData.map((p) => p.cumulativeNetSavings || 0);
// Grab final row for some KPIs
const finalRow = projectionData[projectionData.length - 1] || {};
const finalRet = finalRow.retirementSavings?.toFixed(0) || '0';
const finalEmerg = finalRow.emergencySavings?.toFixed(0) || '0';
// 5) Handle “Edit” scenario -> open your existing `ScenarioEditModal.js`
// But that modal currently references setFinancialProfile, setCollegeProfile directly,
// so you may want a specialized version that changes only this scenarios row.
// For simplicity, well just show how to open it:
return ( return (
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}> <div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
<h3>{scenario.career_name || 'Untitled Scenario'}</h3> <h3>{localScenario.career_name || 'Untitled Scenario'}</h3>
<p>Status: {scenario.status}</p> <p>Status: {localScenario.status}</p>
<Line <Line
data={{ data={{
labels, labels: projectionData.map((p) => p.month),
datasets: [ datasets: [
{ {
label: 'Net Savings', label: 'Net Savings',
data: netSavingsData, data: projectionData.map((p) => p.cumulativeNetSavings || 0),
borderColor: 'blue', borderColor: 'blue',
fill: false fill: false
} }
@ -172,27 +170,24 @@ export default function ScenarioContainer({
<div style={{ marginTop: '0.5rem' }}> <div style={{ marginTop: '0.5rem' }}>
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br /> <strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br />
<strong>Final Retirement:</strong> ${finalRet} <br /> <strong>Retirement (final):</strong> ${
<strong>Final Emergency:</strong> ${finalEmerg} projectionData[projectionData.length - 1]?.retirementSavings?.toFixed(0) || 0
}
</div> </div>
{/* The timeline for this scenario. We pass careerPathId */}
<MilestoneTimeline <MilestoneTimeline
careerPathId={scenario.id} careerPathId={localScenario.id}
authFetch={authFetch} authFetch={authFetch}
activeView="Financial" // or a state that toggles Career vs. Financial activeView="Financial"
setActiveView={() => {}} setActiveView={() => {}}
onMilestoneUpdated={() => { onMilestoneUpdated={() => {
// re-fetch or something // re-fetch or something
// We'll just force a re-fetch of scenarios milestones
// or re-run the entire load effect
}} }}
/> />
{/* Show AI suggestions if you like */}
<AISuggestedMilestones <AISuggestedMilestones
career={scenario.career_name} career={localScenario.career_name}
careerPathId={scenario.id} careerPathId={localScenario.id}
authFetch={authFetch} authFetch={authFetch}
activeView="Financial" activeView="Financial"
projectionData={projectionData} projectionData={projectionData}
@ -200,31 +195,21 @@ export default function ScenarioContainer({
<div style={{ marginTop: '0.5rem' }}> <div style={{ marginTop: '0.5rem' }}>
<button onClick={() => setEditOpen(true)}>Edit</button> <button onClick={() => setEditOpen(true)}>Edit</button>
<button onClick={onClone} style={{ marginLeft: '0.5rem' }}>Clone</button> <button onClick={onClone} style={{ marginLeft: '0.5rem' }}>
Clone
</button>
<button onClick={onRemove} style={{ marginLeft: '0.5rem', color: 'red' }}> <button onClick={onRemove} style={{ marginLeft: '0.5rem', color: 'red' }}>
Remove Remove
</button> </button>
</div> </div>
{/* Reuse your existing ScenarioEditModal that expects {/* Updated ScenarioEditModal that references localScenario + setLocalScenario */}
setFinancialProfile, setCollegeProfile, etc.
However, you might want a specialized "ScenarioEditModal" that updates
the DB fields for *this* scenario. For now, we just show how to open. */}
<ScenarioEditModal <ScenarioEditModal
show={editOpen} show={editOpen}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
financialProfile={financialProfile} scenario={localScenario}
setFinancialProfile={() => { setScenario={setLocalScenario}
// If you truly want scenario-specific financial data,
// youd do a more advanced approach.
// For now, do nothing or re-fetch from server.
}}
collegeProfile={collegeProfile}
setCollegeProfile={(updated) => {
setCollegeProfile((prev) => ({ ...prev, ...updated }));
}}
apiURL="/api" apiURL="/api"
authFetch={authFetch}
/> />
</div> </div>
); );

View File

@ -5,234 +5,195 @@ import authFetch from '../utils/authFetch.js';
const ScenarioEditModal = ({ const ScenarioEditModal = ({
show, show,
onClose, onClose,
financialProfile, scenario, // <== We'll need the scenario object here
setFinancialProfile, setScenario, // callback to update the scenario in parent
collegeProfile, apiURL
setCollegeProfile,
apiURL,
authFetch,
}) => { }) => {
const [formData, setFormData] = useState({}); const [formData, setFormData] = useState({});
// Populate local formData whenever show=true
useEffect(() => { useEffect(() => {
if (!show) return; if (!show || !scenario) return;
setFormData({ setFormData({
// From financialProfile: careerName: scenario.career_name || '',
currentSalary: financialProfile?.current_salary ?? 0, status: scenario.status || 'planned',
monthlyExpenses: financialProfile?.monthly_expenses ?? 0, startDate: scenario.start_date || '',
monthlyDebtPayments: financialProfile?.monthly_debt_payments ?? 0, projectedEndDate: scenario.projected_end_date || '',
retirementSavings: financialProfile?.retirement_savings ?? 0, // existing fields
emergencySavings: financialProfile?.emergency_fund ?? 0, // newly added columns:
monthlyRetirementContribution: financialProfile?.retirement_contribution ?? 0, plannedMonthlyExpenses: scenario.planned_monthly_expenses ?? '',
monthlyEmergencyContribution: financialProfile?.emergency_contribution ?? 0, plannedMonthlyDebt: scenario.planned_monthly_debt_payments ?? '',
surplusEmergencyAllocation: financialProfile?.extra_cash_emergency_pct ?? 50, plannedMonthlyRetirement: scenario.planned_monthly_retirement_contribution ?? '',
surplusRetirementAllocation: financialProfile?.extra_cash_retirement_pct ?? 50, plannedMonthlyEmergency: scenario.planned_monthly_emergency_contribution ?? '',
plannedSurplusEmergencyPct: scenario.planned_surplus_emergency_pct ?? '',
// From collegeProfile: plannedSurplusRetirementPct: scenario.planned_surplus_retirement_pct ?? '',
studentLoanAmount: collegeProfile?.existing_college_debt ?? 0, plannedAdditionalIncome: scenario.planned_additional_income ?? '',
interestRate: collegeProfile?.interest_rate ?? 5, // ...
loanTerm: collegeProfile?.loan_term ?? 10,
loanDeferralUntilGraduation: !!collegeProfile?.loan_deferral_until_graduation,
academicCalendar: collegeProfile?.academic_calendar ?? 'monthly',
annualFinancialAid: collegeProfile?.annual_financial_aid ?? 0,
calculatedTuition: collegeProfile?.tuition ?? 0,
extraPayment: collegeProfile?.extra_payment ?? 0,
partTimeIncome: 0, // or fetch from DB if you store it
gradDate: collegeProfile?.expected_graduation ?? '',
programType: collegeProfile?.program_type ?? '',
creditHoursPerYear: collegeProfile?.credit_hours_per_year ?? 0,
hoursCompleted: collegeProfile?.hours_completed ?? 0,
programLength: collegeProfile?.program_length ?? 0,
inCollege:
collegeProfile?.college_enrollment_status === 'currently_enrolled' ||
collegeProfile?.college_enrollment_status === 'prospective_student',
expectedSalary: collegeProfile?.expected_salary ?? financialProfile?.current_salary ?? 0,
}); });
}, [show, financialProfile, collegeProfile]); }, [show, scenario]);
// Handle form changes in local state
const handleChange = (e) => { const handleChange = (e) => {
const { name, type, value, checked } = e.target; const { name, value } = e.target;
setFormData((prev) => ({ setFormData(prev => ({ ...prev, [name]: value }));
...prev,
[name]:
type === 'checkbox'
? checked
: type === 'number'
? parseFloat(value) || 0
: value
}));
}; };
// SAVE: Update DB and local states, then close
const handleSave = async () => { const handleSave = async () => {
if (!scenario) return;
try { try {
// 1) Update the backend (financialProfile + collegeProfile): // We'll call POST /api/premium/career-profile or a separate PUT.
// (Adjust endpoints/methods as needed in your codebase) // Because the code is "upsert," we can do the same POST
await authFetch(`${apiURL}/premium/financial-profile`, { // and rely on ON CONFLICT.
method: 'PUT', const payload = {
headers: { 'Content-Type': 'application/json' }, career_name: formData.careerName,
body: JSON.stringify({ status: formData.status,
current_salary: formData.currentSalary, start_date: formData.startDate,
monthly_expenses: formData.monthlyExpenses, projected_end_date: formData.projectedEndDate,
monthly_debt_payments: formData.monthlyDebtPayments,
retirement_savings: formData.retirementSavings,
emergency_fund: formData.emergencySavings,
retirement_contribution: formData.monthlyRetirementContribution,
emergency_contribution: formData.monthlyEmergencyContribution,
extra_cash_emergency_pct: formData.surplusEmergencyAllocation,
extra_cash_retirement_pct: formData.surplusRetirementAllocation
})
});
await authFetch(`${apiURL}/premium/college-profile`, { planned_monthly_expenses: formData.plannedMonthlyExpenses === ''
method: 'PUT', ? null
headers: { 'Content-Type': 'application/json' }, : parseFloat(formData.plannedMonthlyExpenses),
body: JSON.stringify({ planned_monthly_debt_payments: formData.plannedMonthlyDebt === ''
existing_college_debt: formData.studentLoanAmount, ? null
interest_rate: formData.interestRate, : parseFloat(formData.plannedMonthlyDebt),
loan_term: formData.loanTerm, planned_monthly_retirement_contribution: formData.plannedMonthlyRetirement === ''
loan_deferral_until_graduation: formData.loanDeferralUntilGraduation, ? null
academic_calendar: formData.academicCalendar, : parseFloat(formData.plannedMonthlyRetirement),
annual_financial_aid: formData.annualFinancialAid, planned_monthly_emergency_contribution: formData.plannedMonthlyEmergency === ''
tuition: formData.calculatedTuition, ? null
extra_payment: formData.extraPayment, : parseFloat(formData.plannedMonthlyEmergency),
expected_graduation: formData.gradDate, planned_surplus_emergency_pct: formData.plannedSurplusEmergencyPct === ''
program_type: formData.programType, ? null
credit_hours_per_year: formData.creditHoursPerYear, : parseFloat(formData.plannedSurplusEmergencyPct),
hours_completed: formData.hoursCompleted, planned_surplus_retirement_pct: formData.plannedSurplusRetirementPct === ''
program_length: formData.programLength, ? null
college_enrollment_status: formData.inCollege : parseFloat(formData.plannedSurplusRetirementPct),
? 'currently_enrolled' planned_additional_income: formData.plannedAdditionalIncome === ''
: 'not_enrolled', ? null
expected_salary: formData.expectedSalary : parseFloat(formData.plannedAdditionalIncome),
}) };
});
// 2) Update local React state so your useEffect triggers re-simulation const res = await authFetch(`${apiURL}/premium/career-profile`, {
setFinancialProfile((prev) => ({ method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(`HTTP ${res.status} - failed to update scenario`);
// If successful, we can optionally fetch the updated row or just
// update local scenario:
const data = await res.json();
console.log('Scenario upserted:', data);
// Optionally call setScenario if you want to reflect changes in UI
setScenario(prev => ({
...prev, ...prev,
current_salary: formData.currentSalary, career_name: formData.careerName,
monthly_expenses: formData.monthlyExpenses, status: formData.status,
monthly_debt_payments: formData.monthlyDebtPayments, start_date: formData.startDate,
retirement_savings: formData.retirementSavings, projected_end_date: formData.projectedEndDate,
emergency_fund: formData.emergencySavings, planned_monthly_expenses: payload.planned_monthly_expenses,
retirement_contribution: formData.monthlyRetirementContribution, planned_monthly_debt_payments: payload.planned_monthly_debt_payments,
emergency_contribution: formData.monthlyEmergencyContribution, planned_monthly_retirement_contribution: payload.planned_monthly_retirement_contribution,
extra_cash_emergency_pct: formData.surplusEmergencyAllocation, planned_monthly_emergency_contribution: payload.planned_monthly_emergency_contribution,
extra_cash_retirement_pct: formData.surplusRetirementAllocation planned_surplus_emergency_pct: payload.planned_surplus_emergency_pct,
planned_surplus_retirement_pct: payload.planned_surplus_retirement_pct,
planned_additional_income: payload.planned_additional_income
})); }));
setCollegeProfile((prev) => ({
...prev,
existing_college_debt: formData.studentLoanAmount,
interest_rate: formData.interestRate,
loan_term: formData.loanTerm,
loan_deferral_until_graduation: formData.loanDeferralUntilGraduation,
academic_calendar: formData.academicCalendar,
annual_financial_aid: formData.annualFinancialAid,
tuition: formData.calculatedTuition,
extra_payment: formData.extraPayment,
expected_graduation: formData.gradDate,
program_type: formData.programType,
credit_hours_per_year: formData.creditHoursPerYear,
hours_completed: formData.hoursCompleted,
program_length: formData.programLength,
college_enrollment_status: formData.inCollege
? 'currently_enrolled'
: 'not_enrolled',
expected_salary: formData.expectedSalary
}));
// 3) Close the modal
onClose(); onClose();
} catch (err) { } catch (err) {
console.error('Error saving scenario changes:', err); console.error('Error saving scenario changes:', err);
// Optionally show a toast or error UI alert('Failed to save scenario. See console for details.');
} }
}; };
// If show=false, don't render anything
if (!show) return null; if (!show) return null;
return ( return (
<div className="modal-backdrop"> <div className="modal-backdrop">
<div className="modal-container"> <div className="modal-container">
<h2 className="text-xl font-bold mb-4">Edit Scenario Inputs</h2> <h2>Edit Scenario</h2>
{/* EXAMPLE FIELDS: Add all the fields you actually want visible */} <label>Scenario Name</label>
<div className="mb-3">
<label className="block font-semibold">Current Salary</label>
<input <input
type="number" name="careerName"
name="currentSalary" value={formData.careerName}
value={formData.currentSalary}
onChange={handleChange} onChange={handleChange}
className="border px-2 py-1 w-full"
/> />
</div>
<div className="mb-3"> <label>Status</label>
<label className="block font-semibold">Monthly Expenses</label> <select
<input name="status"
type="number" value={formData.status}
name="monthlyExpenses"
value={formData.monthlyExpenses}
onChange={handleChange} onChange={handleChange}
className="border px-2 py-1 w-full"
/>
</div>
<div className="mb-3">
<label className="block font-semibold">Tuition</label>
<input
type="number"
name="calculatedTuition"
value={formData.calculatedTuition}
onChange={handleChange}
className="border px-2 py-1 w-full"
/>
</div>
<div className="mb-3">
<label className="block font-semibold">Annual Financial Aid</label>
<input
type="number"
name="annualFinancialAid"
value={formData.annualFinancialAid}
onChange={handleChange}
className="border px-2 py-1 w-full"
/>
</div>
{/* Example checkbox for loan deferral */}
<div className="mb-3 flex items-center">
<input
type="checkbox"
name="loanDeferralUntilGraduation"
checked={formData.loanDeferralUntilGraduation}
onChange={handleChange}
className="mr-2"
/>
<label>Defer loan payments until graduation</label>
</div>
{/* Add all other fields you want to expose... */}
{/* Modal Buttons */}
<div className="flex justify-end mt-6">
<button
onClick={onClose}
className="px-4 py-2 mr-2 border rounded"
> >
<option value="planned">Planned</option>
<option value="current">Current</option>
<option value="completed">Completed</option>
</select>
{/* A few new fields for “planned_” columns: */}
<label>Planned Monthly Expenses</label>
<input
type="number"
name="plannedMonthlyExpenses"
value={formData.plannedMonthlyExpenses}
onChange={handleChange}
/>
<label>Planned Monthly Debt</label>
<input
type="number"
name="plannedMonthlyDebt"
value={formData.plannedMonthlyDebt}
onChange={handleChange}
/>
<label>Planned Retirement Contribution (monthly)</label>
<input
type="number"
name="plannedMonthlyRetirement"
value={formData.plannedMonthlyRetirement}
onChange={handleChange}
/>
<label>Planned Emergency Contribution (monthly)</label>
<input
type="number"
name="plannedMonthlyEmergency"
value={formData.plannedMonthlyEmergency}
onChange={handleChange}
/>
<label>Planned Surplus % Emergency</label>
<input
type="number"
name="plannedSurplusEmergencyPct"
value={formData.plannedSurplusEmergencyPct}
onChange={handleChange}
/>
<label>Planned Surplus % Retirement</label>
<input
type="number"
name="plannedSurplusRetirementPct"
value={formData.plannedSurplusRetirementPct}
onChange={handleChange}
/>
<label>Planned Additional Income</label>
<input
type="number"
name="plannedAdditionalIncome"
value={formData.plannedAdditionalIncome}
onChange={handleChange}
/>
<div className="flex justify-end mt-6">
<button onClick={onClose} style={{ marginRight: '1rem' }}>
Cancel Cancel
</button> </button>
<button <button onClick={handleSave}>
onClick={handleSave}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
Save Save
</button> </button>
</div> </div>

Binary file not shown.