Added wizard functionality to milestones for copy/delete. Still need to add global refresh.
This commit is contained in:
parent
eed3767172
commit
23ac7260ab
@ -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)
|
||||
------------------------------------------------------------------ */
|
||||
|
14
src/App.js
14
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() {
|
||||
<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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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: */}
|
||||
<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}
|
||||
|
@ -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 scenario’s milestones (where is_universal=0) + universal (is_universal=1)
|
||||
// 2) Fetch scenario’s 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 user’s 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) We’ll 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 scenario’s row.
|
||||
// For simplicity, we’ll 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 scenario’s 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,
|
||||
// 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -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>
|
||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user