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
|
// POST a new career profile
|
||||||
|
// server3.js
|
||||||
app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res) => {
|
app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res) => {
|
||||||
const {
|
const {
|
||||||
career_name,
|
career_name,
|
||||||
@ -100,10 +123,18 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
|||||||
start_date,
|
start_date,
|
||||||
projected_end_date,
|
projected_end_date,
|
||||||
college_enrollment_status,
|
college_enrollment_status,
|
||||||
currently_working
|
currently_working,
|
||||||
|
|
||||||
|
// NEW planned columns
|
||||||
|
planned_monthly_expenses,
|
||||||
|
planned_monthly_debt_payments,
|
||||||
|
planned_monthly_retirement_contribution,
|
||||||
|
planned_monthly_emergency_contribution,
|
||||||
|
planned_surplus_emergency_pct,
|
||||||
|
planned_surplus_retirement_pct,
|
||||||
|
planned_additional_income
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// If you need to ensure the user gave us a career_name:
|
|
||||||
if (!career_name) {
|
if (!career_name) {
|
||||||
return res.status(400).json({ error: 'career_name is required.' });
|
return res.status(400).json({ error: 'career_name is required.' });
|
||||||
}
|
}
|
||||||
@ -112,6 +143,8 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
|||||||
const newCareerPathId = uuidv4();
|
const newCareerPathId = uuidv4();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Insert or update row in career_paths. We rely on ON CONFLICT(user_id, career_name).
|
||||||
|
// If you want a different conflict target, change accordingly.
|
||||||
await db.run(`
|
await db.run(`
|
||||||
INSERT INTO career_paths (
|
INSERT INTO career_paths (
|
||||||
id,
|
id,
|
||||||
@ -122,10 +155,21 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
|||||||
projected_end_date,
|
projected_end_date,
|
||||||
college_enrollment_status,
|
college_enrollment_status,
|
||||||
currently_working,
|
currently_working,
|
||||||
|
|
||||||
|
planned_monthly_expenses,
|
||||||
|
planned_monthly_debt_payments,
|
||||||
|
planned_monthly_retirement_contribution,
|
||||||
|
planned_monthly_emergency_contribution,
|
||||||
|
planned_surplus_emergency_pct,
|
||||||
|
planned_surplus_retirement_pct,
|
||||||
|
planned_additional_income,
|
||||||
|
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?)
|
||||||
ON CONFLICT(user_id, career_name)
|
ON CONFLICT(user_id, career_name)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
@ -133,22 +177,41 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
|||||||
projected_end_date = excluded.projected_end_date,
|
projected_end_date = excluded.projected_end_date,
|
||||||
college_enrollment_status = excluded.college_enrollment_status,
|
college_enrollment_status = excluded.college_enrollment_status,
|
||||||
currently_working = excluded.currently_working,
|
currently_working = excluded.currently_working,
|
||||||
|
|
||||||
|
planned_monthly_expenses = excluded.planned_monthly_expenses,
|
||||||
|
planned_monthly_debt_payments = excluded.planned_monthly_debt_payments,
|
||||||
|
planned_monthly_retirement_contribution = excluded.planned_monthly_retirement_contribution,
|
||||||
|
planned_monthly_emergency_contribution = excluded.planned_monthly_emergency_contribution,
|
||||||
|
planned_surplus_emergency_pct = excluded.planned_surplus_emergency_pct,
|
||||||
|
planned_surplus_retirement_pct = excluded.planned_surplus_retirement_pct,
|
||||||
|
planned_additional_income = excluded.planned_additional_income,
|
||||||
|
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
`, [
|
`, [
|
||||||
newCareerPathId, // id
|
newCareerPathId,
|
||||||
req.userId, // user_id
|
req.userId,
|
||||||
career_name, // career_name
|
career_name,
|
||||||
status || 'planned', // status (if null, default to 'planned')
|
status || 'planned',
|
||||||
start_date || now,
|
start_date || now,
|
||||||
projected_end_date || null,
|
projected_end_date || null,
|
||||||
college_enrollment_status || null,
|
college_enrollment_status || null,
|
||||||
currently_working || null,
|
currently_working || null,
|
||||||
|
|
||||||
|
// new planned columns
|
||||||
|
planned_monthly_expenses ?? null,
|
||||||
|
planned_monthly_debt_payments ?? null,
|
||||||
|
planned_monthly_retirement_contribution ?? null,
|
||||||
|
planned_monthly_emergency_contribution ?? null,
|
||||||
|
planned_surplus_emergency_pct ?? null,
|
||||||
|
planned_surplus_retirement_pct ?? null,
|
||||||
|
planned_additional_income ?? null,
|
||||||
|
|
||||||
now, // created_at
|
now, // created_at
|
||||||
now, // updated_at on initial insert
|
now, // updated_at
|
||||||
now // updated_at on conflict
|
now // updated_at on conflict
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Optionally fetch the row's ID after upsert
|
// Optionally fetch the row's ID (or entire row) after upsert:
|
||||||
const result = await db.get(`
|
const result = await db.get(`
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM career_paths
|
FROM career_paths
|
||||||
@ -166,7 +229,11 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
Milestone ENDPOINTS
|
||||||
|
------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
// CREATE one or more milestones
|
||||||
app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) => {
|
app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body;
|
const body = req.body;
|
||||||
@ -183,12 +250,12 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
|||||||
career_path_id,
|
career_path_id,
|
||||||
progress,
|
progress,
|
||||||
status,
|
status,
|
||||||
new_salary
|
new_salary,
|
||||||
|
is_universal
|
||||||
} = m;
|
} = m;
|
||||||
|
|
||||||
// Validate some required fields
|
// Validate some required fields
|
||||||
if (!milestone_type || !title || !date || !career_path_id) {
|
if (!milestone_type || !title || !date || !career_path_id) {
|
||||||
// Optionally handle partial errors, but let's do a quick check
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'One or more milestones missing required fields',
|
error: 'One or more milestones missing required fields',
|
||||||
details: m
|
details: m
|
||||||
@ -210,9 +277,10 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
|||||||
progress,
|
progress,
|
||||||
status,
|
status,
|
||||||
new_salary,
|
new_salary,
|
||||||
|
is_universal,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, [
|
`, [
|
||||||
id,
|
id,
|
||||||
req.userId,
|
req.userId,
|
||||||
@ -224,6 +292,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
|||||||
progress || 0,
|
progress || 0,
|
||||||
status || 'planned',
|
status || 'planned',
|
||||||
new_salary || null,
|
new_salary || null,
|
||||||
|
is_universal ? 1 : 0, // store 1 or 0
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
]);
|
]);
|
||||||
@ -239,6 +308,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
|||||||
progress: progress || 0,
|
progress: progress || 0,
|
||||||
status: status || 'planned',
|
status: status || 'planned',
|
||||||
new_salary: new_salary || null,
|
new_salary: new_salary || null,
|
||||||
|
is_universal: is_universal ? 1 : 0,
|
||||||
tasks: []
|
tasks: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -246,7 +316,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
|||||||
return res.status(201).json(createdMilestones);
|
return res.status(201).json(createdMilestones);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CASE 2: Handle single milestone (the old logic)
|
// CASE 2: Single milestone creation
|
||||||
const {
|
const {
|
||||||
milestone_type,
|
milestone_type,
|
||||||
title,
|
title,
|
||||||
@ -255,7 +325,8 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
|||||||
career_path_id,
|
career_path_id,
|
||||||
progress,
|
progress,
|
||||||
status,
|
status,
|
||||||
new_salary
|
new_salary,
|
||||||
|
is_universal
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!milestone_type || !title || !date || !career_path_id) {
|
if (!milestone_type || !title || !date || !career_path_id) {
|
||||||
@ -280,9 +351,10 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
|||||||
progress,
|
progress,
|
||||||
status,
|
status,
|
||||||
new_salary,
|
new_salary,
|
||||||
|
is_universal,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, [
|
`, [
|
||||||
id,
|
id,
|
||||||
req.userId,
|
req.userId,
|
||||||
@ -294,6 +366,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
|||||||
progress || 0,
|
progress || 0,
|
||||||
status || 'planned',
|
status || 'planned',
|
||||||
new_salary || null,
|
new_salary || null,
|
||||||
|
is_universal ? 1 : 0,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
]);
|
]);
|
||||||
@ -310,6 +383,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
|||||||
progress: progress || 0,
|
progress: progress || 0,
|
||||||
status: status || 'planned',
|
status: status || 'planned',
|
||||||
new_salary: new_salary || null,
|
new_salary: new_salary || null,
|
||||||
|
is_universal: is_universal ? 1 : 0,
|
||||||
tasks: []
|
tasks: []
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -320,6 +394,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// UPDATE an existing milestone
|
||||||
app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (req, res) => {
|
app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { milestoneId } = req.params;
|
const { milestoneId } = req.params;
|
||||||
@ -331,7 +406,8 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
|
|||||||
career_path_id,
|
career_path_id,
|
||||||
progress,
|
progress,
|
||||||
status,
|
status,
|
||||||
new_salary
|
new_salary,
|
||||||
|
is_universal
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Check if milestone exists and belongs to user
|
// Check if milestone exists and belongs to user
|
||||||
@ -346,8 +422,21 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
|
|||||||
return res.status(404).json({ error: 'Milestone not found or not yours.' });
|
return res.status(404).json({ error: 'Milestone not found or not yours.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Merge fields with existing if not provided
|
||||||
|
const finalMilestoneType = milestone_type || existing.milestone_type;
|
||||||
|
const finalTitle = title || existing.title;
|
||||||
|
const finalDesc = description || existing.description;
|
||||||
|
const finalDate = date || existing.date;
|
||||||
|
const finalCareerPath = career_path_id || existing.career_path_id;
|
||||||
|
const finalProgress = progress != null ? progress : existing.progress;
|
||||||
|
const finalStatus = status || existing.status;
|
||||||
|
const finalSalary = new_salary != null ? new_salary : existing.new_salary;
|
||||||
|
const finalIsUniversal =
|
||||||
|
is_universal != null ? (is_universal ? 1 : 0) : existing.is_universal;
|
||||||
|
|
||||||
|
// Update row
|
||||||
await db.run(`
|
await db.run(`
|
||||||
UPDATE milestones
|
UPDATE milestones
|
||||||
SET
|
SET
|
||||||
@ -359,19 +448,23 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
|
|||||||
progress = ?,
|
progress = ?,
|
||||||
status = ?,
|
status = ?,
|
||||||
new_salary = ?,
|
new_salary = ?,
|
||||||
|
is_universal = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
AND user_id = ?
|
||||||
`, [
|
`, [
|
||||||
milestone_type || existing.milestone_type,
|
finalMilestoneType,
|
||||||
title || existing.title,
|
finalTitle,
|
||||||
description || existing.description,
|
finalDesc,
|
||||||
date || existing.date,
|
finalDate,
|
||||||
career_path_id || existing.career_path_id,
|
finalCareerPath,
|
||||||
progress != null ? progress : existing.progress,
|
finalProgress,
|
||||||
status || existing.status,
|
finalStatus,
|
||||||
new_salary != null ? new_salary : existing.new_salary,
|
finalSalary,
|
||||||
|
finalIsUniversal,
|
||||||
now,
|
now,
|
||||||
milestoneId
|
milestoneId,
|
||||||
|
req.userId
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Return the updated record with tasks
|
// Return the updated record with tasks
|
||||||
@ -400,11 +493,44 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET all milestones for a given careerPathId
|
||||||
app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => {
|
app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => {
|
||||||
const { careerPathId } = req.query;
|
const { careerPathId } = req.query;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Fetch the milestones for this user + path
|
// if user wants universal=1 only, e.g. careerPathId=universal
|
||||||
|
if (careerPathId === 'universal') {
|
||||||
|
// For example, fetch all is_universal=1 for the user:
|
||||||
|
const universalRows = await db.all(`
|
||||||
|
SELECT *
|
||||||
|
FROM milestones
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND is_universal = 1
|
||||||
|
`, [req.userId]);
|
||||||
|
|
||||||
|
// attach tasks if needed
|
||||||
|
const milestoneIds = universalRows.map(m => m.id);
|
||||||
|
let tasksByMilestone = {};
|
||||||
|
if (milestoneIds.length > 0) {
|
||||||
|
const tasks = await db.all(`
|
||||||
|
SELECT *
|
||||||
|
FROM tasks
|
||||||
|
WHERE milestone_id IN (${milestoneIds.map(() => '?').join(',')})
|
||||||
|
`, milestoneIds);
|
||||||
|
tasksByMilestone = tasks.reduce((acc, t) => {
|
||||||
|
if (!acc[t.milestone_id]) acc[t.milestone_id] = [];
|
||||||
|
acc[t.milestone_id].push(t);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
const uniMils = universalRows.map(m => ({
|
||||||
|
...m,
|
||||||
|
tasks: tasksByMilestone[m.id] || []
|
||||||
|
}));
|
||||||
|
return res.json({ milestones: uniMils });
|
||||||
|
}
|
||||||
|
|
||||||
|
// else fetch by careerPathId
|
||||||
const milestones = await db.all(`
|
const milestones = await db.all(`
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM milestones
|
FROM milestones
|
||||||
@ -412,7 +538,6 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
|
|||||||
AND career_path_id = ?
|
AND career_path_id = ?
|
||||||
`, [req.userId, careerPathId]);
|
`, [req.userId, careerPathId]);
|
||||||
|
|
||||||
// 2. For each milestone, fetch tasks
|
|
||||||
const milestoneIds = milestones.map(m => m.id);
|
const milestoneIds = milestones.map(m => m.id);
|
||||||
let tasksByMilestone = {};
|
let tasksByMilestone = {};
|
||||||
if (milestoneIds.length > 0) {
|
if (milestoneIds.length > 0) {
|
||||||
@ -429,7 +554,6 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
|
|||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Attach tasks to each milestone object
|
|
||||||
const milestonesWithTasks = milestones.map(m => ({
|
const milestonesWithTasks = milestones.map(m => ({
|
||||||
...m,
|
...m,
|
||||||
tasks: tasksByMilestone[m.id] || []
|
tasks: tasksByMilestone[m.id] || []
|
||||||
@ -442,6 +566,259 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// COPY an existing milestone to other scenarios
|
||||||
|
app.post('/api/premium/milestone/copy', authenticatePremiumUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { milestoneId, scenarioIds } = req.body;
|
||||||
|
if (!milestoneId || !Array.isArray(scenarioIds) || scenarioIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Missing milestoneId or scenarioIds.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Fetch the original
|
||||||
|
const original = await db.get(`
|
||||||
|
SELECT *
|
||||||
|
FROM milestones
|
||||||
|
WHERE id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
`, [milestoneId, req.userId]);
|
||||||
|
|
||||||
|
if (!original) {
|
||||||
|
return res.status(404).json({ error: 'Milestone not found or not owned by user.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Force is_universal=1 on the original
|
||||||
|
if (original.is_universal !== 1) {
|
||||||
|
await db.run(`
|
||||||
|
UPDATE milestones
|
||||||
|
SET is_universal = 1
|
||||||
|
WHERE id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
`, [ milestoneId, req.userId ]);
|
||||||
|
|
||||||
|
// Also refresh "original" object if you want
|
||||||
|
original.is_universal = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) If no origin_milestone_id, set it
|
||||||
|
let originId = original.origin_milestone_id || original.id;
|
||||||
|
if (!original.origin_milestone_id) {
|
||||||
|
await db.run(`
|
||||||
|
UPDATE milestones
|
||||||
|
SET origin_milestone_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
`, [ originId, milestoneId, req.userId ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) fetch tasks & impacts
|
||||||
|
const tasks = await db.all(`
|
||||||
|
SELECT *
|
||||||
|
FROM tasks
|
||||||
|
WHERE milestone_id = ?
|
||||||
|
`, [milestoneId]);
|
||||||
|
|
||||||
|
const impacts = await db.all(`
|
||||||
|
SELECT *
|
||||||
|
FROM milestone_impacts
|
||||||
|
WHERE milestone_id = ?
|
||||||
|
`, [milestoneId]);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const copiesCreated = [];
|
||||||
|
|
||||||
|
for (let scenarioId of scenarioIds) {
|
||||||
|
if (scenarioId === original.career_path_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMilestoneId = uuidv4();
|
||||||
|
|
||||||
|
// Always set isUniversal=1 on copies
|
||||||
|
const isUniversal = 1;
|
||||||
|
|
||||||
|
await db.run(`
|
||||||
|
INSERT INTO milestones (
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
career_path_id,
|
||||||
|
milestone_type,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
date,
|
||||||
|
progress,
|
||||||
|
status,
|
||||||
|
new_salary,
|
||||||
|
is_universal,
|
||||||
|
origin_milestone_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, [
|
||||||
|
newMilestoneId,
|
||||||
|
req.userId,
|
||||||
|
scenarioId,
|
||||||
|
original.milestone_type,
|
||||||
|
original.title,
|
||||||
|
original.description,
|
||||||
|
original.date,
|
||||||
|
original.progress,
|
||||||
|
original.status,
|
||||||
|
original.new_salary,
|
||||||
|
isUniversal,
|
||||||
|
originId,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
]);
|
||||||
|
|
||||||
|
// copy tasks
|
||||||
|
for (let t of tasks) {
|
||||||
|
const newTaskId = uuidv4();
|
||||||
|
await db.run(`
|
||||||
|
INSERT INTO tasks (
|
||||||
|
id,
|
||||||
|
milestone_id,
|
||||||
|
user_id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
due_date,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'not_started', ?, ?)
|
||||||
|
`, [
|
||||||
|
newTaskId,
|
||||||
|
newMilestoneId,
|
||||||
|
req.userId,
|
||||||
|
t.title,
|
||||||
|
t.description,
|
||||||
|
t.due_date || null,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy impacts
|
||||||
|
for (let imp of impacts) {
|
||||||
|
const newImpactId = uuidv4();
|
||||||
|
await db.run(`
|
||||||
|
INSERT INTO milestone_impacts (
|
||||||
|
id,
|
||||||
|
milestone_id,
|
||||||
|
impact_type,
|
||||||
|
direction,
|
||||||
|
amount,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, [
|
||||||
|
newImpactId,
|
||||||
|
newMilestoneId,
|
||||||
|
imp.impact_type,
|
||||||
|
imp.direction,
|
||||||
|
imp.amount,
|
||||||
|
imp.start_date || null,
|
||||||
|
imp.end_date || null,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
copiesCreated.push(newMilestoneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
originalId: milestoneId,
|
||||||
|
origin_milestone_id: originId,
|
||||||
|
copiesCreated
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error copying milestone:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to copy milestone.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE milestone from ALL scenarios
|
||||||
|
app.delete('/api/premium/milestones/:milestoneId/all', authenticatePremiumUser, async (req, res) => {
|
||||||
|
const { milestoneId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) Fetch the milestone
|
||||||
|
const existing = await db.get(`
|
||||||
|
SELECT id, user_id, origin_milestone_id
|
||||||
|
FROM milestones
|
||||||
|
WHERE id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
`, [milestoneId, req.userId]);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Milestone not found or not owned by user.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originId = existing.origin_milestone_id || existing.id;
|
||||||
|
|
||||||
|
// 2) Delete all copies referencing that origin
|
||||||
|
await db.run(`
|
||||||
|
DELETE FROM milestones
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND origin_milestone_id = ?
|
||||||
|
`, [req.userId, originId]);
|
||||||
|
|
||||||
|
// Also delete the original if it doesn't store itself in origin_milestone_id
|
||||||
|
await db.run(`
|
||||||
|
DELETE FROM milestones
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND id = ?
|
||||||
|
AND origin_milestone_id IS NULL
|
||||||
|
`, [req.userId, originId]);
|
||||||
|
|
||||||
|
res.json({ message: 'Deleted from all scenarios' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting milestone from all scenarios:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete milestone from all scenarios.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE milestone from this scenario only
|
||||||
|
app.delete('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (req, res) => {
|
||||||
|
const { milestoneId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) check user ownership
|
||||||
|
const existing = await db.get(`
|
||||||
|
SELECT id, user_id
|
||||||
|
FROM milestones
|
||||||
|
WHERE id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
`, [milestoneId, req.userId]);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Milestone not found or not owned by user.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Delete the single row
|
||||||
|
await db.run(`
|
||||||
|
DELETE FROM milestones
|
||||||
|
WHERE id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
`, [milestoneId, req.userId]);
|
||||||
|
|
||||||
|
// optionally also remove tasks + impacts if you want
|
||||||
|
// e.g.:
|
||||||
|
// await db.run('DELETE FROM tasks WHERE milestone_id = ?', [milestoneId]);
|
||||||
|
// await db.run('DELETE FROM milestone_impacts WHERE milestone_id = ?', [milestoneId]);
|
||||||
|
|
||||||
|
res.json({ message: 'Milestone deleted from this scenario.' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting single milestone:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete milestone.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
FINANCIAL PROFILES (Renamed emergency_contribution)
|
FINANCIAL PROFILES (Renamed emergency_contribution)
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
|
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 Paywall from "./components/Paywall.js";
|
||||||
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
|
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
|
||||||
|
|
||||||
|
// NEW: import your MultiScenarioView component
|
||||||
|
import MultiScenarioView from './components/MultiScenarioView.js';
|
||||||
|
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -21,7 +24,13 @@ function App() {
|
|||||||
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(() => !!localStorage.getItem('token'));
|
const [isAuthenticated, setIsAuthenticated] = useState(() => !!localStorage.getItem('token'));
|
||||||
|
|
||||||
const premiumPaths = ['/milestone-tracker', '/paywall', '/financial-profile'];
|
// Any paths that are specifically “premium” (where you might not want to show an Upgrade CTA).
|
||||||
|
const premiumPaths = [
|
||||||
|
'/milestone-tracker',
|
||||||
|
'/paywall',
|
||||||
|
'/financial-profile',
|
||||||
|
'/multi-scenario', // ADDED here so the CTA is hidden on the multi-scenario page
|
||||||
|
];
|
||||||
|
|
||||||
const showPremiumCTA = !premiumPaths.includes(location.pathname);
|
const showPremiumCTA = !premiumPaths.includes(location.pathname);
|
||||||
|
|
||||||
@ -55,11 +64,14 @@ function App() {
|
|||||||
<Route path="/financial-profile" element={<FinancialProfileForm />} />
|
<Route path="/financial-profile" element={<FinancialProfileForm />} />
|
||||||
<Route path="/premium-onboarding" element={<OnboardingContainer />} />
|
<Route path="/premium-onboarding" element={<OnboardingContainer />} />
|
||||||
|
|
||||||
|
{/* NEW multi-scenario route */}
|
||||||
|
<Route path="/multi-scenario" element={<MultiScenarioView />} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/signin" />} />
|
<Route path="*" element={<Navigate to="/signin" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
<SessionExpiredHandler />
|
<SessionExpiredHandler />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -3,21 +3,27 @@ import React, { useEffect, useState, useCallback } from 'react';
|
|||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|
||||||
const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView, onMilestoneUpdated }) => {
|
const MilestoneTimeline = ({
|
||||||
|
careerPathId,
|
||||||
|
authFetch,
|
||||||
|
activeView,
|
||||||
|
setActiveView,
|
||||||
|
onMilestoneUpdated // optional callback if you want the parent to be notified of changes
|
||||||
|
}) => {
|
||||||
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
|
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
|
||||||
|
|
||||||
// The "new or edit" milestone form state
|
// "new or edit" milestone form data
|
||||||
const [newMilestone, setNewMilestone] = useState({
|
const [newMilestone, setNewMilestone] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
date: '',
|
date: '',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
newSalary: '',
|
newSalary: '',
|
||||||
// Each impact can have: { id?, impact_type, direction, amount, start_date, end_date }
|
impacts: [],
|
||||||
impacts: []
|
isUniversal: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
// We track which existing impacts the user removed, so we can DELETE them
|
// We'll track which existing impacts are removed so we can do a DELETE if needed
|
||||||
const [impactsToDelete, setImpactsToDelete] = useState([]);
|
const [impactsToDelete, setImpactsToDelete] = useState([]);
|
||||||
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@ -27,10 +33,77 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
const [showTaskForm, setShowTaskForm] = useState(null);
|
const [showTaskForm, setShowTaskForm] = useState(null);
|
||||||
const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' });
|
const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' });
|
||||||
|
|
||||||
/**
|
// For the Copy wizard
|
||||||
* Fetch all milestones (and their tasks) for this careerPathId.
|
const [scenarios, setScenarios] = useState([]);
|
||||||
* Then categorize them by milestone_type: 'Career' or 'Financial'.
|
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
||||||
*/
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 1) Impact Helper Functions (define them first to avoid scoping errors)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Insert a new blank impact into newMilestone.impacts
|
||||||
|
const addNewImpact = () => {
|
||||||
|
setNewMilestone(prev => ({
|
||||||
|
...prev,
|
||||||
|
impacts: [
|
||||||
|
...prev.impacts,
|
||||||
|
{
|
||||||
|
impact_type: 'ONE_TIME',
|
||||||
|
direction: 'subtract',
|
||||||
|
amount: 0,
|
||||||
|
start_date: '',
|
||||||
|
end_date: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove an impact from newMilestone.impacts
|
||||||
|
const removeImpact = (idx) => {
|
||||||
|
setNewMilestone(prev => {
|
||||||
|
const newImpacts = [...prev.impacts];
|
||||||
|
const removed = newImpacts[idx];
|
||||||
|
if (removed.id) {
|
||||||
|
// queue up for DB DELETE
|
||||||
|
setImpactsToDelete(old => [...old, removed.id]);
|
||||||
|
}
|
||||||
|
newImpacts.splice(idx, 1);
|
||||||
|
return { ...prev, impacts: newImpacts };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update a specific impact property
|
||||||
|
const updateImpact = (idx, field, value) => {
|
||||||
|
setNewMilestone(prev => {
|
||||||
|
const newImpacts = [...prev.impacts];
|
||||||
|
newImpacts[idx] = { ...newImpacts[idx], [field]: value };
|
||||||
|
return { ...prev, impacts: newImpacts };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 2) Load scenarios (for copy wizard)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadScenarios() {
|
||||||
|
try {
|
||||||
|
const res = await authFetch('/api/premium/career-profile/all');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setScenarios(data.careerPaths || []);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to load scenarios. Status:', res.status);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading scenarios for copy wizard:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadScenarios();
|
||||||
|
}, [authFetch]);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 3) Fetch milestones for the current scenario
|
||||||
|
// ------------------------------------------------------------------
|
||||||
const fetchMilestones = useCallback(async () => {
|
const fetchMilestones = useCallback(async () => {
|
||||||
if (!careerPathId) return;
|
if (!careerPathId) return;
|
||||||
try {
|
try {
|
||||||
@ -41,12 +114,12 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.milestones) {
|
if (!data.milestones) {
|
||||||
console.warn('No milestones field in response:', data);
|
console.warn('No milestones returned:', data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const categorized = { Career: [], Financial: [] };
|
const categorized = { Career: [], Financial: [] };
|
||||||
data.milestones.forEach((m) => {
|
data.milestones.forEach(m => {
|
||||||
if (categorized[m.milestone_type]) {
|
if (categorized[m.milestone_type]) {
|
||||||
categorized[m.milestone_type].push(m);
|
categorized[m.milestone_type].push(m);
|
||||||
} else {
|
} else {
|
||||||
@ -64,57 +137,52 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
fetchMilestones();
|
fetchMilestones();
|
||||||
}, [fetchMilestones]);
|
}, [fetchMilestones]);
|
||||||
|
|
||||||
/**
|
// ------------------------------------------------------------------
|
||||||
* Async function to edit an existing milestone.
|
// 4) "Edit" an existing milestone => load impacts
|
||||||
* Fetch its impacts, populate newMilestone, show the form.
|
// ------------------------------------------------------------------
|
||||||
*/
|
|
||||||
const handleEditMilestone = async (m) => {
|
const handleEditMilestone = async (m) => {
|
||||||
try {
|
try {
|
||||||
// Reset impactsToDelete whenever we edit a new milestone
|
|
||||||
setImpactsToDelete([]);
|
setImpactsToDelete([]);
|
||||||
|
|
||||||
// Fetch existing impacts for milestone "m"
|
|
||||||
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
|
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error('Failed to fetch milestone impacts, status:', res.status);
|
console.error('Failed to fetch milestone impacts. Status:', res.status);
|
||||||
throw new Error(`HTTP ${res.status}`);
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const fetchedImpacts = data.impacts || [];
|
const fetchedImpacts = data.impacts || [];
|
||||||
|
|
||||||
// Populate the newMilestone form
|
|
||||||
setNewMilestone({
|
setNewMilestone({
|
||||||
title: m.title || '',
|
title: m.title || '',
|
||||||
description: m.description || '',
|
description: m.description || '',
|
||||||
date: m.date || '',
|
date: m.date || '',
|
||||||
progress: m.progress || 0,
|
progress: m.progress || 0,
|
||||||
newSalary: m.new_salary || '',
|
newSalary: m.new_salary || '',
|
||||||
impacts: fetchedImpacts.map((imp) => ({
|
impacts: fetchedImpacts.map(imp => ({
|
||||||
// If the DB row has id, we'll store it for PUT or DELETE
|
|
||||||
id: imp.id,
|
id: imp.id,
|
||||||
impact_type: imp.impact_type || 'ONE_TIME',
|
impact_type: imp.impact_type || 'ONE_TIME',
|
||||||
direction: imp.direction || 'subtract',
|
direction: imp.direction || 'subtract',
|
||||||
amount: imp.amount || 0,
|
amount: imp.amount || 0,
|
||||||
start_date: imp.start_date || '',
|
start_date: imp.start_date || '',
|
||||||
end_date: imp.end_date || ''
|
end_date: imp.end_date || ''
|
||||||
}))
|
})),
|
||||||
|
isUniversal: m.is_universal ? 1 : 0
|
||||||
});
|
});
|
||||||
|
|
||||||
setEditingMilestone(m);
|
setEditingMilestone(m);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error in handleEditMilestone:', err);
|
console.error('Error editing milestone:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// ------------------------------------------------------------------
|
||||||
* Create or update a milestone (plus handle impacts).
|
// 5) Save (create or update) a milestone => handle impacts if needed
|
||||||
*/
|
// ------------------------------------------------------------------
|
||||||
const saveMilestone = async () => {
|
const saveMilestone = async () => {
|
||||||
if (!activeView) return;
|
if (!activeView) return;
|
||||||
|
|
||||||
// If editing, we do PUT; otherwise POST
|
|
||||||
const url = editingMilestone
|
const url = editingMilestone
|
||||||
? `/api/premium/milestones/${editingMilestone.id}`
|
? `/api/premium/milestones/${editingMilestone.id}`
|
||||||
: `/api/premium/milestone`;
|
: `/api/premium/milestone`;
|
||||||
@ -131,143 +199,126 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
new_salary:
|
new_salary:
|
||||||
activeView === 'Financial' && newMilestone.newSalary
|
activeView === 'Financial' && newMilestone.newSalary
|
||||||
? parseFloat(newMilestone.newSalary)
|
? parseFloat(newMilestone.newSalary)
|
||||||
: null
|
: null,
|
||||||
|
is_universal: newMilestone.isUniversal || 0
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Sending request:', method, url, payload);
|
|
||||||
const res = await authFetch(url, {
|
const res = await authFetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json();
|
const errData = await res.json();
|
||||||
console.error('Failed to save milestone:', errorData);
|
console.error('Failed to save milestone:', errData);
|
||||||
alert(errorData.error || 'Error saving milestone');
|
alert(errData.error || 'Error saving milestone');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (onMilestoneUpdated) onMilestoneUpdated();
|
|
||||||
|
|
||||||
const savedMilestone = await res.json();
|
const savedMilestone = await res.json();
|
||||||
console.log('Milestone saved/updated:', savedMilestone);
|
console.log('Milestone saved/updated:', savedMilestone);
|
||||||
|
|
||||||
// If Financial, handle the "impacts"
|
// If financial => handle impacts
|
||||||
if (activeView === 'Financial') {
|
if (activeView === 'Financial') {
|
||||||
// 1) Delete impacts that user removed
|
// 1) Delete old impacts
|
||||||
for (const impactId of impactsToDelete) {
|
for (const impactId of impactsToDelete) {
|
||||||
if (impactId) {
|
if (impactId) {
|
||||||
console.log('Deleting old impact', impactId);
|
|
||||||
const delRes = await authFetch(`/api/premium/milestone-impacts/${impactId}`, {
|
const delRes = await authFetch(`/api/premium/milestone-impacts/${impactId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
if (!delRes.ok) {
|
if (!delRes.ok) {
|
||||||
console.error('Failed to delete old impact', impactId, await delRes.text());
|
console.error('Failed deleting old impact', impactId, await delRes.text());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) For each current impact in newMilestone.impacts
|
// 2) Insert/Update new impacts
|
||||||
// We'll track the index so we can store the newly created ID if needed
|
|
||||||
for (let i = 0; i < newMilestone.impacts.length; i++) {
|
for (let i = 0; i < newMilestone.impacts.length; i++) {
|
||||||
const impact = newMilestone.impacts[i];
|
const imp = newMilestone.impacts[i];
|
||||||
if (impact.id) {
|
if (imp.id) {
|
||||||
// existing row => PUT
|
// existing => PUT
|
||||||
const putPayload = {
|
const putPayload = {
|
||||||
milestone_id: savedMilestone.id,
|
milestone_id: savedMilestone.id,
|
||||||
impact_type: impact.impact_type,
|
impact_type: imp.impact_type,
|
||||||
direction: impact.direction,
|
direction: imp.direction,
|
||||||
amount: parseFloat(impact.amount) || 0,
|
amount: parseFloat(imp.amount) || 0,
|
||||||
start_date: impact.start_date || null,
|
start_date: imp.start_date || null,
|
||||||
end_date: impact.end_date || null
|
end_date: imp.end_date || null
|
||||||
};
|
};
|
||||||
console.log('Updating milestone impact:', impact.id, putPayload);
|
const impRes = await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
|
||||||
const impRes = await authFetch(`/api/premium/milestone-impacts/${impact.id}`, {
|
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(putPayload)
|
body: JSON.stringify(putPayload)
|
||||||
});
|
});
|
||||||
if (!impRes.ok) {
|
if (!impRes.ok) {
|
||||||
const errImp = await impRes.json();
|
const errImp = await impRes.json();
|
||||||
console.error('Failed to update milestone impact:', errImp);
|
console.error('Failed updating existing impact:', errImp);
|
||||||
} else {
|
|
||||||
const updatedImpact = await impRes.json();
|
|
||||||
console.log('Updated Impact:', updatedImpact);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// [FIX HERE] If no id => POST to create new
|
// new => POST
|
||||||
const impactPayload = {
|
const postPayload = {
|
||||||
milestone_id: savedMilestone.id,
|
milestone_id: savedMilestone.id,
|
||||||
impact_type: impact.impact_type,
|
impact_type: imp.impact_type,
|
||||||
direction: impact.direction,
|
direction: imp.direction,
|
||||||
amount: parseFloat(impact.amount) || 0,
|
amount: parseFloat(imp.amount) || 0,
|
||||||
start_date: impact.start_date || null,
|
start_date: imp.start_date || null,
|
||||||
end_date: impact.end_date || null
|
end_date: imp.end_date || null
|
||||||
};
|
};
|
||||||
console.log('Creating milestone impact:', impactPayload);
|
|
||||||
const impRes = await authFetch('/api/premium/milestone-impacts', {
|
const impRes = await authFetch('/api/premium/milestone-impacts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(impactPayload)
|
body: JSON.stringify(postPayload)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!impRes.ok) {
|
if (!impRes.ok) {
|
||||||
const errImp = await impRes.json();
|
console.error('Failed creating new impact:', await impRes.text());
|
||||||
console.error('Failed to create milestone impact:', errImp);
|
|
||||||
} else {
|
|
||||||
const createdImpact = await impRes.json();
|
|
||||||
if (createdImpact && createdImpact.id) {
|
|
||||||
setNewMilestone(prev => {
|
|
||||||
const newImpacts = [...prev.impacts];
|
|
||||||
newImpacts[i] = { ...newImpacts[i], id: createdImpact.id };
|
|
||||||
return { ...prev, impacts: newImpacts };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local state so we don't have to refetch everything
|
// optional local state update to avoid re-fetch
|
||||||
setMilestones((prev) => {
|
setMilestones((prev) => {
|
||||||
const updated = { ...prev };
|
const newState = { ...prev };
|
||||||
if (editingMilestone) {
|
if (editingMilestone) {
|
||||||
updated[activeView] = updated[activeView].map((m) =>
|
newState[activeView] = newState[activeView].map(m =>
|
||||||
m.id === editingMilestone.id ? savedMilestone : m
|
m.id === editingMilestone.id ? savedMilestone : m
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
updated[activeView].push(savedMilestone);
|
newState[activeView].push(savedMilestone);
|
||||||
}
|
}
|
||||||
return updated;
|
return newState;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset form
|
// reset the form
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingMilestone(null);
|
setEditingMilestone(null);
|
||||||
|
|
||||||
// [FIX HERE] The next line ensures the updated or newly created impact IDs
|
|
||||||
// stay in the local state if the user tries to edit the milestone again
|
|
||||||
// in the same session.
|
|
||||||
setNewMilestone({
|
setNewMilestone({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
date: '',
|
date: '',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
newSalary: '',
|
newSalary: '',
|
||||||
impacts: []
|
impacts: [],
|
||||||
|
isUniversal: 0
|
||||||
});
|
});
|
||||||
setImpactsToDelete([]);
|
setImpactsToDelete([]);
|
||||||
|
|
||||||
|
// optionally re-fetch from DB
|
||||||
|
// await fetchMilestones();
|
||||||
|
|
||||||
|
if (onMilestoneUpdated) {
|
||||||
|
onMilestoneUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving milestone:', err);
|
console.error('Error saving milestone:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// ------------------------------------------------------------------
|
||||||
* Add a new task to an existing milestone
|
// 6) addTask => attach a new task to an existing milestone
|
||||||
*/
|
// ------------------------------------------------------------------
|
||||||
const addTask = async (milestoneId) => {
|
const addTask = async (milestoneId) => {
|
||||||
try {
|
try {
|
||||||
const taskPayload = {
|
const taskPayload = {
|
||||||
@ -292,11 +343,11 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
const createdTask = await res.json();
|
const createdTask = await res.json();
|
||||||
console.log('Task created:', createdTask);
|
console.log('Task created:', createdTask);
|
||||||
|
|
||||||
// Update the milestone's tasks in local state
|
// update local state
|
||||||
setMilestones((prev) => {
|
setMilestones((prev) => {
|
||||||
const newState = { ...prev };
|
const newState = { ...prev };
|
||||||
['Career', 'Financial'].forEach((category) => {
|
['Career', 'Financial'].forEach((cat) => {
|
||||||
newState[category] = newState[category].map((m) => {
|
newState[cat] = newState[cat].map((m) => {
|
||||||
if (m.id === milestoneId) {
|
if (m.id === milestoneId) {
|
||||||
return {
|
return {
|
||||||
...m,
|
...m,
|
||||||
@ -316,7 +367,135 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// For timeline
|
// ------------------------------------------------------------------
|
||||||
|
// 7) "Copy" wizard -> after copying => re-fetch or local update
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
function CopyMilestoneWizard({ milestone, scenarios, onClose, authFetch }) {
|
||||||
|
const [selectedScenarios, setSelectedScenarios] = useState([]);
|
||||||
|
|
||||||
|
if (!milestone) return null;
|
||||||
|
|
||||||
|
function toggleScenario(scenarioId) {
|
||||||
|
setSelectedScenarios(prev => {
|
||||||
|
if (prev.includes(scenarioId)) {
|
||||||
|
return prev.filter(id => id !== scenarioId);
|
||||||
|
} else {
|
||||||
|
return [...prev, scenarioId];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
try {
|
||||||
|
const res = await authFetch('/api/premium/milestone/copy', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
milestoneId: milestone.id,
|
||||||
|
scenarioIds: selectedScenarios
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to copy milestone');
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('Copied milestone to new scenarios:', data);
|
||||||
|
|
||||||
|
onClose(); // close wizard
|
||||||
|
|
||||||
|
// re-fetch or update local
|
||||||
|
await fetchMilestones();
|
||||||
|
if (onMilestoneUpdated) {
|
||||||
|
onMilestoneUpdated();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error copying milestone:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal-container">
|
||||||
|
<h3>Copy Milestone to Other Scenarios</h3>
|
||||||
|
<p>Milestone: <strong>{milestone.title}</strong></p>
|
||||||
|
|
||||||
|
{scenarios.map(s => (
|
||||||
|
<div key={s.id}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedScenarios.includes(s.id)}
|
||||||
|
onChange={() => toggleScenario(s.id)}
|
||||||
|
/>
|
||||||
|
{s.career_name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<button onClick={onClose} style={{ marginRight: '0.5rem' }}>Cancel</button>
|
||||||
|
<button onClick={handleCopy}>Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 8) Delete milestone => single or all
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
async function handleDeleteMilestone(m) {
|
||||||
|
if (m.is_universal === 1) {
|
||||||
|
const userChoice = window.confirm(
|
||||||
|
'This milestone is universal. OK => remove from ALL scenarios, Cancel => remove only from this scenario.'
|
||||||
|
);
|
||||||
|
if (userChoice) {
|
||||||
|
// delete from all
|
||||||
|
try {
|
||||||
|
const delAll = await authFetch(`/api/premium/milestones/${m.id}/all`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!delAll.ok) {
|
||||||
|
console.error('Failed removing universal from all. Status:', delAll.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// re-fetch
|
||||||
|
await fetchMilestones();
|
||||||
|
if (onMilestoneUpdated) {
|
||||||
|
onMilestoneUpdated();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting universal milestone from all:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// remove from single scenario
|
||||||
|
await deleteSingleMilestone(m);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// normal => single scenario
|
||||||
|
await deleteSingleMilestone(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSingleMilestone(m) {
|
||||||
|
try {
|
||||||
|
const delRes = await authFetch(`/api/premium/milestones/${m.id}`, { method: 'DELETE' });
|
||||||
|
if (!delRes.ok) {
|
||||||
|
console.error('Failed to delete single milestone:', delRes.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// re-fetch
|
||||||
|
await fetchMilestones();
|
||||||
|
if (onMilestoneUpdated) {
|
||||||
|
onMilestoneUpdated();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error removing milestone from scenario:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 9) Positioning in the timeline
|
||||||
|
// ------------------------------------------------------------------
|
||||||
const allMilestonesCombined = [...milestones.Career, ...milestones.Financial];
|
const allMilestonesCombined = [...milestones.Career, ...milestones.Financial];
|
||||||
const lastDate = allMilestonesCombined.reduce((latest, m) => {
|
const lastDate = allMilestonesCombined.reduce((latest, m) => {
|
||||||
const d = new Date(m.date);
|
const d = new Date(m.date);
|
||||||
@ -332,61 +511,13 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
return Math.min(Math.max(ratio * 100, 0), 100);
|
return Math.min(Math.max(ratio * 100, 0), 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// ------------------------------------------------------------------
|
||||||
* Add a new empty impact (no id => new)
|
// Render
|
||||||
*/
|
// ------------------------------------------------------------------
|
||||||
const addNewImpact = () => {
|
|
||||||
setNewMilestone((prev) => ({
|
|
||||||
...prev,
|
|
||||||
impacts: [
|
|
||||||
...prev.impacts,
|
|
||||||
{
|
|
||||||
// no 'id' => brand new
|
|
||||||
impact_type: 'ONE_TIME',
|
|
||||||
direction: 'subtract',
|
|
||||||
amount: 0,
|
|
||||||
start_date: '',
|
|
||||||
end_date: ''
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove an impact from the UI. If it had an `id`, track it in impactsToDelete for later DELETE call.
|
|
||||||
*/
|
|
||||||
const removeImpact = (idx) => {
|
|
||||||
setNewMilestone((prev) => {
|
|
||||||
const newImpacts = [...prev.impacts];
|
|
||||||
const removed = newImpacts[idx];
|
|
||||||
if (removed.id) {
|
|
||||||
setImpactsToDelete((old) => [...old, removed.id]);
|
|
||||||
}
|
|
||||||
newImpacts.splice(idx, 1);
|
|
||||||
return { ...prev, impacts: newImpacts };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateImpact = (idx, field, value) => {
|
|
||||||
setNewMilestone((prev) => {
|
|
||||||
const newImpacts = [...prev.impacts];
|
|
||||||
newImpacts[idx] = { ...newImpacts[idx], [field]: value };
|
|
||||||
return { ...prev, impacts: newImpacts };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!activeView || !milestones[activeView]) {
|
|
||||||
return (
|
|
||||||
<div className="milestone-timeline">
|
|
||||||
<p>Loading or no milestones in this view...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="milestone-timeline">
|
<div className="milestone-timeline">
|
||||||
<div className="view-selector">
|
<div className="view-selector">
|
||||||
{['Career', 'Financial'].map((view) => (
|
{['Career', 'Financial'].map(view => (
|
||||||
<button
|
<button
|
||||||
key={view}
|
key={view}
|
||||||
className={activeView === view ? 'active' : ''}
|
className={activeView === view ? 'active' : ''}
|
||||||
@ -397,11 +528,11 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New Milestone button */}
|
{/* + New Milestone button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (showForm) {
|
if (showForm) {
|
||||||
// Cancel form
|
// Cancel
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingMilestone(null);
|
setEditingMilestone(null);
|
||||||
setNewMilestone({
|
setNewMilestone({
|
||||||
@ -410,7 +541,8 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
date: '',
|
date: '',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
newSalary: '',
|
newSalary: '',
|
||||||
impacts: []
|
impacts: [],
|
||||||
|
isUniversal: 0
|
||||||
});
|
});
|
||||||
setImpactsToDelete([]);
|
setImpactsToDelete([]);
|
||||||
} else {
|
} else {
|
||||||
@ -439,7 +571,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
type="date"
|
type="date"
|
||||||
placeholder="Milestone Date"
|
placeholder="Milestone Date"
|
||||||
value={newMilestone.date}
|
value={newMilestone.date}
|
||||||
onChange={(e) => setNewMilestone((prev) => ({ ...prev, date: e.target.value }))}
|
onChange={(e) => setNewMilestone(prev => ({ ...prev, date: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -447,7 +579,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
value={newMilestone.progress === 0 ? '' : newMilestone.progress}
|
value={newMilestone.progress === 0 ? '' : newMilestone.progress}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value === '' ? 0 : parseInt(e.target.value, 10);
|
const val = e.target.value === '' ? 0 : parseInt(e.target.value, 10);
|
||||||
setNewMilestone((prev) => ({ ...prev, progress: val }));
|
setNewMilestone(prev => ({ ...prev, progress: val }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -455,7 +587,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Full New Salary (e.g., 70000)"
|
placeholder="Full New Salary (e.g. 70000)"
|
||||||
value={newMilestone.newSalary}
|
value={newMilestone.newSalary}
|
||||||
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })}
|
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@ -535,12 +667,30 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button onClick={saveMilestone}>
|
{/* universal checkbox */}
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!newMilestone.isUniversal}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewMilestone(prev => ({
|
||||||
|
...prev,
|
||||||
|
isUniversal: e.target.checked ? 1 : 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{' '}Apply this milestone to all scenarios?
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={saveMilestone} style={{ marginTop: '1rem' }}>
|
||||||
{editingMilestone ? 'Update' : 'Add'} Milestone
|
{editingMilestone ? 'Update' : 'Add'} Milestone
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
<div className="milestone-timeline-container">
|
<div className="milestone-timeline-container">
|
||||||
<div className="milestone-timeline-line" />
|
<div className="milestone-timeline-line" />
|
||||||
|
|
||||||
@ -586,6 +736,23 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
|
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Edit, Copy, Delete Buttons */}
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<button onClick={() => handleEditMilestone(m)}>Edit</button>
|
||||||
|
<button
|
||||||
|
style={{ marginLeft: '0.5rem' }}
|
||||||
|
onClick={() => setCopyWizardMilestone(m)}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{ marginLeft: '0.5rem', color: 'red' }}
|
||||||
|
onClick={() => handleDeleteMilestone(m)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showTaskForm === m.id && (
|
{showTaskForm === m.id && (
|
||||||
<div className="task-form">
|
<div className="task-form">
|
||||||
<input
|
<input
|
||||||
@ -613,6 +780,17 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView,
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CopyWizard modal if copying */}
|
||||||
|
{copyWizardMilestone && (
|
||||||
|
<CopyMilestoneWizard
|
||||||
|
milestone={copyWizardMilestone}
|
||||||
|
scenarios={scenarios}
|
||||||
|
onClose={() => setCopyWizardMilestone(null)}
|
||||||
|
authFetch={authFetch}
|
||||||
|
onMilestoneUpdated={onMilestoneUpdated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -40,27 +40,35 @@ ChartJS.register(
|
|||||||
const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const apiURL = process.env.REACT_APP_API_URL;
|
const apiURL = process.env.REACT_APP_API_URL;
|
||||||
|
|
||||||
// -------------------------
|
// --------------------------------------------------
|
||||||
// State
|
// State
|
||||||
// -------------------------
|
// --------------------------------------------------
|
||||||
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
|
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
|
||||||
const [careerPathId, setCareerPathId] = useState(null);
|
const [careerPathId, setCareerPathId] = useState(null);
|
||||||
const [existingCareerPaths, setExistingCareerPaths] = useState([]);
|
const [existingCareerPaths, setExistingCareerPaths] = useState([]);
|
||||||
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
|
|
||||||
const [activeView, setActiveView] = useState("Career");
|
const [activeView, setActiveView] = useState("Career");
|
||||||
|
|
||||||
|
// Real user snapshot
|
||||||
const [financialProfile, setFinancialProfile] = useState(null);
|
const [financialProfile, setFinancialProfile] = useState(null);
|
||||||
|
|
||||||
|
// Scenario row (with planned_* overrides) from GET /api/premium/career-profile/:careerPathId
|
||||||
|
const [scenarioRow, setScenarioRow] = useState(null);
|
||||||
|
|
||||||
|
// scenario's collegeProfile row
|
||||||
const [collegeProfile, setCollegeProfile] = useState(null);
|
const [collegeProfile, setCollegeProfile] = useState(null);
|
||||||
|
|
||||||
|
// Simulation results
|
||||||
const [projectionData, setProjectionData] = useState([]);
|
const [projectionData, setProjectionData] = useState([]);
|
||||||
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
||||||
|
|
||||||
|
// Possibly let user type the simulation length
|
||||||
const [simulationYearsInput, setSimulationYearsInput] = useState("20");
|
const [simulationYearsInput, setSimulationYearsInput] = useState("20");
|
||||||
|
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
|
||||||
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
|
||||||
|
|
||||||
// Possibly loaded from location.state
|
// Possibly loaded from location.state
|
||||||
const {
|
const {
|
||||||
@ -68,10 +76,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
loanPayoffMonth: initialLoanPayoffMonth = null
|
loanPayoffMonth: initialLoanPayoffMonth = null
|
||||||
} = location.state || {};
|
} = location.state || {};
|
||||||
|
|
||||||
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
|
// --------------------------------------------------
|
||||||
// -------------------------
|
// 1) Fetch user’s scenario list + financialProfile
|
||||||
// 1. Fetch career paths + financialProfile on mount
|
// --------------------------------------------------
|
||||||
// -------------------------
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCareerPaths = async () => {
|
const fetchCareerPaths = async () => {
|
||||||
const res = await authFetch(`${apiURL}/premium/career-profile/all`);
|
const res = await authFetch(`${apiURL}/premium/career-profile/all`);
|
||||||
@ -84,7 +91,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
setSelectedCareer(fromPopout);
|
setSelectedCareer(fromPopout);
|
||||||
setCareerPathId(fromPopout.career_path_id);
|
setCareerPathId(fromPopout.career_path_id);
|
||||||
} else if (!selectedCareer) {
|
} else if (!selectedCareer) {
|
||||||
// Try to fetch the latest
|
// fallback to latest
|
||||||
const latest = await authFetch(`${apiURL}/premium/career-profile/latest`);
|
const latest = await authFetch(`${apiURL}/premium/career-profile/latest`);
|
||||||
if (latest && latest.ok) {
|
if (latest && latest.ok) {
|
||||||
const latestData = await latest.json();
|
const latestData = await latest.json();
|
||||||
@ -98,7 +105,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
|
|
||||||
const fetchFinancialProfile = async () => {
|
const fetchFinancialProfile = async () => {
|
||||||
const res = await authFetch(`${apiURL}/premium/financial-profile`);
|
const res = await authFetch(`${apiURL}/premium/financial-profile`);
|
||||||
if (res && res.ok) {
|
if (res?.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setFinancialProfile(data);
|
setFinancialProfile(data);
|
||||||
}
|
}
|
||||||
@ -108,256 +115,265 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
fetchFinancialProfile();
|
fetchFinancialProfile();
|
||||||
}, [apiURL, location.state, selectedCareer]);
|
}, [apiURL, location.state, selectedCareer]);
|
||||||
|
|
||||||
// -------------------------
|
// --------------------------------------------------
|
||||||
// 2. Fetch the college profile for the selected careerPathId
|
// 2) When careerPathId changes => fetch scenarioRow + collegeProfile
|
||||||
// -------------------------
|
// --------------------------------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!careerPathId) {
|
if (!careerPathId) {
|
||||||
|
setScenarioRow(null);
|
||||||
setCollegeProfile(null);
|
setCollegeProfile(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchCollegeProfile = async () => {
|
async function fetchScenario() {
|
||||||
const res = await authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`);
|
const scenRes = await authFetch(`${apiURL}/premium/career-profile/${careerPathId}`);
|
||||||
if (!res || !res.ok) {
|
if (scenRes.ok) {
|
||||||
|
const data = await scenRes.json();
|
||||||
|
setScenarioRow(data);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch scenario row:', scenRes.status);
|
||||||
|
setScenarioRow(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCollege() {
|
||||||
|
const colRes = await authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`);
|
||||||
|
if (!colRes?.ok) {
|
||||||
setCollegeProfile(null);
|
setCollegeProfile(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await colRes.json();
|
||||||
setCollegeProfile(data);
|
setCollegeProfile(data);
|
||||||
};
|
}
|
||||||
|
|
||||||
fetchCollegeProfile();
|
fetchScenario();
|
||||||
|
fetchCollege();
|
||||||
}, [careerPathId, apiURL]);
|
}, [careerPathId, apiURL]);
|
||||||
|
|
||||||
// -------------------------
|
// --------------------------------------------------
|
||||||
// 3. Initial simulation when profiles + career loaded
|
// 3) Once we have (financialProfile, scenarioRow, collegeProfile),
|
||||||
// (But this does NOT update after milestone changes yet)
|
// run initial simulation with the scenario's milestones + impacts
|
||||||
// -------------------------
|
// --------------------------------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!financialProfile || !collegeProfile || !selectedCareer || !careerPathId) return;
|
if (!financialProfile || !scenarioRow || !collegeProfile) return;
|
||||||
|
|
||||||
// 1) Fetch the raw milestones for this careerPath
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
// 1) load milestones for scenario
|
||||||
const milRes = await authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`);
|
const milRes = await authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`);
|
||||||
if (!milRes.ok) {
|
if (!milRes.ok) {
|
||||||
console.error('Failed to fetch initial milestones');
|
console.error('Failed to fetch initial milestones for scenario', careerPathId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const milestonesData = await milRes.json();
|
const milestonesData = await milRes.json();
|
||||||
const allMilestones = milestonesData.milestones || [];
|
const allMilestones = milestonesData.milestones || [];
|
||||||
|
|
||||||
// 2) For each milestone, fetch impacts
|
// 2) fetch impacts for each
|
||||||
const impactPromises = allMilestones.map((m) =>
|
const impactPromises = allMilestones.map(m =>
|
||||||
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
|
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then(r => r.ok ? r.json() : null)
|
||||||
.then((data) => data?.impacts || [])
|
.then(data => data?.impacts || [])
|
||||||
.catch((err) => {
|
.catch(err => {
|
||||||
console.error('Failed fetching impacts for milestone', m.id, err);
|
console.warn('Error fetching impacts for milestone', m.id, err);
|
||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const impactsForEach = await Promise.all(impactPromises);
|
const impactsForEach = await Promise.all(impactPromises);
|
||||||
const milestonesWithImpacts = allMilestones.map((m, i) => ({
|
const milestonesWithImpacts = allMilestones.map((m, i) => ({
|
||||||
...m,
|
...m,
|
||||||
impacts: impactsForEach[i] || [],
|
impacts: impactsForEach[i] || []
|
||||||
}));
|
}));
|
||||||
|
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts);
|
||||||
|
|
||||||
// 3) Flatten them
|
// 3) Build the merged profile w/ scenario overrides
|
||||||
const allImpacts = milestonesWithImpacts.flatMap((m) => m.impacts || []);
|
const mergedProfile = buildMergedProfile(
|
||||||
|
financialProfile,
|
||||||
|
scenarioRow,
|
||||||
|
collegeProfile,
|
||||||
|
allImpacts,
|
||||||
|
simulationYears
|
||||||
|
);
|
||||||
|
|
||||||
// 4) Build the mergedProfile (like you already do)
|
// 4) run the simulation
|
||||||
const mergedProfile = {
|
const { projectionData: pData, loanPaidOffMonth: payoff } =
|
||||||
// From financialProfile
|
|
||||||
currentSalary: financialProfile.current_salary || 0,
|
|
||||||
monthlyExpenses: financialProfile.monthly_expenses || 0,
|
|
||||||
monthlyDebtPayments: financialProfile.monthly_debt_payments || 0,
|
|
||||||
retirementSavings: financialProfile.retirement_savings || 0,
|
|
||||||
emergencySavings: financialProfile.emergency_fund || 0,
|
|
||||||
monthlyRetirementContribution: financialProfile.retirement_contribution || 0,
|
|
||||||
monthlyEmergencyContribution: financialProfile.emergency_contribution || 0,
|
|
||||||
surplusEmergencyAllocation: financialProfile.extra_cash_emergency_pct || 50,
|
|
||||||
surplusRetirementAllocation: financialProfile.extra_cash_retirement_pct || 50,
|
|
||||||
|
|
||||||
// From collegeProfile
|
|
||||||
studentLoanAmount: collegeProfile.existing_college_debt || 0,
|
|
||||||
interestRate: collegeProfile.interest_rate || 5,
|
|
||||||
loanTerm: collegeProfile.loan_term || 10,
|
|
||||||
loanDeferralUntilGraduation: !!collegeProfile.loan_deferral_until_graduation,
|
|
||||||
academicCalendar: collegeProfile.academic_calendar || 'monthly',
|
|
||||||
annualFinancialAid: collegeProfile.annual_financial_aid || 0,
|
|
||||||
calculatedTuition: collegeProfile.tuition || 0,
|
|
||||||
extraPayment: collegeProfile.extra_payment || 0,
|
|
||||||
inCollege:
|
|
||||||
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
|
|
||||||
collegeProfile.college_enrollment_status === 'prospective_student',
|
|
||||||
gradDate: collegeProfile.expected_graduation || null,
|
|
||||||
programType: collegeProfile.program_type,
|
|
||||||
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
|
|
||||||
hoursCompleted: collegeProfile.hours_completed || 0,
|
|
||||||
programLength: collegeProfile.program_length || 0,
|
|
||||||
startDate: new Date().toISOString(),
|
|
||||||
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary,
|
|
||||||
|
|
||||||
// The key: impacts
|
|
||||||
milestoneImpacts: allImpacts,
|
|
||||||
|
|
||||||
simulationYears,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 5) Run the simulation
|
|
||||||
const { projectionData: initialProjData, loanPaidOffMonth: payoff } =
|
|
||||||
simulateFinancialProjection(mergedProfile);
|
simulateFinancialProjection(mergedProfile);
|
||||||
|
|
||||||
let cumulativeSavings = mergedProfile.emergencySavings || 0;
|
// 5) If you track cumulative net
|
||||||
const finalData = initialProjData.map((month) => {
|
let cumu = mergedProfile.emergencySavings || 0;
|
||||||
cumulativeSavings += (month.netSavings || 0);
|
const finalData = pData.map(mo => {
|
||||||
return { ...month, cumulativeNetSavings: cumulativeSavings };
|
cumu += (mo.netSavings || 0);
|
||||||
|
return { ...mo, cumulativeNetSavings: cumu };
|
||||||
});
|
});
|
||||||
|
|
||||||
setProjectionData(finalData);
|
setProjectionData(finalData);
|
||||||
setLoanPayoffMonth(payoff);
|
setLoanPayoffMonth(payoff);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching initial milestones/impacts or simulating:', err);
|
console.error('Error in initial scenario simulation:', err);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [financialProfile, collegeProfile, simulationYears, selectedCareer, careerPathId]);
|
}, [
|
||||||
|
financialProfile,
|
||||||
|
scenarioRow,
|
||||||
|
collegeProfile,
|
||||||
|
simulationYears,
|
||||||
|
careerPathId,
|
||||||
|
apiURL
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSimulationYearsChange = (e) => {
|
// Merges the real snapshot w/ scenario overrides + milestones
|
||||||
setSimulationYearsInput(e.target.value); // let user type partial/blank
|
function buildMergedProfile(finProf, scenRow, colProf, milestoneImpacts, simYears) {
|
||||||
|
return {
|
||||||
|
// Real snapshot fallback
|
||||||
|
currentSalary: finProf.current_salary || 0,
|
||||||
|
monthlyExpenses:
|
||||||
|
scenRow.planned_monthly_expenses ?? finProf.monthly_expenses ?? 0,
|
||||||
|
monthlyDebtPayments:
|
||||||
|
scenRow.planned_monthly_debt_payments ?? finProf.monthly_debt_payments ?? 0,
|
||||||
|
retirementSavings: finProf.retirement_savings ?? 0,
|
||||||
|
emergencySavings: finProf.emergency_fund ?? 0,
|
||||||
|
monthlyRetirementContribution:
|
||||||
|
scenRow.planned_monthly_retirement_contribution ??
|
||||||
|
finProf.retirement_contribution ??
|
||||||
|
0,
|
||||||
|
monthlyEmergencyContribution:
|
||||||
|
scenRow.planned_monthly_emergency_contribution ??
|
||||||
|
finProf.emergency_contribution ??
|
||||||
|
0,
|
||||||
|
surplusEmergencyAllocation:
|
||||||
|
scenRow.planned_surplus_emergency_pct ??
|
||||||
|
finProf.extra_cash_emergency_pct ??
|
||||||
|
50,
|
||||||
|
surplusRetirementAllocation:
|
||||||
|
scenRow.planned_surplus_retirement_pct ??
|
||||||
|
finProf.extra_cash_retirement_pct ??
|
||||||
|
50,
|
||||||
|
additionalIncome:
|
||||||
|
scenRow.planned_additional_income ?? finProf.additional_income ?? 0,
|
||||||
|
|
||||||
|
// College stuff
|
||||||
|
studentLoanAmount: colProf.existing_college_debt || 0,
|
||||||
|
interestRate: colProf.interest_rate || 5,
|
||||||
|
loanTerm: colProf.loan_term || 10,
|
||||||
|
loanDeferralUntilGraduation: !!colProf.loan_deferral_until_graduation,
|
||||||
|
academicCalendar: colProf.academic_calendar || 'monthly',
|
||||||
|
annualFinancialAid: colProf.annual_financial_aid || 0,
|
||||||
|
calculatedTuition: colProf.tuition || 0,
|
||||||
|
extraPayment: colProf.extra_payment || 0,
|
||||||
|
inCollege:
|
||||||
|
colProf.college_enrollment_status === 'currently_enrolled' ||
|
||||||
|
colProf.college_enrollment_status === 'prospective_student',
|
||||||
|
gradDate: colProf.expected_graduation || null,
|
||||||
|
programType: colProf.program_type,
|
||||||
|
creditHoursPerYear: colProf.credit_hours_per_year || 0,
|
||||||
|
hoursCompleted: colProf.hours_completed || 0,
|
||||||
|
programLength: colProf.program_length || 0,
|
||||||
|
expectedSalary: colProf.expected_salary || finProf.current_salary || 0,
|
||||||
|
|
||||||
|
// Additional
|
||||||
|
startDate: new Date().toISOString(),
|
||||||
|
simulationYears: simYears,
|
||||||
|
|
||||||
|
// Milestone Impacts
|
||||||
|
milestoneImpacts: milestoneImpacts || []
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSimulationYearsBlur = () => {
|
|
||||||
// Optionally, onBlur you can “normalize” the value
|
|
||||||
// (e.g. if they left it blank, revert to "20").
|
|
||||||
if (simulationYearsInput.trim() === "") {
|
|
||||||
setSimulationYearsInput("20");
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// -------------------------------------------------
|
// ------------------------------------------------------
|
||||||
// 4. reSimulate() => re-fetch everything (financial, college, milestones & impacts),
|
// 4) reSimulate => after milestone changes or user toggles something
|
||||||
// re-run the simulation. This is triggered AFTER user updates a milestone in MilestoneTimeline.
|
// ------------------------------------------------------
|
||||||
// -------------------------------------------------
|
|
||||||
const reSimulate = async () => {
|
const reSimulate = async () => {
|
||||||
if (!careerPathId) return;
|
if (!careerPathId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1) Fetch financial + college + raw milestones
|
// 1) fetch everything again
|
||||||
const [finResp, colResp, milResp] = await Promise.all([
|
const [finResp, scenResp, colResp, milResp] = await Promise.all([
|
||||||
authFetch(`${apiURL}/premium/financial-profile`),
|
authFetch(`${apiURL}/premium/financial-profile`),
|
||||||
|
authFetch(`${apiURL}/premium/career-profile/${careerPathId}`),
|
||||||
authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`),
|
authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`),
|
||||||
authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`)
|
authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!finResp.ok || !colResp.ok || !milResp.ok) {
|
if (!finResp.ok || !scenResp.ok || !colResp.ok || !milResp.ok) {
|
||||||
console.error('One reSimulate fetch failed:', finResp.status, colResp.status, milResp.status);
|
console.error(
|
||||||
|
'One reSimulate fetch failed:',
|
||||||
|
finResp.status,
|
||||||
|
scenResp.status,
|
||||||
|
colResp.status,
|
||||||
|
milResp.status
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updatedFinancial, updatedCollege, milestonesData] = await Promise.all([
|
const [updatedFinancial, updatedScenario, updatedCollege, milData] =
|
||||||
|
await Promise.all([
|
||||||
finResp.json(),
|
finResp.json(),
|
||||||
|
scenResp.json(),
|
||||||
colResp.json(),
|
colResp.json(),
|
||||||
milResp.json()
|
milResp.json()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 2) For each milestone, fetch its impacts separately (if not already included)
|
const allMilestones = milData.milestones || [];
|
||||||
const allMilestones = milestonesData.milestones || [];
|
|
||||||
const impactsPromises = allMilestones.map(m =>
|
const impactsPromises = allMilestones.map(m =>
|
||||||
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
|
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
|
||||||
.then(r => r.ok ? r.json() : null)
|
.then(r => r.ok ? r.json() : null)
|
||||||
.then(data => data?.impacts || [])
|
.then(data => data?.impacts || [])
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Failed fetching impacts for milestone', m.id, err);
|
console.warn('Impact fetch err for milestone', m.id, err);
|
||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const impactsForEach = await Promise.all(impactsPromises);
|
const impactsForEach = await Promise.all(impactsPromises);
|
||||||
// Merge them onto the milestone array if desired
|
|
||||||
const milestonesWithImpacts = allMilestones.map((m, i) => ({
|
const milestonesWithImpacts = allMilestones.map((m, i) => ({
|
||||||
...m,
|
...m,
|
||||||
impacts: impactsForEach[i] || []
|
impacts: impactsForEach[i] || []
|
||||||
}));
|
}));
|
||||||
|
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts);
|
||||||
|
|
||||||
// Flatten or gather all impacts if your simulation function needs them
|
// 2) Build merged
|
||||||
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts || []);
|
const mergedProfile = buildMergedProfile(
|
||||||
|
updatedFinancial,
|
||||||
|
updatedScenario,
|
||||||
|
updatedCollege,
|
||||||
|
allImpacts,
|
||||||
|
simulationYears
|
||||||
|
);
|
||||||
|
|
||||||
// 3) Build mergedProfile
|
// 3) run
|
||||||
const mergedProfile = {
|
|
||||||
// From updatedFinancial
|
|
||||||
currentSalary: updatedFinancial.current_salary || 0,
|
|
||||||
monthlyExpenses: updatedFinancial.monthly_expenses || 0,
|
|
||||||
monthlyDebtPayments: updatedFinancial.monthly_debt_payments || 0,
|
|
||||||
retirementSavings: updatedFinancial.retirement_savings || 0,
|
|
||||||
emergencySavings: updatedFinancial.emergency_fund || 0,
|
|
||||||
monthlyRetirementContribution: updatedFinancial.retirement_contribution || 0,
|
|
||||||
monthlyEmergencyContribution: updatedFinancial.emergency_contribution || 0,
|
|
||||||
surplusEmergencyAllocation: updatedFinancial.extra_cash_emergency_pct || 50,
|
|
||||||
surplusRetirementAllocation: updatedFinancial.extra_cash_retirement_pct || 50,
|
|
||||||
|
|
||||||
// From updatedCollege
|
|
||||||
studentLoanAmount: updatedCollege.existing_college_debt || 0,
|
|
||||||
interestRate: updatedCollege.interest_rate || 5,
|
|
||||||
loanTerm: updatedCollege.loan_term || 10,
|
|
||||||
loanDeferralUntilGraduation: !!updatedCollege.loan_deferral_until_graduation,
|
|
||||||
academicCalendar: updatedCollege.academic_calendar || 'monthly',
|
|
||||||
annualFinancialAid: updatedCollege.annual_financial_aid || 0,
|
|
||||||
calculatedTuition: updatedCollege.tuition || 0,
|
|
||||||
extraPayment: updatedCollege.extra_payment || 0,
|
|
||||||
inCollege:
|
|
||||||
updatedCollege.college_enrollment_status === 'currently_enrolled' ||
|
|
||||||
updatedCollege.college_enrollment_status === 'prospective_student',
|
|
||||||
gradDate: updatedCollege.expected_graduation || null,
|
|
||||||
programType: updatedCollege.program_type,
|
|
||||||
creditHoursPerYear: updatedCollege.credit_hours_per_year || 0,
|
|
||||||
hoursCompleted: updatedCollege.hours_completed || 0,
|
|
||||||
programLength: updatedCollege.program_length || 0,
|
|
||||||
startDate: new Date().toISOString(),
|
|
||||||
expectedSalary: updatedCollege.expected_salary || updatedFinancial.current_salary,
|
|
||||||
|
|
||||||
// The key: pass the impacts to the simulation if needed
|
|
||||||
milestoneImpacts: allImpacts
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4) Re-run simulation
|
|
||||||
const { projectionData: newProjData, loanPaidOffMonth: payoff } =
|
const { projectionData: newProjData, loanPaidOffMonth: payoff } =
|
||||||
simulateFinancialProjection(mergedProfile);
|
simulateFinancialProjection(mergedProfile);
|
||||||
|
|
||||||
// 5) If you track cumulative net savings:
|
// 4) cumulative
|
||||||
let cumulativeSavings = mergedProfile.emergencySavings || 0;
|
let csum = mergedProfile.emergencySavings || 0;
|
||||||
const finalData = newProjData.map(month => {
|
const finalData = newProjData.map(mo => {
|
||||||
cumulativeSavings += (month.netSavings || 0);
|
csum += (mo.netSavings || 0);
|
||||||
return { ...month, cumulativeNetSavings: cumulativeSavings };
|
return { ...mo, cumulativeNetSavings: csum };
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6) Update states => triggers chart refresh
|
|
||||||
setProjectionData(finalData);
|
setProjectionData(finalData);
|
||||||
setLoanPayoffMonth(payoff);
|
setLoanPayoffMonth(payoff);
|
||||||
|
|
||||||
// Optionally store the new profiles in state if you like
|
// also store updated scenario, financial, college
|
||||||
setFinancialProfile(updatedFinancial);
|
setFinancialProfile(updatedFinancial);
|
||||||
|
setScenarioRow(updatedScenario);
|
||||||
setCollegeProfile(updatedCollege);
|
setCollegeProfile(updatedCollege);
|
||||||
|
|
||||||
console.log('Re-simulated after Milestone update!', {
|
console.log('Re-simulated after milestone update', { mergedProfile, finalData });
|
||||||
mergedProfile,
|
|
||||||
milestonesWithImpacts
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error in reSimulate:', err);
|
console.error('Error in reSimulate:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ...
|
// handle user typing simulation length
|
||||||
// The rest of your component logic
|
const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value);
|
||||||
// ...
|
const handleSimulationYearsBlur = () => {
|
||||||
|
if (!simulationYearsInput.trim()) {
|
||||||
|
setSimulationYearsInput("20");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Logging
|
||||||
console.log(
|
console.log(
|
||||||
'First 5 items of projectionData:',
|
'First 5 items of projectionData:',
|
||||||
Array.isArray(projectionData) ? projectionData.slice(0, 5) : 'projectionData not yet available'
|
Array.isArray(projectionData) ? projectionData.slice(0, 5) : 'none'
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -373,7 +389,6 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
authFetch={authFetch}
|
authFetch={authFetch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pass reSimulate as onMilestoneUpdated: */}
|
|
||||||
<MilestoneTimeline
|
<MilestoneTimeline
|
||||||
careerPathId={careerPathId}
|
careerPathId={careerPathId}
|
||||||
authFetch={authFetch}
|
authFetch={authFetch}
|
||||||
@ -470,7 +485,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div className="mt-4">
|
||||||
<label>Simulation Length (years): </label>
|
<label>Simulation Length (years): </label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -500,7 +515,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
{pendingCareerForModal && (
|
{pendingCareerForModal && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// handleConfirmCareerSelection logic
|
// Example Confirm
|
||||||
|
console.log('TODO: handleConfirmCareerSelection => new scenario?');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Confirm Career Change to {pendingCareerForModal}
|
Confirm Career Change to {pendingCareerForModal}
|
||||||
|
@ -2,8 +2,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||||||
|
|
||||||
// Reuse your existing:
|
|
||||||
import ScenarioEditModal from './ScenarioEditModal.js';
|
import ScenarioEditModal from './ScenarioEditModal.js';
|
||||||
import MilestoneTimeline from './MilestoneTimeline.js';
|
import MilestoneTimeline from './MilestoneTimeline.js';
|
||||||
import AISuggestedMilestones from './AISuggestedMilestones.js';
|
import AISuggestedMilestones from './AISuggestedMilestones.js';
|
||||||
@ -14,9 +12,11 @@ export default function ScenarioContainer({
|
|||||||
financialProfile, // single row, shared across user
|
financialProfile, // single row, shared across user
|
||||||
onClone,
|
onClone,
|
||||||
onRemove,
|
onRemove,
|
||||||
onScenarioUpdated // callback to parent to store updated scenario data
|
onScenarioUpdated
|
||||||
}) {
|
}) {
|
||||||
|
const [localScenario, setLocalScenario] = useState(scenario);
|
||||||
const [collegeProfile, setCollegeProfile] = useState(null);
|
const [collegeProfile, setCollegeProfile] = useState(null);
|
||||||
|
|
||||||
const [milestones, setMilestones] = useState([]);
|
const [milestones, setMilestones] = useState([]);
|
||||||
const [universalMilestones, setUniversalMilestones] = useState([]);
|
const [universalMilestones, setUniversalMilestones] = useState([]);
|
||||||
|
|
||||||
@ -25,12 +25,19 @@ export default function ScenarioContainer({
|
|||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
|
||||||
|
// Re-sync if parent updates scenario
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalScenario(scenario);
|
||||||
|
}, [scenario]);
|
||||||
|
|
||||||
// 1) Fetch the college profile for this scenario
|
// 1) Fetch the college profile for this scenario
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scenario?.id) return;
|
if (!localScenario?.id) return;
|
||||||
async function loadCollegeProfile() {
|
async function loadCollegeProfile() {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/premium/college-profile?careerPathId=${scenario.id}`);
|
const res = await authFetch(
|
||||||
|
`/api/premium/college-profile?careerPathId=${localScenario.id}`
|
||||||
|
);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setCollegeProfile(data);
|
setCollegeProfile(data);
|
||||||
@ -43,20 +50,16 @@ export default function ScenarioContainer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadCollegeProfile();
|
loadCollegeProfile();
|
||||||
}, [scenario]);
|
}, [localScenario]);
|
||||||
|
|
||||||
// 2) Fetch scenario’s milestones (where is_universal=0) + universal (is_universal=1)
|
// 2) Fetch scenario’s milestones (and universal)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scenario?.id) return;
|
if (!localScenario?.id) return;
|
||||||
async function loadMilestones() {
|
async function loadMilestones() {
|
||||||
try {
|
try {
|
||||||
const [scenRes, uniRes] = await Promise.all([
|
const [scenRes, uniRes] = await Promise.all([
|
||||||
authFetch(`/api/premium/milestones?careerPathId=${scenario.id}`),
|
authFetch(`/api/premium/milestones?careerPathId=${localScenario.id}`),
|
||||||
// for universal: we do an extra call with no careerPathId.
|
authFetch(`/api/premium/milestones?careerPathId=universal`) // if you have that route
|
||||||
// But your current code always requires a careerPathId. So you might
|
|
||||||
// create a new endpoint /api/premium/milestones?is_universal=1 or something.
|
|
||||||
// We'll assume you have it:
|
|
||||||
authFetch(`/api/premium/milestones?careerPathId=universal`)
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let scenarioData = scenRes.ok ? (await scenRes.json()) : { milestones: [] };
|
let scenarioData = scenRes.ok ? (await scenRes.json()) : { milestones: [] };
|
||||||
@ -69,26 +72,42 @@ export default function ScenarioContainer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadMilestones();
|
loadMilestones();
|
||||||
}, [scenario]);
|
}, [localScenario]);
|
||||||
|
|
||||||
// 3) Whenever we have financialProfile + collegeProfile + milestones, run the simulation
|
// 3) Merge real snapshot + scenario overrides => run simulation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!financialProfile || !collegeProfile) return;
|
if (!financialProfile || !collegeProfile) return;
|
||||||
|
|
||||||
// Merge them into the userProfile object for the simulator:
|
// Merge the scenario's planned overrides if not null,
|
||||||
|
// else fallback to the real snapshot in financialProfile
|
||||||
const mergedProfile = {
|
const mergedProfile = {
|
||||||
// Financial fields
|
|
||||||
currentSalary: financialProfile.current_salary || 0,
|
currentSalary: financialProfile.current_salary || 0,
|
||||||
monthlyExpenses: financialProfile.monthly_expenses || 0,
|
monthlyExpenses:
|
||||||
monthlyDebtPayments: financialProfile.monthly_debt_payments || 0,
|
localScenario.planned_monthly_expenses ?? financialProfile.monthly_expenses ?? 0,
|
||||||
retirementSavings: financialProfile.retirement_savings || 0,
|
monthlyDebtPayments:
|
||||||
emergencySavings: financialProfile.emergency_fund || 0,
|
localScenario.planned_monthly_debt_payments ?? financialProfile.monthly_debt_payments ?? 0,
|
||||||
monthlyRetirementContribution: financialProfile.retirement_contribution || 0,
|
retirementSavings: financialProfile.retirement_savings ?? 0,
|
||||||
monthlyEmergencyContribution: financialProfile.emergency_contribution || 0,
|
emergencySavings: financialProfile.emergency_fund ?? 0,
|
||||||
surplusEmergencyAllocation: financialProfile.extra_cash_emergency_pct || 50,
|
monthlyRetirementContribution:
|
||||||
surplusRetirementAllocation: financialProfile.extra_cash_retirement_pct || 50,
|
localScenario.planned_monthly_retirement_contribution ??
|
||||||
|
financialProfile.retirement_contribution ??
|
||||||
|
0,
|
||||||
|
monthlyEmergencyContribution:
|
||||||
|
localScenario.planned_monthly_emergency_contribution ??
|
||||||
|
financialProfile.emergency_contribution ??
|
||||||
|
0,
|
||||||
|
surplusEmergencyAllocation:
|
||||||
|
localScenario.planned_surplus_emergency_pct ??
|
||||||
|
financialProfile.extra_cash_emergency_pct ??
|
||||||
|
50,
|
||||||
|
surplusRetirementAllocation:
|
||||||
|
localScenario.planned_surplus_retirement_pct ??
|
||||||
|
financialProfile.extra_cash_retirement_pct ??
|
||||||
|
50,
|
||||||
|
additionalIncome:
|
||||||
|
localScenario.planned_additional_income ?? financialProfile.additional_income ?? 0,
|
||||||
|
|
||||||
// College fields (scenario-based)
|
// College fields
|
||||||
studentLoanAmount: collegeProfile.existing_college_debt || 0,
|
studentLoanAmount: collegeProfile.existing_college_debt || 0,
|
||||||
interestRate: collegeProfile.interest_rate || 5,
|
interestRate: collegeProfile.interest_rate || 5,
|
||||||
loanTerm: collegeProfile.loan_term || 10,
|
loanTerm: collegeProfile.loan_term || 10,
|
||||||
@ -102,66 +121,45 @@ export default function ScenarioContainer({
|
|||||||
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
|
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
|
||||||
hoursCompleted: collegeProfile.hours_completed || 0,
|
hoursCompleted: collegeProfile.hours_completed || 0,
|
||||||
programLength: collegeProfile.program_length || 0,
|
programLength: collegeProfile.program_length || 0,
|
||||||
|
|
||||||
// We assume user’s baseline “inCollege” from the DB:
|
|
||||||
inCollege:
|
inCollege:
|
||||||
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
|
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
|
||||||
collegeProfile.college_enrollment_status === 'prospective_student',
|
collegeProfile.college_enrollment_status === 'prospective_student',
|
||||||
|
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary || 0,
|
||||||
|
|
||||||
// If you store expected_salary in collegeProfile
|
// Flatten scenario + universal milestoneImpacts
|
||||||
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary,
|
|
||||||
|
|
||||||
// Flatten the scenario + universal milestones’ impacts
|
|
||||||
milestoneImpacts: buildAllImpacts([...milestones, ...universalMilestones])
|
milestoneImpacts: buildAllImpacts([...milestones, ...universalMilestones])
|
||||||
};
|
};
|
||||||
|
|
||||||
const { projectionData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile);
|
const { projectionData, loanPaidOffMonth } =
|
||||||
|
simulateFinancialProjection(mergedProfile);
|
||||||
setProjectionData(projectionData);
|
setProjectionData(projectionData);
|
||||||
setLoanPaidOffMonth(loanPaidOffMonth);
|
setLoanPaidOffMonth(loanPaidOffMonth);
|
||||||
}, [financialProfile, collegeProfile, milestones, universalMilestones]);
|
}, [financialProfile, collegeProfile, localScenario, milestones, universalMilestones]);
|
||||||
|
|
||||||
// Helper: Flatten all milestone impacts into one array for the simulator
|
|
||||||
function buildAllImpacts(allMilestones) {
|
function buildAllImpacts(allMilestones) {
|
||||||
let impacts = [];
|
let impacts = [];
|
||||||
for (let m of allMilestones) {
|
for (let m of allMilestones) {
|
||||||
// Possibly fetch m.impacts if you store them directly on the milestone
|
|
||||||
// or if you fetch them separately.
|
|
||||||
// If your code stores them as `m.impacts = [ { direction, amount, ... } ]`
|
|
||||||
if (m.impacts) {
|
if (m.impacts) {
|
||||||
impacts.push(...m.impacts);
|
impacts.push(...m.impacts);
|
||||||
}
|
}
|
||||||
// If you also want a milestone that sets a new salary, handle that logic too
|
// If new_salary logic is relevant, handle it here
|
||||||
// E.g., { impact_type: 'SALARY_CHANGE', start_date: m.date, newSalary: m.new_salary }
|
|
||||||
}
|
}
|
||||||
return impacts;
|
return impacts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) We’ll display a single line chart with Net Savings (or cumulativeNetSavings)
|
// Edit => open modal
|
||||||
const labels = projectionData.map((p) => p.month);
|
|
||||||
const netSavingsData = projectionData.map((p) => p.cumulativeNetSavings || 0);
|
|
||||||
|
|
||||||
// Grab final row for some KPIs
|
|
||||||
const finalRow = projectionData[projectionData.length - 1] || {};
|
|
||||||
const finalRet = finalRow.retirementSavings?.toFixed(0) || '0';
|
|
||||||
const finalEmerg = finalRow.emergencySavings?.toFixed(0) || '0';
|
|
||||||
|
|
||||||
// 5) Handle “Edit” scenario -> open your existing `ScenarioEditModal.js`
|
|
||||||
// But that modal currently references setFinancialProfile, setCollegeProfile directly,
|
|
||||||
// so you may want a specialized version that changes only this scenario’s row.
|
|
||||||
// For simplicity, we’ll just show how to open it:
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
|
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
|
||||||
<h3>{scenario.career_name || 'Untitled Scenario'}</h3>
|
<h3>{localScenario.career_name || 'Untitled Scenario'}</h3>
|
||||||
<p>Status: {scenario.status}</p>
|
<p>Status: {localScenario.status}</p>
|
||||||
|
|
||||||
<Line
|
<Line
|
||||||
data={{
|
data={{
|
||||||
labels,
|
labels: projectionData.map((p) => p.month),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Net Savings',
|
label: 'Net Savings',
|
||||||
data: netSavingsData,
|
data: projectionData.map((p) => p.cumulativeNetSavings || 0),
|
||||||
borderColor: 'blue',
|
borderColor: 'blue',
|
||||||
fill: false
|
fill: false
|
||||||
}
|
}
|
||||||
@ -172,27 +170,24 @@ export default function ScenarioContainer({
|
|||||||
|
|
||||||
<div style={{ marginTop: '0.5rem' }}>
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br />
|
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br />
|
||||||
<strong>Final Retirement:</strong> ${finalRet} <br />
|
<strong>Retirement (final):</strong> ${
|
||||||
<strong>Final Emergency:</strong> ${finalEmerg}
|
projectionData[projectionData.length - 1]?.retirementSavings?.toFixed(0) || 0
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The timeline for this scenario. We pass careerPathId */}
|
|
||||||
<MilestoneTimeline
|
<MilestoneTimeline
|
||||||
careerPathId={scenario.id}
|
careerPathId={localScenario.id}
|
||||||
authFetch={authFetch}
|
authFetch={authFetch}
|
||||||
activeView="Financial" // or a state that toggles Career vs. Financial
|
activeView="Financial"
|
||||||
setActiveView={() => {}}
|
setActiveView={() => {}}
|
||||||
onMilestoneUpdated={() => {
|
onMilestoneUpdated={() => {
|
||||||
// re-fetch or something
|
// re-fetch or something
|
||||||
// We'll just force a re-fetch of scenario’s milestones
|
|
||||||
// or re-run the entire load effect
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Show AI suggestions if you like */}
|
|
||||||
<AISuggestedMilestones
|
<AISuggestedMilestones
|
||||||
career={scenario.career_name}
|
career={localScenario.career_name}
|
||||||
careerPathId={scenario.id}
|
careerPathId={localScenario.id}
|
||||||
authFetch={authFetch}
|
authFetch={authFetch}
|
||||||
activeView="Financial"
|
activeView="Financial"
|
||||||
projectionData={projectionData}
|
projectionData={projectionData}
|
||||||
@ -200,31 +195,21 @@ export default function ScenarioContainer({
|
|||||||
|
|
||||||
<div style={{ marginTop: '0.5rem' }}>
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
<button onClick={() => setEditOpen(true)}>Edit</button>
|
<button onClick={() => setEditOpen(true)}>Edit</button>
|
||||||
<button onClick={onClone} style={{ marginLeft: '0.5rem' }}>Clone</button>
|
<button onClick={onClone} style={{ marginLeft: '0.5rem' }}>
|
||||||
|
Clone
|
||||||
|
</button>
|
||||||
<button onClick={onRemove} style={{ marginLeft: '0.5rem', color: 'red' }}>
|
<button onClick={onRemove} style={{ marginLeft: '0.5rem', color: 'red' }}>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reuse your existing ScenarioEditModal that expects
|
{/* Updated ScenarioEditModal that references localScenario + setLocalScenario */}
|
||||||
setFinancialProfile, setCollegeProfile, etc.
|
|
||||||
However, you might want a specialized "ScenarioEditModal" that updates
|
|
||||||
the DB fields for *this* scenario. For now, we just show how to open. */}
|
|
||||||
<ScenarioEditModal
|
<ScenarioEditModal
|
||||||
show={editOpen}
|
show={editOpen}
|
||||||
onClose={() => setEditOpen(false)}
|
onClose={() => setEditOpen(false)}
|
||||||
financialProfile={financialProfile}
|
scenario={localScenario}
|
||||||
setFinancialProfile={() => {
|
setScenario={setLocalScenario}
|
||||||
// If you truly want scenario-specific financial data,
|
|
||||||
// you’d do a more advanced approach.
|
|
||||||
// For now, do nothing or re-fetch from server.
|
|
||||||
}}
|
|
||||||
collegeProfile={collegeProfile}
|
|
||||||
setCollegeProfile={(updated) => {
|
|
||||||
setCollegeProfile((prev) => ({ ...prev, ...updated }));
|
|
||||||
}}
|
|
||||||
apiURL="/api"
|
apiURL="/api"
|
||||||
authFetch={authFetch}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -5,234 +5,195 @@ import authFetch from '../utils/authFetch.js';
|
|||||||
const ScenarioEditModal = ({
|
const ScenarioEditModal = ({
|
||||||
show,
|
show,
|
||||||
onClose,
|
onClose,
|
||||||
financialProfile,
|
scenario, // <== We'll need the scenario object here
|
||||||
setFinancialProfile,
|
setScenario, // callback to update the scenario in parent
|
||||||
collegeProfile,
|
apiURL
|
||||||
setCollegeProfile,
|
|
||||||
apiURL,
|
|
||||||
authFetch,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [formData, setFormData] = useState({});
|
const [formData, setFormData] = useState({});
|
||||||
|
|
||||||
// Populate local formData whenever show=true
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) return;
|
if (!show || !scenario) return;
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
// From financialProfile:
|
careerName: scenario.career_name || '',
|
||||||
currentSalary: financialProfile?.current_salary ?? 0,
|
status: scenario.status || 'planned',
|
||||||
monthlyExpenses: financialProfile?.monthly_expenses ?? 0,
|
startDate: scenario.start_date || '',
|
||||||
monthlyDebtPayments: financialProfile?.monthly_debt_payments ?? 0,
|
projectedEndDate: scenario.projected_end_date || '',
|
||||||
retirementSavings: financialProfile?.retirement_savings ?? 0,
|
// existing fields
|
||||||
emergencySavings: financialProfile?.emergency_fund ?? 0,
|
// newly added columns:
|
||||||
monthlyRetirementContribution: financialProfile?.retirement_contribution ?? 0,
|
plannedMonthlyExpenses: scenario.planned_monthly_expenses ?? '',
|
||||||
monthlyEmergencyContribution: financialProfile?.emergency_contribution ?? 0,
|
plannedMonthlyDebt: scenario.planned_monthly_debt_payments ?? '',
|
||||||
surplusEmergencyAllocation: financialProfile?.extra_cash_emergency_pct ?? 50,
|
plannedMonthlyRetirement: scenario.planned_monthly_retirement_contribution ?? '',
|
||||||
surplusRetirementAllocation: financialProfile?.extra_cash_retirement_pct ?? 50,
|
plannedMonthlyEmergency: scenario.planned_monthly_emergency_contribution ?? '',
|
||||||
|
plannedSurplusEmergencyPct: scenario.planned_surplus_emergency_pct ?? '',
|
||||||
// From collegeProfile:
|
plannedSurplusRetirementPct: scenario.planned_surplus_retirement_pct ?? '',
|
||||||
studentLoanAmount: collegeProfile?.existing_college_debt ?? 0,
|
plannedAdditionalIncome: scenario.planned_additional_income ?? '',
|
||||||
interestRate: collegeProfile?.interest_rate ?? 5,
|
// ...
|
||||||
loanTerm: collegeProfile?.loan_term ?? 10,
|
|
||||||
loanDeferralUntilGraduation: !!collegeProfile?.loan_deferral_until_graduation,
|
|
||||||
academicCalendar: collegeProfile?.academic_calendar ?? 'monthly',
|
|
||||||
annualFinancialAid: collegeProfile?.annual_financial_aid ?? 0,
|
|
||||||
calculatedTuition: collegeProfile?.tuition ?? 0,
|
|
||||||
extraPayment: collegeProfile?.extra_payment ?? 0,
|
|
||||||
partTimeIncome: 0, // or fetch from DB if you store it
|
|
||||||
gradDate: collegeProfile?.expected_graduation ?? '',
|
|
||||||
programType: collegeProfile?.program_type ?? '',
|
|
||||||
creditHoursPerYear: collegeProfile?.credit_hours_per_year ?? 0,
|
|
||||||
hoursCompleted: collegeProfile?.hours_completed ?? 0,
|
|
||||||
programLength: collegeProfile?.program_length ?? 0,
|
|
||||||
inCollege:
|
|
||||||
collegeProfile?.college_enrollment_status === 'currently_enrolled' ||
|
|
||||||
collegeProfile?.college_enrollment_status === 'prospective_student',
|
|
||||||
expectedSalary: collegeProfile?.expected_salary ?? financialProfile?.current_salary ?? 0,
|
|
||||||
});
|
});
|
||||||
}, [show, financialProfile, collegeProfile]);
|
}, [show, scenario]);
|
||||||
|
|
||||||
// Handle form changes in local state
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, type, value, checked } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData((prev) => ({
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
...prev,
|
|
||||||
[name]:
|
|
||||||
type === 'checkbox'
|
|
||||||
? checked
|
|
||||||
: type === 'number'
|
|
||||||
? parseFloat(value) || 0
|
|
||||||
: value
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// SAVE: Update DB and local states, then close
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
if (!scenario) return;
|
||||||
try {
|
try {
|
||||||
// 1) Update the backend (financialProfile + collegeProfile):
|
// We'll call POST /api/premium/career-profile or a separate PUT.
|
||||||
// (Adjust endpoints/methods as needed in your codebase)
|
// Because the code is "upsert," we can do the same POST
|
||||||
await authFetch(`${apiURL}/premium/financial-profile`, {
|
// and rely on ON CONFLICT.
|
||||||
method: 'PUT',
|
const payload = {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
career_name: formData.careerName,
|
||||||
body: JSON.stringify({
|
status: formData.status,
|
||||||
current_salary: formData.currentSalary,
|
start_date: formData.startDate,
|
||||||
monthly_expenses: formData.monthlyExpenses,
|
projected_end_date: formData.projectedEndDate,
|
||||||
monthly_debt_payments: formData.monthlyDebtPayments,
|
|
||||||
retirement_savings: formData.retirementSavings,
|
|
||||||
emergency_fund: formData.emergencySavings,
|
|
||||||
retirement_contribution: formData.monthlyRetirementContribution,
|
|
||||||
emergency_contribution: formData.monthlyEmergencyContribution,
|
|
||||||
extra_cash_emergency_pct: formData.surplusEmergencyAllocation,
|
|
||||||
extra_cash_retirement_pct: formData.surplusRetirementAllocation
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
await authFetch(`${apiURL}/premium/college-profile`, {
|
planned_monthly_expenses: formData.plannedMonthlyExpenses === ''
|
||||||
method: 'PUT',
|
? null
|
||||||
headers: { 'Content-Type': 'application/json' },
|
: parseFloat(formData.plannedMonthlyExpenses),
|
||||||
body: JSON.stringify({
|
planned_monthly_debt_payments: formData.plannedMonthlyDebt === ''
|
||||||
existing_college_debt: formData.studentLoanAmount,
|
? null
|
||||||
interest_rate: formData.interestRate,
|
: parseFloat(formData.plannedMonthlyDebt),
|
||||||
loan_term: formData.loanTerm,
|
planned_monthly_retirement_contribution: formData.plannedMonthlyRetirement === ''
|
||||||
loan_deferral_until_graduation: formData.loanDeferralUntilGraduation,
|
? null
|
||||||
academic_calendar: formData.academicCalendar,
|
: parseFloat(formData.plannedMonthlyRetirement),
|
||||||
annual_financial_aid: formData.annualFinancialAid,
|
planned_monthly_emergency_contribution: formData.plannedMonthlyEmergency === ''
|
||||||
tuition: formData.calculatedTuition,
|
? null
|
||||||
extra_payment: formData.extraPayment,
|
: parseFloat(formData.plannedMonthlyEmergency),
|
||||||
expected_graduation: formData.gradDate,
|
planned_surplus_emergency_pct: formData.plannedSurplusEmergencyPct === ''
|
||||||
program_type: formData.programType,
|
? null
|
||||||
credit_hours_per_year: formData.creditHoursPerYear,
|
: parseFloat(formData.plannedSurplusEmergencyPct),
|
||||||
hours_completed: formData.hoursCompleted,
|
planned_surplus_retirement_pct: formData.plannedSurplusRetirementPct === ''
|
||||||
program_length: formData.programLength,
|
? null
|
||||||
college_enrollment_status: formData.inCollege
|
: parseFloat(formData.plannedSurplusRetirementPct),
|
||||||
? 'currently_enrolled'
|
planned_additional_income: formData.plannedAdditionalIncome === ''
|
||||||
: 'not_enrolled',
|
? null
|
||||||
expected_salary: formData.expectedSalary
|
: parseFloat(formData.plannedAdditionalIncome),
|
||||||
})
|
};
|
||||||
});
|
|
||||||
|
|
||||||
// 2) Update local React state so your useEffect triggers re-simulation
|
const res = await authFetch(`${apiURL}/premium/career-profile`, {
|
||||||
setFinancialProfile((prev) => ({
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} - failed to update scenario`);
|
||||||
|
|
||||||
|
// If successful, we can optionally fetch the updated row or just
|
||||||
|
// update local scenario:
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('Scenario upserted:', data);
|
||||||
|
|
||||||
|
// Optionally call setScenario if you want to reflect changes in UI
|
||||||
|
setScenario(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
current_salary: formData.currentSalary,
|
career_name: formData.careerName,
|
||||||
monthly_expenses: formData.monthlyExpenses,
|
status: formData.status,
|
||||||
monthly_debt_payments: formData.monthlyDebtPayments,
|
start_date: formData.startDate,
|
||||||
retirement_savings: formData.retirementSavings,
|
projected_end_date: formData.projectedEndDate,
|
||||||
emergency_fund: formData.emergencySavings,
|
planned_monthly_expenses: payload.planned_monthly_expenses,
|
||||||
retirement_contribution: formData.monthlyRetirementContribution,
|
planned_monthly_debt_payments: payload.planned_monthly_debt_payments,
|
||||||
emergency_contribution: formData.monthlyEmergencyContribution,
|
planned_monthly_retirement_contribution: payload.planned_monthly_retirement_contribution,
|
||||||
extra_cash_emergency_pct: formData.surplusEmergencyAllocation,
|
planned_monthly_emergency_contribution: payload.planned_monthly_emergency_contribution,
|
||||||
extra_cash_retirement_pct: formData.surplusRetirementAllocation
|
planned_surplus_emergency_pct: payload.planned_surplus_emergency_pct,
|
||||||
|
planned_surplus_retirement_pct: payload.planned_surplus_retirement_pct,
|
||||||
|
planned_additional_income: payload.planned_additional_income
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setCollegeProfile((prev) => ({
|
|
||||||
...prev,
|
|
||||||
existing_college_debt: formData.studentLoanAmount,
|
|
||||||
interest_rate: formData.interestRate,
|
|
||||||
loan_term: formData.loanTerm,
|
|
||||||
loan_deferral_until_graduation: formData.loanDeferralUntilGraduation,
|
|
||||||
academic_calendar: formData.academicCalendar,
|
|
||||||
annual_financial_aid: formData.annualFinancialAid,
|
|
||||||
tuition: formData.calculatedTuition,
|
|
||||||
extra_payment: formData.extraPayment,
|
|
||||||
expected_graduation: formData.gradDate,
|
|
||||||
program_type: formData.programType,
|
|
||||||
credit_hours_per_year: formData.creditHoursPerYear,
|
|
||||||
hours_completed: formData.hoursCompleted,
|
|
||||||
program_length: formData.programLength,
|
|
||||||
college_enrollment_status: formData.inCollege
|
|
||||||
? 'currently_enrolled'
|
|
||||||
: 'not_enrolled',
|
|
||||||
expected_salary: formData.expectedSalary
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 3) Close the modal
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving scenario changes:', err);
|
console.error('Error saving scenario changes:', err);
|
||||||
// Optionally show a toast or error UI
|
alert('Failed to save scenario. See console for details.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// If show=false, don't render anything
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop">
|
<div className="modal-backdrop">
|
||||||
<div className="modal-container">
|
<div className="modal-container">
|
||||||
<h2 className="text-xl font-bold mb-4">Edit Scenario Inputs</h2>
|
<h2>Edit Scenario</h2>
|
||||||
|
|
||||||
{/* EXAMPLE FIELDS: Add all the fields you actually want visible */}
|
<label>Scenario Name</label>
|
||||||
<div className="mb-3">
|
|
||||||
<label className="block font-semibold">Current Salary</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
name="careerName"
|
||||||
name="currentSalary"
|
value={formData.careerName}
|
||||||
value={formData.currentSalary}
|
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="border px-2 py-1 w-full"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
<label>Status</label>
|
||||||
<label className="block font-semibold">Monthly Expenses</label>
|
<select
|
||||||
<input
|
name="status"
|
||||||
type="number"
|
value={formData.status}
|
||||||
name="monthlyExpenses"
|
|
||||||
value={formData.monthlyExpenses}
|
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="border px-2 py-1 w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="block font-semibold">Tuition</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="calculatedTuition"
|
|
||||||
value={formData.calculatedTuition}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="border px-2 py-1 w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="block font-semibold">Annual Financial Aid</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="annualFinancialAid"
|
|
||||||
value={formData.annualFinancialAid}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="border px-2 py-1 w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Example checkbox for loan deferral */}
|
|
||||||
<div className="mb-3 flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="loanDeferralUntilGraduation"
|
|
||||||
checked={formData.loanDeferralUntilGraduation}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<label>Defer loan payments until graduation</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add all other fields you want to expose... */}
|
|
||||||
|
|
||||||
{/* Modal Buttons */}
|
|
||||||
<div className="flex justify-end mt-6">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 mr-2 border rounded"
|
|
||||||
>
|
>
|
||||||
|
<option value="planned">Planned</option>
|
||||||
|
<option value="current">Current</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* A few new fields for “planned_” columns: */}
|
||||||
|
<label>Planned Monthly Expenses</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="plannedMonthlyExpenses"
|
||||||
|
value={formData.plannedMonthlyExpenses}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>Planned Monthly Debt</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="plannedMonthlyDebt"
|
||||||
|
value={formData.plannedMonthlyDebt}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>Planned Retirement Contribution (monthly)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="plannedMonthlyRetirement"
|
||||||
|
value={formData.plannedMonthlyRetirement}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>Planned Emergency Contribution (monthly)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="plannedMonthlyEmergency"
|
||||||
|
value={formData.plannedMonthlyEmergency}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>Planned Surplus % → Emergency</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="plannedSurplusEmergencyPct"
|
||||||
|
value={formData.plannedSurplusEmergencyPct}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>Planned Surplus % → Retirement</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="plannedSurplusRetirementPct"
|
||||||
|
value={formData.plannedSurplusRetirementPct}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label>Planned Additional Income</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="plannedAdditionalIncome"
|
||||||
|
value={formData.plannedAdditionalIncome}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6">
|
||||||
|
<button onClick={onClose} style={{ marginRight: '1rem' }}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={handleSave}>
|
||||||
onClick={handleSave}
|
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded"
|
|
||||||
>
|
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user