diff --git a/backend/server3.js b/backend/server3.js index 64e09f5..29caa9f 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -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) ------------------------------------------------------------------ */ diff --git a/src/App.js b/src/App.js index 3391a8e..4c2593c 100644 --- a/src/App.js +++ b/src/App.js @@ -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() { } /> } /> + {/* NEW multi-scenario route */} + } /> )} } /> + ); diff --git a/src/components/MilestoneTimeline.js b/src/components/MilestoneTimeline.js index d7f6c7d..996c37b 100644 --- a/src/components/MilestoneTimeline.js +++ b/src/components/MilestoneTimeline.js @@ -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 ( +
+
+

Copy Milestone to Other Scenarios

+

Milestone: {milestone.title}

+ + {scenarios.map(s => ( +
+ +
+ ))} + +
+ + +
+
+
+ ); + } + + // ------------------------------------------------------------------ + // 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 ( -
-

Loading or no milestones in this view...

-
- ); - } - + // ------------------------------------------------------------------ + // Render + // ------------------------------------------------------------------ return (
- {['Career', 'Financial'].map((view) => ( + {['Career', 'Financial'].map(view => (
)} + {/* Timeline */}
@@ -586,6 +736,23 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView, {showTaskForm === m.id ? 'Cancel Task' : 'Add Task'} + {/* Edit, Copy, Delete Buttons */} +
+ + + +
+ {showTaskForm === m.id && (
+ + {/* CopyWizard modal if copying */} + {copyWizardMilestone && ( + setCopyWizardMilestone(null)} + authFetch={authFetch} + onMilestoneUpdated={onMilestoneUpdated} + /> + )}
); }; diff --git a/src/components/MilestoneTracker.js b/src/components/MilestoneTracker.js index 723a7a4..9d644f1 100644 --- a/src/components/MilestoneTracker.js +++ b/src/components/MilestoneTracker.js @@ -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 user’s 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: */} {
)} -
- - -
+
+ + +
setPendingCareerForModal(careerName)} @@ -500,7 +515,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => { {pendingCareerForModal && ( - +
- {/* 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 */} setEditOpen(false)} - financialProfile={financialProfile} - setFinancialProfile={() => { - // If you truly want scenario-specific financial data, - // you’d 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} />
); diff --git a/src/components/ScenarioEditModal.js b/src/components/ScenarioEditModal.js index 880abda..be03aeb 100644 --- a/src/components/ScenarioEditModal.js +++ b/src/components/ScenarioEditModal.js @@ -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 (
-

Edit Scenario Inputs

+

Edit Scenario

- {/* EXAMPLE FIELDS: Add all the fields you actually want visible */} -
- - -
+ + -
- - -
+ + -
- - -
+ {/* A few new fields for “planned_” columns: */} + + -
- - -
+ + - {/* Example checkbox for loan deferral */} -
- - -
+ + - {/* Add all other fields you want to expose... */} + + + + + + + + + + + - {/* Modal Buttons */}
- -
diff --git a/user_profile.db b/user_profile.db index b4507c2..c011861 100644 Binary files a/user_profile.db and b/user_profile.db differ