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
// server3.js
app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res) => {
const {
career_name,
@ -100,10 +123,18 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
start_date,
projected_end_date,
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;
// If you need to ensure the user gave us a career_name:
if (!career_name) {
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 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(`
INSERT INTO career_paths (
id,
@ -122,10 +155,21 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
projected_end_date,
college_enrollment_status,
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,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?,
?, ?)
ON CONFLICT(user_id, career_name)
DO UPDATE SET
status = excluded.status,
@ -133,22 +177,41 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
projected_end_date = excluded.projected_end_date,
college_enrollment_status = excluded.college_enrollment_status,
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 = ?
`, [
newCareerPathId, // id
req.userId, // user_id
career_name, // career_name
status || 'planned', // status (if null, default to 'planned')
newCareerPathId,
req.userId,
career_name,
status || 'planned',
start_date || now,
projected_end_date || null,
college_enrollment_status || null,
currently_working || null,
now, // created_at
now, // updated_at on initial insert
now // updated_at on conflict
// 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, // updated_at
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(`
SELECT id
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) => {
try {
const body = req.body;
@ -183,12 +250,12 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
career_path_id,
progress,
status,
new_salary
new_salary,
is_universal
} = m;
// Validate some required fields
if (!milestone_type || !title || !date || !career_path_id) {
// Optionally handle partial errors, but let's do a quick check
return res.status(400).json({
error: 'One or more milestones missing required fields',
details: m
@ -210,9 +277,10 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
progress,
status,
new_salary,
is_universal,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
id,
req.userId,
@ -224,6 +292,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
progress || 0,
status || 'planned',
new_salary || null,
is_universal ? 1 : 0, // store 1 or 0
now,
now
]);
@ -239,6 +308,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
progress: progress || 0,
status: status || 'planned',
new_salary: new_salary || null,
is_universal: is_universal ? 1 : 0,
tasks: []
});
}
@ -246,7 +316,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
return res.status(201).json(createdMilestones);
}
// CASE 2: Handle single milestone (the old logic)
// CASE 2: Single milestone creation
const {
milestone_type,
title,
@ -255,7 +325,8 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
career_path_id,
progress,
status,
new_salary
new_salary,
is_universal
} = body;
if (!milestone_type || !title || !date || !career_path_id) {
@ -280,9 +351,10 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
progress,
status,
new_salary,
is_universal,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
id,
req.userId,
@ -294,6 +366,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
progress || 0,
status || 'planned',
new_salary || null,
is_universal ? 1 : 0,
now,
now
]);
@ -310,6 +383,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
progress: progress || 0,
status: status || 'planned',
new_salary: new_salary || null,
is_universal: is_universal ? 1 : 0,
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) => {
try {
const { milestoneId } = req.params;
@ -331,7 +406,8 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
career_path_id,
progress,
status,
new_salary
new_salary,
is_universal
} = req.body;
// 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.' });
}
// Update
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(`
UPDATE milestones
SET
@ -359,19 +448,23 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
progress = ?,
status = ?,
new_salary = ?,
is_universal = ?,
updated_at = ?
WHERE id = ?
AND user_id = ?
`, [
milestone_type || existing.milestone_type,
title || existing.title,
description || existing.description,
date || existing.date,
career_path_id || existing.career_path_id,
progress != null ? progress : existing.progress,
status || existing.status,
new_salary != null ? new_salary : existing.new_salary,
finalMilestoneType,
finalTitle,
finalDesc,
finalDate,
finalCareerPath,
finalProgress,
finalStatus,
finalSalary,
finalIsUniversal,
now,
milestoneId
milestoneId,
req.userId
]);
// 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) => {
const { careerPathId } = req.query;
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(`
SELECT *
FROM milestones
@ -412,7 +538,6 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
AND career_path_id = ?
`, [req.userId, careerPathId]);
// 2. For each milestone, fetch tasks
const milestoneIds = milestones.map(m => m.id);
let tasksByMilestone = {};
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 => ({
...m,
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)
------------------------------------------------------------------ */

View File

@ -13,6 +13,9 @@ import MilestoneTracker from "./components/MilestoneTracker.js";
import Paywall from "./components/Paywall.js";
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
// NEW: import your MultiScenarioView component
import MultiScenarioView from './components/MultiScenarioView.js';
import './App.css';
function App() {
@ -21,7 +24,13 @@ function App() {
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);
@ -55,11 +64,14 @@ function App() {
<Route path="/financial-profile" element={<FinancialProfileForm />} />
<Route path="/premium-onboarding" element={<OnboardingContainer />} />
{/* NEW multi-scenario route */}
<Route path="/multi-scenario" element={<MultiScenarioView />} />
</>
)}
<Route path="*" element={<Navigate to="/signin" />} />
</Routes>
<SessionExpiredHandler />
</div>
);

View File

@ -3,21 +3,27 @@ import React, { useEffect, useState, useCallback } from 'react';
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: [] });
// The "new or edit" milestone form state
// "new or edit" milestone form data
const [newMilestone, setNewMilestone] = useState({
title: '',
description: '',
date: '',
progress: 0,
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 [showForm, setShowForm] = useState(false);
@ -27,10 +33,77 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
const [showTaskForm, setShowTaskForm] = useState(null);
const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' });
/**
* Fetch all milestones (and their tasks) for this careerPathId.
* Then categorize them by milestone_type: 'Career' or 'Financial'.
*/
// For the Copy wizard
const [scenarios, setScenarios] = useState([]);
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 () => {
if (!careerPathId) return;
try {
@ -41,12 +114,12 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
}
const data = await res.json();
if (!data.milestones) {
console.warn('No milestones field in response:', data);
console.warn('No milestones returned:', data);
return;
}
const categorized = { Career: [], Financial: [] };
data.milestones.forEach((m) => {
data.milestones.forEach(m => {
if (categorized[m.milestone_type]) {
categorized[m.milestone_type].push(m);
} else {
@ -64,57 +137,52 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
fetchMilestones();
}, [fetchMilestones]);
/**
* Async function to edit an existing milestone.
* Fetch its impacts, populate newMilestone, show the form.
*/
// ------------------------------------------------------------------
// 4) "Edit" an existing milestone => load impacts
// ------------------------------------------------------------------
const handleEditMilestone = async (m) => {
try {
// Reset impactsToDelete whenever we edit a new milestone
setImpactsToDelete([]);
// Fetch existing impacts for milestone "m"
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
if (!res.ok) {
console.error('Failed to fetch milestone impacts, status:', res.status);
throw new Error(`HTTP ${res.status}`);
console.error('Failed to fetch milestone impacts. Status:', res.status);
return;
}
const data = await res.json();
const fetchedImpacts = data.impacts || [];
// Populate the newMilestone form
setNewMilestone({
title: m.title || '',
description: m.description || '',
date: m.date || '',
progress: m.progress || 0,
newSalary: m.new_salary || '',
impacts: fetchedImpacts.map((imp) => ({
// If the DB row has id, we'll store it for PUT or DELETE
impacts: fetchedImpacts.map(imp => ({
id: imp.id,
impact_type: imp.impact_type || 'ONE_TIME',
direction: imp.direction || 'subtract',
amount: imp.amount || 0,
start_date: imp.start_date || '',
end_date: imp.end_date || ''
}))
})),
isUniversal: m.is_universal ? 1 : 0
});
setEditingMilestone(m);
setShowForm(true);
} 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 () => {
if (!activeView) return;
// If editing, we do PUT; otherwise POST
const url = editingMilestone
? `/api/premium/milestones/${editingMilestone.id}`
: `/api/premium/milestone`;
@ -131,143 +199,126 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
new_salary:
activeView === 'Financial' && newMilestone.newSalary
? parseFloat(newMilestone.newSalary)
: null
: null,
is_universal: newMilestone.isUniversal || 0
};
try {
console.log('Sending request:', method, url, payload);
const res = await authFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const errorData = await res.json();
console.error('Failed to save milestone:', errorData);
alert(errorData.error || 'Error saving milestone');
const errData = await res.json();
console.error('Failed to save milestone:', errData);
alert(errData.error || 'Error saving milestone');
return;
}
if (onMilestoneUpdated) onMilestoneUpdated();
const savedMilestone = await res.json();
console.log('Milestone saved/updated:', savedMilestone);
// If Financial, handle the "impacts"
// If financial => handle impacts
if (activeView === 'Financial') {
// 1) Delete impacts that user removed
// 1) Delete old impacts
for (const impactId of impactsToDelete) {
if (impactId) {
console.log('Deleting old impact', impactId);
const delRes = await authFetch(`/api/premium/milestone-impacts/${impactId}`, {
method: 'DELETE'
});
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
// We'll track the index so we can store the newly created ID if needed
// 2) Insert/Update new impacts
for (let i = 0; i < newMilestone.impacts.length; i++) {
const impact = newMilestone.impacts[i];
if (impact.id) {
// existing row => PUT
const imp = newMilestone.impacts[i];
if (imp.id) {
// existing => PUT
const putPayload = {
milestone_id: savedMilestone.id,
impact_type: impact.impact_type,
direction: impact.direction,
amount: parseFloat(impact.amount) || 0,
start_date: impact.start_date || null,
end_date: impact.end_date || null
impact_type: imp.impact_type,
direction: imp.direction,
amount: parseFloat(imp.amount) || 0,
start_date: imp.start_date || null,
end_date: imp.end_date || null
};
console.log('Updating milestone impact:', impact.id, putPayload);
const impRes = await authFetch(`/api/premium/milestone-impacts/${impact.id}`, {
const impRes = await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(putPayload)
});
if (!impRes.ok) {
const errImp = await impRes.json();
console.error('Failed to update milestone impact:', errImp);
} else {
const updatedImpact = await impRes.json();
console.log('Updated Impact:', updatedImpact);
console.error('Failed updating existing impact:', errImp);
}
} else {
// [FIX HERE] If no id => POST to create new
const impactPayload = {
// new => POST
const postPayload = {
milestone_id: savedMilestone.id,
impact_type: impact.impact_type,
direction: impact.direction,
amount: parseFloat(impact.amount) || 0,
start_date: impact.start_date || null,
end_date: impact.end_date || null
impact_type: imp.impact_type,
direction: imp.direction,
amount: parseFloat(imp.amount) || 0,
start_date: imp.start_date || null,
end_date: imp.end_date || null
};
console.log('Creating milestone impact:', impactPayload);
const impRes = await authFetch('/api/premium/milestone-impacts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(impactPayload)
body: JSON.stringify(postPayload)
});
if (!impRes.ok) {
const errImp = await impRes.json();
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 };
});
}
console.error('Failed creating new impact:', await impRes.text());
}
}
}
}
// Update local state so we don't have to refetch everything
// optional local state update to avoid re-fetch
setMilestones((prev) => {
const updated = { ...prev };
const newState = { ...prev };
if (editingMilestone) {
updated[activeView] = updated[activeView].map((m) =>
newState[activeView] = newState[activeView].map(m =>
m.id === editingMilestone.id ? savedMilestone : m
);
} else {
updated[activeView].push(savedMilestone);
newState[activeView].push(savedMilestone);
}
return updated;
return newState;
});
// Reset form
// reset the form
setShowForm(false);
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({
title: '',
description: '',
date: '',
progress: 0,
newSalary: '',
impacts: []
impacts: [],
isUniversal: 0
});
setImpactsToDelete([]);
// optionally re-fetch from DB
// await fetchMilestones();
if (onMilestoneUpdated) {
onMilestoneUpdated();
}
} catch (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) => {
try {
const taskPayload = {
@ -292,11 +343,11 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
const createdTask = await res.json();
console.log('Task created:', createdTask);
// Update the milestone's tasks in local state
// update local state
setMilestones((prev) => {
const newState = { ...prev };
['Career', 'Financial'].forEach((category) => {
newState[category] = newState[category].map((m) => {
['Career', 'Financial'].forEach((cat) => {
newState[cat] = newState[cat].map((m) => {
if (m.id === milestoneId) {
return {
...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 lastDate = allMilestonesCombined.reduce((latest, m) => {
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);
};
/**
* Add a new empty impact (no id => new)
*/
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>
);
}
// ------------------------------------------------------------------
// Render
// ------------------------------------------------------------------
return (
<div className="milestone-timeline">
<div className="view-selector">
{['Career', 'Financial'].map((view) => (
{['Career', 'Financial'].map(view => (
<button
key={view}
className={activeView === view ? 'active' : ''}
@ -397,11 +528,11 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
))}
</div>
{/* New Milestone button */}
{/* + New Milestone button */}
<button
onClick={() => {
if (showForm) {
// Cancel form
// Cancel
setShowForm(false);
setEditingMilestone(null);
setNewMilestone({
@ -410,7 +541,8 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
date: '',
progress: 0,
newSalary: '',
impacts: []
impacts: [],
isUniversal: 0
});
setImpactsToDelete([]);
} else {
@ -439,7 +571,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
type="date"
placeholder="Milestone Date"
value={newMilestone.date}
onChange={(e) => setNewMilestone((prev) => ({ ...prev, date: e.target.value }))}
onChange={(e) => setNewMilestone(prev => ({ ...prev, date: e.target.value }))}
/>
<input
type="number"
@ -447,7 +579,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
value={newMilestone.progress === 0 ? '' : newMilestone.progress}
onChange={(e) => {
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>
<input
type="number"
placeholder="Full New Salary (e.g., 70000)"
placeholder="Full New Salary (e.g. 70000)"
value={newMilestone.newSalary}
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })}
/>
@ -535,12 +667,30 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
</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
</button>
</div>
)}
{/* Timeline */}
<div className="milestone-timeline-container">
<div className="milestone-timeline-line" />
@ -586,6 +736,23 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
</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 && (
<div className="task-form">
<input
@ -613,6 +780,17 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
);
})}
</div>
{/* CopyWizard modal if copying */}
{copyWizardMilestone && (
<CopyMilestoneWizard
milestone={copyWizardMilestone}
scenarios={scenarios}
onClose={() => setCopyWizardMilestone(null)}
authFetch={authFetch}
onMilestoneUpdated={onMilestoneUpdated}
/>
)}
</div>
);
};

View File

@ -40,27 +40,35 @@ ChartJS.register(
const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
const location = useLocation();
const navigate = useNavigate();
const apiURL = process.env.REACT_APP_API_URL;
// -------------------------
// --------------------------------------------------
// State
// -------------------------
// --------------------------------------------------
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
const [careerPathId, setCareerPathId] = useState(null);
const [existingCareerPaths, setExistingCareerPaths] = useState([]);
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
const [activeView, setActiveView] = useState("Career");
// Real user snapshot
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);
// Simulation results
const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
// Possibly let user type the simulation length
const [simulationYearsInput, setSimulationYearsInput] = useState("20");
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
const [showEditModal, setShowEditModal] = useState(false);
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
// Possibly loaded from location.state
const {
@ -68,10 +76,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
loanPayoffMonth: initialLoanPayoffMonth = null
} = location.state || {};
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
// -------------------------
// 1. Fetch career paths + financialProfile on mount
// -------------------------
// --------------------------------------------------
// 1) Fetch users scenario list + financialProfile
// --------------------------------------------------
useEffect(() => {
const fetchCareerPaths = async () => {
const res = await authFetch(`${apiURL}/premium/career-profile/all`);
@ -84,7 +91,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
setSelectedCareer(fromPopout);
setCareerPathId(fromPopout.career_path_id);
} else if (!selectedCareer) {
// Try to fetch the latest
// fallback to latest
const latest = await authFetch(`${apiURL}/premium/career-profile/latest`);
if (latest && latest.ok) {
const latestData = await latest.json();
@ -98,7 +105,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
const fetchFinancialProfile = async () => {
const res = await authFetch(`${apiURL}/premium/financial-profile`);
if (res && res.ok) {
if (res?.ok) {
const data = await res.json();
setFinancialProfile(data);
}
@ -108,256 +115,265 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
fetchFinancialProfile();
}, [apiURL, location.state, selectedCareer]);
// -------------------------
// 2. Fetch the college profile for the selected careerPathId
// -------------------------
// --------------------------------------------------
// 2) When careerPathId changes => fetch scenarioRow + collegeProfile
// --------------------------------------------------
useEffect(() => {
if (!careerPathId) {
setScenarioRow(null);
setCollegeProfile(null);
return;
}
const fetchCollegeProfile = async () => {
const res = await authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`);
if (!res || !res.ok) {
async function fetchScenario() {
const scenRes = await authFetch(`${apiURL}/premium/career-profile/${careerPathId}`);
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);
return;
}
const data = await res.json();
const data = await colRes.json();
setCollegeProfile(data);
};
}
fetchCollegeProfile();
fetchScenario();
fetchCollege();
}, [careerPathId, apiURL]);
// -------------------------
// 3. Initial simulation when profiles + career loaded
// (But this does NOT update after milestone changes yet)
// -------------------------
// --------------------------------------------------
// 3) Once we have (financialProfile, scenarioRow, collegeProfile),
// run initial simulation with the scenario's milestones + impacts
// --------------------------------------------------
useEffect(() => {
if (!financialProfile || !collegeProfile || !selectedCareer || !careerPathId) return;
// 1) Fetch the raw milestones for this careerPath
if (!financialProfile || !scenarioRow || !collegeProfile) return;
(async () => {
try {
// 1) load milestones for scenario
const milRes = await authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`);
if (!milRes.ok) {
console.error('Failed to fetch initial milestones');
console.error('Failed to fetch initial milestones for scenario', careerPathId);
return;
}
const milestonesData = await milRes.json();
const allMilestones = milestonesData.milestones || [];
// 2) For each milestone, fetch impacts
const impactPromises = allMilestones.map((m) =>
// 2) fetch impacts for each
const impactPromises = allMilestones.map(m =>
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => data?.impacts || [])
.catch((err) => {
console.error('Failed fetching impacts for milestone', m.id, err);
.then(r => r.ok ? r.json() : null)
.then(data => data?.impacts || [])
.catch(err => {
console.warn('Error fetching impacts for milestone', m.id, err);
return [];
})
);
const impactsForEach = await Promise.all(impactPromises);
const milestonesWithImpacts = allMilestones.map((m, i) => ({
...m,
impacts: impactsForEach[i] || [],
impacts: impactsForEach[i] || []
}));
// 3) Flatten them
const allImpacts = milestonesWithImpacts.flatMap((m) => m.impacts || []);
// 4) Build the mergedProfile (like you already do)
const mergedProfile = {
// 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,
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts);
simulationYears,
};
// 5) Run the simulation
const { projectionData: initialProjData, loanPaidOffMonth: payoff } =
// 3) Build the merged profile w/ scenario overrides
const mergedProfile = buildMergedProfile(
financialProfile,
scenarioRow,
collegeProfile,
allImpacts,
simulationYears
);
// 4) run the simulation
const { projectionData: pData, loanPaidOffMonth: payoff } =
simulateFinancialProjection(mergedProfile);
let cumulativeSavings = mergedProfile.emergencySavings || 0;
const finalData = initialProjData.map((month) => {
cumulativeSavings += (month.netSavings || 0);
return { ...month, cumulativeNetSavings: cumulativeSavings };
// 5) If you track cumulative net
let cumu = mergedProfile.emergencySavings || 0;
const finalData = pData.map(mo => {
cumu += (mo.netSavings || 0);
return { ...mo, cumulativeNetSavings: cumu };
});
setProjectionData(finalData);
setLoanPayoffMonth(payoff);
} 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) => {
setSimulationYearsInput(e.target.value); // let user type partial/blank
};
// Merges the real snapshot w/ scenario overrides + milestones
function buildMergedProfile(finProf, scenRow, colProf, milestoneImpacts, simYears) {
return {
// Real snapshot fallback
currentSalary: finProf.current_salary || 0,
monthlyExpenses:
scenRow.planned_monthly_expenses ?? finProf.monthly_expenses ?? 0,
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,
const handleSimulationYearsBlur = () => {
// Optionally, onBlur you can “normalize” the value
// (e.g. if they left it blank, revert to "20").
if (simulationYearsInput.trim() === "") {
setSimulationYearsInput("20");
}
};
// 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,
// -------------------------------------------------
// 4. reSimulate() => re-fetch everything (financial, college, milestones & impacts),
// re-run the simulation. This is triggered AFTER user updates a milestone in MilestoneTimeline.
// -------------------------------------------------
// Additional
startDate: new Date().toISOString(),
simulationYears: simYears,
// Milestone Impacts
milestoneImpacts: milestoneImpacts || []
};
}
// ------------------------------------------------------
// 4) reSimulate => after milestone changes or user toggles something
// ------------------------------------------------------
const reSimulate = async () => {
if (!careerPathId) return;
try {
// 1) Fetch financial + college + raw milestones
const [finResp, colResp, milResp] = await Promise.all([
// 1) fetch everything again
const [finResp, scenResp, colResp, milResp] = await Promise.all([
authFetch(`${apiURL}/premium/financial-profile`),
authFetch(`${apiURL}/premium/career-profile/${careerPathId}`),
authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`),
authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`)
]);
if (!finResp.ok || !colResp.ok || !milResp.ok) {
console.error('One reSimulate fetch failed:', finResp.status, colResp.status, milResp.status);
if (!finResp.ok || !scenResp.ok || !colResp.ok || !milResp.ok) {
console.error(
'One reSimulate fetch failed:',
finResp.status,
scenResp.status,
colResp.status,
milResp.status
);
return;
}
const [updatedFinancial, updatedCollege, milestonesData] = await Promise.all([
finResp.json(),
colResp.json(),
milResp.json()
]);
const [updatedFinancial, updatedScenario, updatedCollege, milData] =
await Promise.all([
finResp.json(),
scenResp.json(),
colResp.json(),
milResp.json()
]);
// 2) For each milestone, fetch its impacts separately (if not already included)
const allMilestones = milestonesData.milestones || [];
const allMilestones = milData.milestones || [];
const impactsPromises = allMilestones.map(m =>
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
.then(r => r.ok ? r.json() : null)
.then(data => data?.impacts || [])
.catch(err => {
console.error('Failed fetching impacts for milestone', m.id, err);
console.warn('Impact fetch err for milestone', m.id, err);
return [];
})
);
const impactsForEach = await Promise.all(impactsPromises);
// Merge them onto the milestone array if desired
const milestonesWithImpacts = allMilestones.map((m, i) => ({
...m,
impacts: impactsForEach[i] || []
}));
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts);
// Flatten or gather all impacts if your simulation function needs them
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts || []);
// 2) Build merged
const mergedProfile = buildMergedProfile(
updatedFinancial,
updatedScenario,
updatedCollege,
allImpacts,
simulationYears
);
// 3) Build mergedProfile
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
// 3) run
const { projectionData: newProjData, loanPaidOffMonth: payoff } =
simulateFinancialProjection(mergedProfile);
// 5) If you track cumulative net savings:
let cumulativeSavings = mergedProfile.emergencySavings || 0;
const finalData = newProjData.map(month => {
cumulativeSavings += (month.netSavings || 0);
return { ...month, cumulativeNetSavings: cumulativeSavings };
// 4) cumulative
let csum = mergedProfile.emergencySavings || 0;
const finalData = newProjData.map(mo => {
csum += (mo.netSavings || 0);
return { ...mo, cumulativeNetSavings: csum };
});
// 6) Update states => triggers chart refresh
setProjectionData(finalData);
setLoanPayoffMonth(payoff);
// Optionally store the new profiles in state if you like
// also store updated scenario, financial, college
setFinancialProfile(updatedFinancial);
setScenarioRow(updatedScenario);
setCollegeProfile(updatedCollege);
console.log('Re-simulated after Milestone update!', {
mergedProfile,
milestonesWithImpacts
});
console.log('Re-simulated after milestone update', { mergedProfile, finalData });
} catch (err) {
console.error('Error in reSimulate:', err);
}
};
// ...
// The rest of your component logic
// ...
// handle user typing simulation length
const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value);
const handleSimulationYearsBlur = () => {
if (!simulationYearsInput.trim()) {
setSimulationYearsInput("20");
}
};
// Logging
console.log(
'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 (
@ -373,7 +389,6 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
authFetch={authFetch}
/>
{/* Pass reSimulate as onMilestoneUpdated: */}
<MilestoneTimeline
careerPathId={careerPathId}
authFetch={authFetch}
@ -470,15 +485,15 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
</div>
)}
<div>
<label>Simulation Length (years): </label>
<input
type="text"
value={simulationYearsInput}
onChange={handleSimulationYearsChange}
onBlur={handleSimulationYearsBlur}
/>
</div>
<div className="mt-4">
<label>Simulation Length (years): </label>
<input
type="text"
value={simulationYearsInput}
onChange={handleSimulationYearsChange}
onBlur={handleSimulationYearsBlur}
/>
</div>
<CareerSearch
onSelectCareer={(careerName) => setPendingCareerForModal(careerName)}
@ -500,7 +515,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
{pendingCareerForModal && (
<button
onClick={() => {
// handleConfirmCareerSelection logic
// Example Confirm
console.log('TODO: handleConfirmCareerSelection => new scenario?');
}}
>
Confirm Career Change to {pendingCareerForModal}

View File

@ -2,8 +2,6 @@
import React, { useState, useEffect } from 'react';
import { Line } from 'react-chartjs-2';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
// Reuse your existing:
import ScenarioEditModal from './ScenarioEditModal.js';
import MilestoneTimeline from './MilestoneTimeline.js';
import AISuggestedMilestones from './AISuggestedMilestones.js';
@ -14,23 +12,32 @@ export default function ScenarioContainer({
financialProfile, // single row, shared across user
onClone,
onRemove,
onScenarioUpdated // callback to parent to store updated scenario data
onScenarioUpdated
}) {
const [localScenario, setLocalScenario] = useState(scenario);
const [collegeProfile, setCollegeProfile] = useState(null);
const [milestones, setMilestones] = useState([]);
const [universalMilestones, setUniversalMilestones] = useState([]);
const [projectionData, setProjectionData] = useState([]);
const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null);
const [editOpen, setEditOpen] = useState(false);
// Re-sync if parent updates scenario
useEffect(() => {
setLocalScenario(scenario);
}, [scenario]);
// 1) Fetch the college profile for this scenario
useEffect(() => {
if (!scenario?.id) return;
if (!localScenario?.id) return;
async function loadCollegeProfile() {
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) {
const data = await res.json();
setCollegeProfile(data);
@ -43,20 +50,16 @@ export default function ScenarioContainer({
}
}
loadCollegeProfile();
}, [scenario]);
}, [localScenario]);
// 2) Fetch scenarios milestones (where is_universal=0) + universal (is_universal=1)
// 2) Fetch scenarios milestones (and universal)
useEffect(() => {
if (!scenario?.id) return;
if (!localScenario?.id) return;
async function loadMilestones() {
try {
const [scenRes, uniRes] = await Promise.all([
authFetch(`/api/premium/milestones?careerPathId=${scenario.id}`),
// for universal: we do an extra call with no careerPathId.
// 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`)
authFetch(`/api/premium/milestones?careerPathId=${localScenario.id}`),
authFetch(`/api/premium/milestones?careerPathId=universal`) // if you have that route
]);
let scenarioData = scenRes.ok ? (await scenRes.json()) : { milestones: [] };
@ -69,26 +72,42 @@ export default function ScenarioContainer({
}
}
loadMilestones();
}, [scenario]);
}, [localScenario]);
// 3) Whenever we have financialProfile + collegeProfile + milestones, run the simulation
// 3) Merge real snapshot + scenario overrides => run simulation
useEffect(() => {
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 = {
// Financial fields
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,
monthlyExpenses:
localScenario.planned_monthly_expenses ?? financialProfile.monthly_expenses ?? 0,
monthlyDebtPayments:
localScenario.planned_monthly_debt_payments ?? financialProfile.monthly_debt_payments ?? 0,
retirementSavings: financialProfile.retirement_savings ?? 0,
emergencySavings: financialProfile.emergency_fund ?? 0,
monthlyRetirementContribution:
localScenario.planned_monthly_retirement_contribution ??
financialProfile.retirement_contribution ??
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,
interestRate: collegeProfile.interest_rate || 5,
loanTerm: collegeProfile.loan_term || 10,
@ -102,66 +121,45 @@ export default function ScenarioContainer({
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
hoursCompleted: collegeProfile.hours_completed || 0,
programLength: collegeProfile.program_length || 0,
// We assume users baseline “inCollege” from the DB:
inCollege:
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
collegeProfile.college_enrollment_status === 'prospective_student',
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary || 0,
// If you store expected_salary in collegeProfile
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary,
// Flatten the scenario + universal milestones impacts
// Flatten scenario + universal milestoneImpacts
milestoneImpacts: buildAllImpacts([...milestones, ...universalMilestones])
};
const { projectionData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile);
const { projectionData, loanPaidOffMonth } =
simulateFinancialProjection(mergedProfile);
setProjectionData(projectionData);
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) {
let impacts = [];
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) {
impacts.push(...m.impacts);
}
// If you also want a milestone that sets a new salary, handle that logic too
// E.g., { impact_type: 'SALARY_CHANGE', start_date: m.date, newSalary: m.new_salary }
// If new_salary logic is relevant, handle it here
}
return impacts;
}
// 4) Well display a single line chart with Net Savings (or cumulativeNetSavings)
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:
// Edit => open modal
return (
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
<h3>{scenario.career_name || 'Untitled Scenario'}</h3>
<p>Status: {scenario.status}</p>
<h3>{localScenario.career_name || 'Untitled Scenario'}</h3>
<p>Status: {localScenario.status}</p>
<Line
data={{
labels,
labels: projectionData.map((p) => p.month),
datasets: [
{
label: 'Net Savings',
data: netSavingsData,
data: projectionData.map((p) => p.cumulativeNetSavings || 0),
borderColor: 'blue',
fill: false
}
@ -172,27 +170,24 @@ export default function ScenarioContainer({
<div style={{ marginTop: '0.5rem' }}>
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br />
<strong>Final Retirement:</strong> ${finalRet} <br />
<strong>Final Emergency:</strong> ${finalEmerg}
<strong>Retirement (final):</strong> ${
projectionData[projectionData.length - 1]?.retirementSavings?.toFixed(0) || 0
}
</div>
{/* The timeline for this scenario. We pass careerPathId */}
<MilestoneTimeline
careerPathId={scenario.id}
careerPathId={localScenario.id}
authFetch={authFetch}
activeView="Financial" // or a state that toggles Career vs. Financial
activeView="Financial"
setActiveView={() => {}}
onMilestoneUpdated={() => {
// 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
career={scenario.career_name}
careerPathId={scenario.id}
career={localScenario.career_name}
careerPathId={localScenario.id}
authFetch={authFetch}
activeView="Financial"
projectionData={projectionData}
@ -200,31 +195,21 @@ export default function ScenarioContainer({
<div style={{ marginTop: '0.5rem' }}>
<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' }}>
Remove
</button>
</div>
{/* Reuse your existing ScenarioEditModal that expects
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. */}
{/* Updated ScenarioEditModal that references localScenario + setLocalScenario */}
<ScenarioEditModal
show={editOpen}
onClose={() => setEditOpen(false)}
financialProfile={financialProfile}
setFinancialProfile={() => {
// 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 }));
}}
scenario={localScenario}
setScenario={setLocalScenario}
apiURL="/api"
authFetch={authFetch}
/>
</div>
);

View File

@ -5,234 +5,195 @@ import authFetch from '../utils/authFetch.js';
const ScenarioEditModal = ({
show,
onClose,
financialProfile,
setFinancialProfile,
collegeProfile,
setCollegeProfile,
apiURL,
authFetch,
scenario, // <== We'll need the scenario object here
setScenario, // callback to update the scenario in parent
apiURL
}) => {
const [formData, setFormData] = useState({});
// Populate local formData whenever show=true
useEffect(() => {
if (!show) return;
if (!show || !scenario) return;
setFormData({
// 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,
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,
careerName: scenario.career_name || '',
status: scenario.status || 'planned',
startDate: scenario.start_date || '',
projectedEndDate: scenario.projected_end_date || '',
// existing fields
// newly added columns:
plannedMonthlyExpenses: scenario.planned_monthly_expenses ?? '',
plannedMonthlyDebt: scenario.planned_monthly_debt_payments ?? '',
plannedMonthlyRetirement: scenario.planned_monthly_retirement_contribution ?? '',
plannedMonthlyEmergency: scenario.planned_monthly_emergency_contribution ?? '',
plannedSurplusEmergencyPct: scenario.planned_surplus_emergency_pct ?? '',
plannedSurplusRetirementPct: scenario.planned_surplus_retirement_pct ?? '',
plannedAdditionalIncome: scenario.planned_additional_income ?? '',
// ...
});
}, [show, financialProfile, collegeProfile]);
}, [show, scenario]);
// Handle form changes in local state
const handleChange = (e) => {
const { name, type, value, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]:
type === 'checkbox'
? checked
: type === 'number'
? parseFloat(value) || 0
: value
}));
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// SAVE: Update DB and local states, then close
const handleSave = async () => {
if (!scenario) return;
try {
// 1) Update the backend (financialProfile + collegeProfile):
// (Adjust endpoints/methods as needed in your codebase)
await authFetch(`${apiURL}/premium/financial-profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_salary: formData.currentSalary,
monthly_expenses: formData.monthlyExpenses,
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
})
});
// We'll call POST /api/premium/career-profile or a separate PUT.
// Because the code is "upsert," we can do the same POST
// and rely on ON CONFLICT.
const payload = {
career_name: formData.careerName,
status: formData.status,
start_date: formData.startDate,
projected_end_date: formData.projectedEndDate,
await authFetch(`${apiURL}/premium/college-profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
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
})
});
planned_monthly_expenses: formData.plannedMonthlyExpenses === ''
? null
: parseFloat(formData.plannedMonthlyExpenses),
planned_monthly_debt_payments: formData.plannedMonthlyDebt === ''
? null
: parseFloat(formData.plannedMonthlyDebt),
planned_monthly_retirement_contribution: formData.plannedMonthlyRetirement === ''
? null
: parseFloat(formData.plannedMonthlyRetirement),
planned_monthly_emergency_contribution: formData.plannedMonthlyEmergency === ''
? null
: parseFloat(formData.plannedMonthlyEmergency),
planned_surplus_emergency_pct: formData.plannedSurplusEmergencyPct === ''
? null
: parseFloat(formData.plannedSurplusEmergencyPct),
planned_surplus_retirement_pct: formData.plannedSurplusRetirementPct === ''
? null
: parseFloat(formData.plannedSurplusRetirementPct),
planned_additional_income: formData.plannedAdditionalIncome === ''
? null
: parseFloat(formData.plannedAdditionalIncome),
};
// 2) Update local React state so your useEffect triggers re-simulation
setFinancialProfile((prev) => ({
const res = await authFetch(`${apiURL}/premium/career-profile`, {
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,
current_salary: formData.currentSalary,
monthly_expenses: formData.monthlyExpenses,
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
career_name: formData.careerName,
status: formData.status,
start_date: formData.startDate,
projected_end_date: formData.projectedEndDate,
planned_monthly_expenses: payload.planned_monthly_expenses,
planned_monthly_debt_payments: payload.planned_monthly_debt_payments,
planned_monthly_retirement_contribution: payload.planned_monthly_retirement_contribution,
planned_monthly_emergency_contribution: payload.planned_monthly_emergency_contribution,
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();
} catch (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;
return (
<div className="modal-backdrop">
<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 */}
<div className="mb-3">
<label className="block font-semibold">Current Salary</label>
<input
type="number"
name="currentSalary"
value={formData.currentSalary}
onChange={handleChange}
className="border px-2 py-1 w-full"
/>
</div>
<label>Scenario Name</label>
<input
name="careerName"
value={formData.careerName}
onChange={handleChange}
/>
<div className="mb-3">
<label className="block font-semibold">Monthly Expenses</label>
<input
type="number"
name="monthlyExpenses"
value={formData.monthlyExpenses}
onChange={handleChange}
className="border px-2 py-1 w-full"
/>
</div>
<label>Status</label>
<select
name="status"
value={formData.status}
onChange={handleChange}
>
<option value="planned">Planned</option>
<option value="current">Current</option>
<option value="completed">Completed</option>
</select>
<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>
{/* A few new fields for “planned_” columns: */}
<label>Planned Monthly Expenses</label>
<input
type="number"
name="plannedMonthlyExpenses"
value={formData.plannedMonthlyExpenses}
onChange={handleChange}
/>
<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>
<label>Planned Monthly Debt</label>
<input
type="number"
name="plannedMonthlyDebt"
value={formData.plannedMonthlyDebt}
onChange={handleChange}
/>
{/* 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>
<label>Planned Retirement Contribution (monthly)</label>
<input
type="number"
name="plannedMonthlyRetirement"
value={formData.plannedMonthlyRetirement}
onChange={handleChange}
/>
{/* Add all other fields you want to expose... */}
<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}
/>
{/* Modal Buttons */}
<div className="flex justify-end mt-6">
<button
onClick={onClose}
className="px-4 py-2 mr-2 border rounded"
>
<button onClick={onClose} style={{ marginRight: '1rem' }}>
Cancel
</button>
<button
onClick={handleSave}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
<button onClick={handleSave}>
Save
</button>
</div>

Binary file not shown.