Added next-steps provided by AI to convert to milestones, fixed simulation. Where Am I Now should be finished.
This commit is contained in:
parent
0ea62392dd
commit
961d0e5fd4
@ -146,6 +146,10 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
projected_end_date,
|
||||
college_enrollment_status,
|
||||
currently_working,
|
||||
// The new field:
|
||||
career_goals,
|
||||
|
||||
// planned fields
|
||||
planned_monthly_expenses,
|
||||
planned_monthly_debt_payments,
|
||||
planned_monthly_retirement_contribution,
|
||||
@ -162,6 +166,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
try {
|
||||
const newId = uuidv4();
|
||||
|
||||
// 1) Insert includes career_goals
|
||||
const sql = `
|
||||
INSERT INTO career_profiles (
|
||||
id,
|
||||
@ -173,6 +178,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
projected_end_date,
|
||||
college_enrollment_status,
|
||||
currently_working,
|
||||
career_goals, -- ADD THIS
|
||||
planned_monthly_expenses,
|
||||
planned_monthly_debt_payments,
|
||||
planned_monthly_retirement_contribution,
|
||||
@ -181,13 +187,14 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
planned_surplus_retirement_pct,
|
||||
planned_additional_income
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
start_date = VALUES(start_date),
|
||||
projected_end_date = VALUES(projected_end_date),
|
||||
college_enrollment_status = VALUES(college_enrollment_status),
|
||||
currently_working = VALUES(currently_working),
|
||||
career_goals = VALUES(career_goals), -- ADD THIS
|
||||
planned_monthly_expenses = VALUES(planned_monthly_expenses),
|
||||
planned_monthly_debt_payments = VALUES(planned_monthly_debt_payments),
|
||||
planned_monthly_retirement_contribution = VALUES(planned_monthly_retirement_contribution),
|
||||
@ -208,6 +215,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
projected_end_date || null,
|
||||
college_enrollment_status || null,
|
||||
currently_working || null,
|
||||
career_goals || null, // pass career_goals here
|
||||
planned_monthly_expenses ?? null,
|
||||
planned_monthly_debt_payments ?? null,
|
||||
planned_monthly_retirement_contribution ?? null,
|
||||
@ -218,11 +226,12 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
]);
|
||||
|
||||
// re-fetch to confirm ID
|
||||
const [rows] = await pool.query(`
|
||||
SELECT id
|
||||
FROM career_profiles
|
||||
WHERE id = ?
|
||||
`, [newId]);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id
|
||||
FROM career_profiles
|
||||
WHERE id = ?`,
|
||||
[newId]
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Career profile upserted.',
|
||||
@ -234,6 +243,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// DELETE a career profile (scenario) by ID
|
||||
app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser, async (req, res) => {
|
||||
const { careerProfileId } = req.params;
|
||||
@ -302,6 +312,269 @@ app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUs
|
||||
}
|
||||
});
|
||||
|
||||
/***************************************************
|
||||
AI - NEXT STEPS ENDPOINT (with date constraints,
|
||||
ignoring scenarioRow.start_date)
|
||||
****************************************************/
|
||||
app.post('/api/premium/ai/next-steps', authenticatePremiumUser, async (req, res) => {
|
||||
try {
|
||||
// 1) Gather user data from request
|
||||
const {
|
||||
userProfile = {},
|
||||
scenarioRow = {},
|
||||
financialProfile = {},
|
||||
collegeProfile = {}
|
||||
} = req.body;
|
||||
|
||||
// 2) Build a summary for ChatGPT
|
||||
// (We'll ignore scenarioRow.start_date in the prompt)
|
||||
const summaryText = buildUserSummary({
|
||||
userProfile,
|
||||
scenarioRow,
|
||||
financialProfile,
|
||||
collegeProfile
|
||||
});
|
||||
|
||||
// 3) Dynamically compute "today's" date and future cutoffs
|
||||
const now = new Date();
|
||||
const isoToday = now.toISOString().slice(0, 10); // e.g. "2025-06-01"
|
||||
|
||||
// short-term = within 6 months
|
||||
const shortTermLimit = new Date(now);
|
||||
shortTermLimit.setMonth(shortTermLimit.getMonth() + 6);
|
||||
const isoShortTermLimit = shortTermLimit.toISOString().slice(0, 10);
|
||||
|
||||
// long-term = 1-3 years
|
||||
const oneYearFromNow = new Date(now);
|
||||
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
|
||||
const isoOneYearFromNow = oneYearFromNow.toISOString().slice(0, 10);
|
||||
|
||||
const threeYearsFromNow = new Date(now);
|
||||
threeYearsFromNow.setFullYear(threeYearsFromNow.getFullYear() + 3);
|
||||
const isoThreeYearsFromNow = threeYearsFromNow.toISOString().slice(0, 10);
|
||||
|
||||
// 4) Construct ChatGPT messages
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: `
|
||||
You are an expert career & financial coach.
|
||||
Today's date: ${isoToday}.
|
||||
Short-term means any date up to ${isoShortTermLimit} (within 6 months).
|
||||
Long-term means a date between ${isoOneYearFromNow} and ${isoThreeYearsFromNow} (1-3 years).
|
||||
All milestone dates must be strictly >= ${isoToday}. Titles must be <= 5 words.
|
||||
Respond ONLY in the requested JSON format.`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `
|
||||
Here is the user's current situation:
|
||||
${summaryText}
|
||||
|
||||
Please provide exactly 3 short-term (within 6 months) and 2 long-term (1–3 years) milestones.
|
||||
Each milestone must have:
|
||||
- "title" (up to 5 words)
|
||||
- "date" in YYYY-MM-DD format (>= ${isoToday})
|
||||
- "description" (1-2 sentences)
|
||||
|
||||
Return ONLY a JSON array, no extra text:
|
||||
|
||||
[
|
||||
{
|
||||
"title": "string",
|
||||
"date": "YYYY-MM-DD",
|
||||
"description": "string"
|
||||
},
|
||||
...
|
||||
]`
|
||||
}
|
||||
];
|
||||
|
||||
// 5) Call OpenAI (ignoring scenarioRow.start_date for date logic)
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-3.5-turbo', // or 'gpt-4'
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 600
|
||||
});
|
||||
|
||||
// 6) Extract raw text
|
||||
const aiAdvice = completion?.choices?.[0]?.message?.content?.trim() || 'No response';
|
||||
|
||||
res.json({ recommendations: aiAdvice });
|
||||
} catch (err) {
|
||||
console.error('Error in /api/premium/ai/next-steps =>', err);
|
||||
res.status(500).json({ error: 'Failed to get AI next steps.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper that converts user data into a concise text summary.
|
||||
* This can still mention scenarioRow, but we do NOT feed
|
||||
* scenarioRow.start_date to ChatGPT for future date calculations.
|
||||
*/
|
||||
function buildUserSummary({
|
||||
userProfile = {},
|
||||
scenarioRow = {},
|
||||
financialProfile = {},
|
||||
collegeProfile = {}
|
||||
}) {
|
||||
// Provide a short multiline string about the user's finances, goals, etc.
|
||||
// but avoid referencing scenarioRow.start_date
|
||||
// e.g.:
|
||||
const location = `${userProfile.state || 'Unknown State'}, ${userProfile.area || 'N/A'}`;
|
||||
const careerName = scenarioRow.career_name || 'Unknown';
|
||||
const careerGoals = scenarioRow.career_goals || 'No goals specified';
|
||||
const status = scenarioRow.status || 'planned';
|
||||
const currentlyWorking = scenarioRow.currently_working || 'no';
|
||||
|
||||
const currentSalary = financialProfile.current_salary || 0;
|
||||
const monthlyExpenses = financialProfile.monthly_expenses || 0;
|
||||
const monthlyDebt = financialProfile.monthly_debt_payments || 0;
|
||||
const retirementSavings = financialProfile.retirement_savings || 0;
|
||||
const emergencyFund = financialProfile.emergency_fund || 0;
|
||||
|
||||
// And similarly for collegeProfile if needed, ignoring start_date
|
||||
return `
|
||||
User Location: ${location}
|
||||
Career Name: ${careerName}
|
||||
Career Goals: ${careerGoals}
|
||||
Career Status: ${status}
|
||||
Currently Working: ${currentlyWorking}
|
||||
|
||||
Financial:
|
||||
- Salary: \$${currentSalary}
|
||||
- Monthly Expenses: \$${monthlyExpenses}
|
||||
- Monthly Debt: \$${monthlyDebt}
|
||||
- Retirement Savings: \$${retirementSavings}
|
||||
- Emergency Fund: \$${emergencyFund}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/***************************************************
|
||||
AI MILESTONE CONVERSION ENDPOINT
|
||||
****************************************************/
|
||||
app.post('/api/premium/milestone/convert-ai', authenticatePremiumUser, async (req, res) => {
|
||||
try {
|
||||
// The client passes us an array of milestones, e.g.:
|
||||
// { milestones: [ { title, date, description, tasks, impacts }, ... ] }
|
||||
const { milestones } = req.body;
|
||||
const { careerProfileId } = req.query;
|
||||
// or from body, if you prefer:
|
||||
// const { careerProfileId } = req.body;
|
||||
|
||||
if (!careerProfileId) {
|
||||
return res.status(400).json({ error: 'careerProfileId is required.' });
|
||||
}
|
||||
if (!Array.isArray(milestones)) {
|
||||
return res.status(400).json({ error: 'Expected milestones array in body.' });
|
||||
}
|
||||
|
||||
const newMilestones = [];
|
||||
|
||||
for (const m of milestones) {
|
||||
// Required fields for your DB:
|
||||
// title, date, career_profile_id
|
||||
if (!m.title || !m.date) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required milestone fields (title/date).',
|
||||
details: m
|
||||
});
|
||||
}
|
||||
|
||||
// create the milestone row
|
||||
const id = uuidv4();
|
||||
await pool.query(`
|
||||
INSERT INTO milestones (
|
||||
id,
|
||||
user_id,
|
||||
career_profile_id,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
progress,
|
||||
status,
|
||||
is_universal
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 0, 'planned', 0)
|
||||
`, [
|
||||
id,
|
||||
req.id,
|
||||
careerProfileId,
|
||||
m.title,
|
||||
m.description || '',
|
||||
m.date
|
||||
]);
|
||||
|
||||
// If the user also sent tasks in m.tasks:
|
||||
if (Array.isArray(m.tasks)) {
|
||||
for (const t of m.tasks) {
|
||||
const taskId = uuidv4();
|
||||
await pool.query(`
|
||||
INSERT INTO tasks (
|
||||
id,
|
||||
milestone_id,
|
||||
user_id,
|
||||
title,
|
||||
description,
|
||||
due_date,
|
||||
status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'not_started')
|
||||
`, [
|
||||
taskId,
|
||||
id,
|
||||
req.id,
|
||||
t.title || 'Task',
|
||||
t.description || '',
|
||||
t.due_date || null
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// If the user also sent impacts in m.impacts:
|
||||
if (Array.isArray(m.impacts)) {
|
||||
for (const imp of m.impacts) {
|
||||
const impactId = uuidv4();
|
||||
await pool.query(`
|
||||
INSERT INTO milestone_impacts (
|
||||
id,
|
||||
milestone_id,
|
||||
impact_type,
|
||||
direction,
|
||||
amount,
|
||||
start_date,
|
||||
end_date
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
impactId,
|
||||
id,
|
||||
imp.impact_type || 'none',
|
||||
imp.direction || 'add',
|
||||
imp.amount || 0,
|
||||
imp.start_date || null,
|
||||
imp.end_date || null
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
newMilestones.push({
|
||||
id,
|
||||
title: m.title,
|
||||
description: m.description || '',
|
||||
date: m.date,
|
||||
tasks: m.tasks || [],
|
||||
impacts: m.impacts || []
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ createdMilestones: newMilestones });
|
||||
} catch (err) {
|
||||
console.error('Error converting AI milestones:', err);
|
||||
return res.status(500).json({ error: 'Failed to convert AI milestones.' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
MILESTONE ENDPOINTS
|
||||
------------------------------------------------------------------ */
|
||||
@ -316,18 +589,16 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
||||
const createdMilestones = [];
|
||||
for (const m of body.milestones) {
|
||||
const {
|
||||
milestone_type,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
career_profile_id,
|
||||
progress,
|
||||
status,
|
||||
new_salary,
|
||||
is_universal
|
||||
} = m;
|
||||
|
||||
if (!milestone_type || !title || !date || !career_profile_id) {
|
||||
if (!title || !date || !career_profile_id) {
|
||||
return res.status(400).json({
|
||||
error: 'One or more milestones missing required fields',
|
||||
details: m
|
||||
@ -340,27 +611,23 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
||||
id,
|
||||
user_id,
|
||||
career_profile_id,
|
||||
milestone_type,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
progress,
|
||||
status,
|
||||
new_salary,
|
||||
is_universal
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
id,
|
||||
req.id,
|
||||
career_profile_id,
|
||||
milestone_type,
|
||||
title,
|
||||
description || '',
|
||||
date,
|
||||
progress || 0,
|
||||
status || 'planned',
|
||||
new_salary || null,
|
||||
is_universal ? 1 : 0
|
||||
]);
|
||||
|
||||
@ -368,13 +635,11 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
||||
id,
|
||||
user_id: req.id,
|
||||
career_profile_id,
|
||||
milestone_type,
|
||||
title,
|
||||
description: description || '',
|
||||
date,
|
||||
progress: progress || 0,
|
||||
status: status || 'planned',
|
||||
new_salary: new_salary || null,
|
||||
is_universal: is_universal ? 1 : 0,
|
||||
tasks: []
|
||||
});
|
||||
@ -384,21 +649,19 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
||||
|
||||
// single milestone
|
||||
const {
|
||||
milestone_type,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
career_profile_id,
|
||||
progress,
|
||||
status,
|
||||
new_salary,
|
||||
is_universal
|
||||
} = body;
|
||||
|
||||
if (!milestone_type || !title || !date || !career_profile_id) {
|
||||
if ( !title || !date || !career_profile_id) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
details: { milestone_type, title, date, career_profile_id }
|
||||
details: { title, date, career_profile_id }
|
||||
});
|
||||
}
|
||||
|
||||
@ -408,27 +671,23 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
||||
id,
|
||||
user_id,
|
||||
career_profile_id,
|
||||
milestone_type,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
progress,
|
||||
status,
|
||||
new_salary,
|
||||
is_universal
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
id,
|
||||
req.id,
|
||||
career_profile_id,
|
||||
milestone_type,
|
||||
title,
|
||||
description || '',
|
||||
date,
|
||||
progress || 0,
|
||||
status || 'planned',
|
||||
new_salary || null,
|
||||
is_universal ? 1 : 0
|
||||
]);
|
||||
|
||||
@ -436,13 +695,11 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
||||
id,
|
||||
user_id: req.id,
|
||||
career_profile_id,
|
||||
milestone_type,
|
||||
title,
|
||||
description: description || '',
|
||||
date,
|
||||
progress: progress || 0,
|
||||
status: status || 'planned',
|
||||
new_salary: new_salary || null,
|
||||
is_universal: is_universal ? 1 : 0,
|
||||
tasks: []
|
||||
};
|
||||
@ -458,14 +715,12 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
|
||||
try {
|
||||
const { milestoneId } = req.params;
|
||||
const {
|
||||
milestone_type,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
career_profile_id,
|
||||
progress,
|
||||
status,
|
||||
new_salary,
|
||||
is_universal
|
||||
} = req.body;
|
||||
|
||||
@ -481,40 +736,34 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
|
||||
}
|
||||
const row = existing[0];
|
||||
|
||||
const finalMilestoneType = milestone_type || row.milestone_type;
|
||||
const finalTitle = title || row.title;
|
||||
const finalDesc = description || row.description;
|
||||
const finalDate = date || row.date;
|
||||
const finalCareerProfileId = career_profile_id || row.career_profile_id;
|
||||
const finalProgress = progress != null ? progress : row.progress;
|
||||
const finalStatus = status || row.status;
|
||||
const finalSalary = new_salary != null ? new_salary : row.new_salary;
|
||||
const finalIsUniversal = is_universal != null ? (is_universal ? 1 : 0) : row.is_universal;
|
||||
|
||||
await pool.query(`
|
||||
UPDATE milestones
|
||||
SET
|
||||
milestone_type = ?,
|
||||
title = ?,
|
||||
description = ?,
|
||||
date = ?,
|
||||
career_profile_id = ?,
|
||||
progress = ?,
|
||||
status = ?,
|
||||
new_salary = ?,
|
||||
is_universal = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
AND user_id = ?
|
||||
`, [
|
||||
finalMilestoneType,
|
||||
finalTitle,
|
||||
finalDesc,
|
||||
finalDate,
|
||||
finalCareerProfileId,
|
||||
finalProgress,
|
||||
finalStatus,
|
||||
finalSalary,
|
||||
finalIsUniversal,
|
||||
milestoneId,
|
||||
req.id
|
||||
@ -683,28 +932,24 @@ app.post('/api/premium/milestone/copy', authenticatePremiumUser, async (req, res
|
||||
id,
|
||||
user_id,
|
||||
career_profile_id,
|
||||
milestone_type,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
progress,
|
||||
status,
|
||||
new_salary,
|
||||
is_universal,
|
||||
origin_milestone_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
newMilestoneId,
|
||||
req.id,
|
||||
scenarioId,
|
||||
original.milestone_type,
|
||||
original.title,
|
||||
original.description,
|
||||
original.date,
|
||||
original.progress,
|
||||
original.status,
|
||||
original.new_salary,
|
||||
1,
|
||||
originId
|
||||
]);
|
||||
@ -1190,6 +1435,7 @@ Milestone Requirements:
|
||||
1. Provide exactly 3 SHORT-TERM milestones (within next 1-2 years).
|
||||
- Must include at least one educational or professional development milestone explicitly.
|
||||
- Do NOT exclusively focus on financial aspects.
|
||||
|
||||
|
||||
2. Provide exactly 2 LONG-TERM milestones (3+ years out).
|
||||
- Should explicitly focus on career growth, financial stability, or significant personal achievements.
|
||||
|
@ -93,17 +93,6 @@ export default function MilestoneTimeline({
|
||||
console.warn('No milestones in response:', data);
|
||||
return;
|
||||
}
|
||||
// Separate them by type
|
||||
const categorized = { Career: [], Financial: [] };
|
||||
data.milestones.forEach((m) => {
|
||||
if (categorized[m.milestone_type]) {
|
||||
categorized[m.milestone_type].push(m);
|
||||
} else {
|
||||
// If there's a random type, log or store somewhere else
|
||||
console.warn(`Unknown milestone type: ${m.milestone_type}`);
|
||||
}
|
||||
});
|
||||
setMilestones(categorized);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch milestones:', err);
|
||||
}
|
||||
@ -150,7 +139,6 @@ export default function MilestoneTimeline({
|
||||
description: m.description || '',
|
||||
date: m.date || '',
|
||||
progress: m.progress || 0,
|
||||
newSalary: m.new_salary || '',
|
||||
impacts: fetchedImpacts.map((imp) => ({
|
||||
id: imp.id,
|
||||
impact_type: imp.impact_type || 'ONE_TIME',
|
||||
@ -188,10 +176,6 @@ export default function MilestoneTimeline({
|
||||
career_profile_id: careerProfileId,
|
||||
progress: newMilestone.progress,
|
||||
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
|
||||
new_salary:
|
||||
activeView === 'Financial' && newMilestone.newSalary
|
||||
? parseFloat(newMilestone.newSalary)
|
||||
: null,
|
||||
is_universal: newMilestone.isUniversal || 0
|
||||
};
|
||||
|
||||
|
@ -21,16 +21,15 @@ import { getFullStateName } from '../utils/stateUtils.js';
|
||||
|
||||
import { Button } from './ui/button.js';
|
||||
import CareerSelectDropdown from './CareerSelectDropdown.js';
|
||||
import CareerSearch from './CareerSearch.js';
|
||||
import MilestoneTimeline from './MilestoneTimeline.js';
|
||||
import ScenarioEditModal from './ScenarioEditModal.js';
|
||||
|
||||
// If you need AI suggestions in the future:
|
||||
// import AISuggestedMilestones from './AISuggestedMilestones.js';
|
||||
|
||||
import './MilestoneTracker.css';
|
||||
import './MilestoneTimeline.css';
|
||||
|
||||
// --------------
|
||||
// Register ChartJS Plugins
|
||||
// --------------
|
||||
ChartJS.register(
|
||||
LineElement,
|
||||
BarElement,
|
||||
@ -43,17 +42,14 @@ ChartJS.register(
|
||||
annotationPlugin
|
||||
);
|
||||
|
||||
// ----------------------
|
||||
// 1) Remove decimals from SOC code
|
||||
// ----------------------
|
||||
// --------------
|
||||
// Helper Functions
|
||||
// --------------
|
||||
function stripSocCode(fullSoc) {
|
||||
if (!fullSoc) return '';
|
||||
return fullSoc.split('.')[0];
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
// 2) Salary Gauge
|
||||
// ----------------------
|
||||
function getRelativePosition(userSal, p10, p90) {
|
||||
if (!p10 || !p90) return 0;
|
||||
if (userSal < p10) return 0;
|
||||
@ -61,6 +57,7 @@ function getRelativePosition(userSal, p10, p90) {
|
||||
return (userSal - p10) / (p90 - p10);
|
||||
}
|
||||
|
||||
// A simple gauge for the user’s salary vs. percentiles
|
||||
function SalaryGauge({ userSalary, percentileRow, prefix = 'regional' }) {
|
||||
if (!percentileRow) return null;
|
||||
|
||||
@ -91,6 +88,7 @@ function SalaryGauge({ userSalary, percentileRow, prefix = 'regional' }) {
|
||||
marginBottom: '8px'
|
||||
}}
|
||||
>
|
||||
{/* Median Marker */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@ -120,6 +118,7 @@ function SalaryGauge({ userSalary, percentileRow, prefix = 'regional' }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Salary Marker */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@ -153,9 +152,6 @@ function SalaryGauge({ userSalary, percentileRow, prefix = 'regional' }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
// 3) Economic Bar
|
||||
// ----------------------
|
||||
function EconomicProjectionsBar({ data }) {
|
||||
if (!data) return null;
|
||||
const {
|
||||
@ -239,16 +235,42 @@ function getYearsInCareer(startDateString) {
|
||||
return Math.floor(diffYears).toString();
|
||||
}
|
||||
|
||||
// ----------------------
|
||||
// 4) MilestoneTracker
|
||||
// ----------------------
|
||||
/**
|
||||
* parseAiJson
|
||||
* If ChatGPT returns a fenced code block like:
|
||||
* ```json
|
||||
* [ { ... }, ... ]
|
||||
* ```
|
||||
* we extract that JSON. Otherwise, we parse the raw string.
|
||||
*/
|
||||
function parseAiJson(rawText) {
|
||||
const fencedRegex = /```json\s*([\s\S]*?)\s*```/i;
|
||||
const match = rawText.match(fencedRegex);
|
||||
if (match) {
|
||||
const jsonStr = match[1].trim();
|
||||
const arr = JSON.parse(jsonStr);
|
||||
// Add an "id" for each milestone
|
||||
arr.forEach((m) => {
|
||||
m.id = crypto.randomUUID();
|
||||
});
|
||||
return arr;
|
||||
} else {
|
||||
// fallback if no fences
|
||||
const arr = JSON.parse(rawText);
|
||||
arr.forEach((m) => {
|
||||
m.id = crypto.randomUUID();
|
||||
});
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
|
||||
export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
const location = useLocation();
|
||||
const apiURL = process.env.REACT_APP_API_URL;
|
||||
|
||||
// Basic states
|
||||
const [userProfile, setUserProfile] = useState(null);
|
||||
const [financialProfile, setFinancialProfile] = useState(null);
|
||||
|
||||
const [masterCareerRatings, setMasterCareerRatings] = useState([]);
|
||||
const [existingCareerProfiles, setExistingCareerProfiles] = useState([]);
|
||||
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
|
||||
@ -260,15 +282,22 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
const [salaryData, setSalaryData] = useState(null);
|
||||
const [economicProjections, setEconomicProjections] = useState(null);
|
||||
|
||||
// Milestones & Projection
|
||||
const [scenarioMilestones, setScenarioMilestones] = useState([]);
|
||||
const [projectionData, setProjectionData] = useState([]);
|
||||
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
||||
|
||||
// Config
|
||||
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
||||
const simulationYears = parseInt(simulationYearsInput, 10) || 20;
|
||||
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
// AI
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [recommendations, setRecommendations] = useState([]); // parsed array
|
||||
const [selectedIds, setSelectedIds] = useState([]); // which rec IDs are checked
|
||||
|
||||
const {
|
||||
projectionData: initProjData = [],
|
||||
loanPayoffMonth: initLoanMonth = null
|
||||
@ -276,22 +305,22 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
|
||||
// 1) Fetch user + financial
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
async function fetchUser() {
|
||||
try {
|
||||
const r = await authFetch('/api/user-profile');
|
||||
if (r.ok) setUserProfile(await r.json());
|
||||
} catch (err) {
|
||||
console.error('Error user-profile =>', err);
|
||||
}
|
||||
};
|
||||
const fetchFin = async () => {
|
||||
}
|
||||
async function fetchFin() {
|
||||
try {
|
||||
const r = await authFetch(`${apiURL}/premium/financial-profile`);
|
||||
if (r.ok) setFinancialProfile(await r.json());
|
||||
} catch (err) {
|
||||
console.error('Error financial =>', err);
|
||||
}
|
||||
};
|
||||
}
|
||||
fetchUser();
|
||||
fetchFin();
|
||||
}, [apiURL]);
|
||||
@ -313,7 +342,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
|
||||
// 3) fetch user’s career-profiles
|
||||
useEffect(() => {
|
||||
const fetchProfiles = async () => {
|
||||
async function fetchProfiles() {
|
||||
const r = await authFetch(`${apiURL}/premium/career-profile/all`);
|
||||
if (!r || !r.ok) return;
|
||||
const d = await r.json();
|
||||
@ -343,7 +372,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
fetchProfiles();
|
||||
}, [apiURL, location.state]);
|
||||
|
||||
@ -357,19 +386,19 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
}
|
||||
localStorage.setItem('lastSelectedCareerProfileId', careerProfileId);
|
||||
|
||||
const fetchScenario = async () => {
|
||||
async function fetchScenario() {
|
||||
const s = await authFetch(`${apiURL}/premium/career-profile/${careerProfileId}`);
|
||||
if (s.ok) setScenarioRow(await s.json());
|
||||
};
|
||||
const fetchCollege = async () => {
|
||||
}
|
||||
async function fetchCollege() {
|
||||
const c = await authFetch(`${apiURL}/premium/college-profile?careerProfileId=${careerProfileId}`);
|
||||
if (c.ok) setCollegeProfile(await c.json());
|
||||
};
|
||||
}
|
||||
fetchScenario();
|
||||
fetchCollege();
|
||||
}, [careerProfileId, apiURL]);
|
||||
|
||||
// 5) from scenarioRow.career_name => find the full SOC => strip
|
||||
// 5) from scenarioRow => find the full SOC => strip
|
||||
useEffect(() => {
|
||||
if (!scenarioRow?.career_name || !masterCareerRatings.length) {
|
||||
setStrippedSocCode(null);
|
||||
@ -395,12 +424,8 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
socCode: strippedSocCode,
|
||||
area: userArea
|
||||
}).toString();
|
||||
const qs = new URLSearchParams({ socCode: strippedSocCode, area: userArea }).toString();
|
||||
const url = `${apiURL}/salary?${qs}`;
|
||||
console.log('[Salary fetch =>]', url);
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) {
|
||||
console.error('[Salary fetch non-200 =>]', r.status);
|
||||
@ -408,7 +433,6 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
return;
|
||||
}
|
||||
const dd = await r.json();
|
||||
console.log('[Salary success =>]', dd);
|
||||
setSalaryData(dd);
|
||||
} catch (err) {
|
||||
console.error('[Salary fetch error]', err);
|
||||
@ -426,7 +450,6 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
(async () => {
|
||||
const qs = new URLSearchParams({ state: userState }).toString();
|
||||
const econUrl = `${apiURL}/projections/${strippedSocCode}?${qs}`;
|
||||
console.log('[Econ fetch =>]', econUrl);
|
||||
try {
|
||||
const r = await authFetch(econUrl);
|
||||
if (!r.ok) {
|
||||
@ -435,7 +458,6 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
return;
|
||||
}
|
||||
const econData = await r.json();
|
||||
console.log('[Econ success =>]', econData);
|
||||
setEconomicProjections(econData);
|
||||
} catch (err) {
|
||||
console.error('[Econ fetch error]', err);
|
||||
@ -445,7 +467,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
}, [strippedSocCode, userState, apiURL]);
|
||||
|
||||
// 8) Build financial projection
|
||||
const buildProjection = async () => {
|
||||
async function buildProjection() {
|
||||
try {
|
||||
const milUrl = `${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`;
|
||||
const mr = await authFetch(milUrl);
|
||||
@ -457,16 +479,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
const allMilestones = md.milestones || [];
|
||||
setScenarioMilestones(allMilestones);
|
||||
|
||||
function parseScenarioOverride(overrideVal, fallbackVal) {
|
||||
// If the DB field is NULL => means user never entered anything
|
||||
if (overrideVal === null) {
|
||||
return fallbackVal;
|
||||
}
|
||||
// Otherwise user typed a number, even if it's "0"
|
||||
return parseFloatOrZero(overrideVal, fallbackVal);
|
||||
}
|
||||
|
||||
|
||||
// fetch impacts
|
||||
const imPromises = allMilestones.map((m) =>
|
||||
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
@ -495,38 +508,44 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
extraCashRetirementPct: parseFloatOrZero(f.extra_cash_retirement_pct, 50)
|
||||
};
|
||||
|
||||
function parseScenarioOverride(overrideVal, fallbackVal) {
|
||||
if (overrideVal === null) {
|
||||
return fallbackVal;
|
||||
}
|
||||
return parseFloatOrZero(overrideVal, fallbackVal);
|
||||
}
|
||||
|
||||
const s = scenarioRow;
|
||||
const scenarioOverrides = {
|
||||
monthlyExpenses: parseScenarioOverride(
|
||||
s.planned_monthly_expenses,
|
||||
financialBase.monthlyExpenses
|
||||
),
|
||||
monthlyDebtPayments: parseScenarioOverride(
|
||||
s.planned_monthly_debt_payments,
|
||||
financialBase.monthlyDebtPayments
|
||||
),
|
||||
monthlyRetirementContribution: parseScenarioOverride(
|
||||
s.planned_monthly_retirement_contribution,
|
||||
financialBase.retirementContribution
|
||||
),
|
||||
monthlyEmergencyContribution: parseScenarioOverride(
|
||||
s.planned_monthly_emergency_contribution,
|
||||
financialBase.emergencyContribution
|
||||
),
|
||||
surplusEmergencyAllocation: parseScenarioOverride(
|
||||
s.planned_surplus_emergency_pct,
|
||||
financialBase.extraCashEmergencyPct
|
||||
),
|
||||
surplusRetirementAllocation: parseScenarioOverride(
|
||||
s.planned_surplus_retirement_pct,
|
||||
financialBase.extraCashRetirementPct
|
||||
),
|
||||
additionalIncome: parseScenarioOverride(
|
||||
s.planned_additional_income,
|
||||
financialBase.additionalIncome
|
||||
),
|
||||
};
|
||||
|
||||
monthlyExpenses: parseScenarioOverride(
|
||||
s.planned_monthly_expenses,
|
||||
financialBase.monthlyExpenses
|
||||
),
|
||||
monthlyDebtPayments: parseScenarioOverride(
|
||||
s.planned_monthly_debt_payments,
|
||||
financialBase.monthlyDebtPayments
|
||||
),
|
||||
monthlyRetirementContribution: parseScenarioOverride(
|
||||
s.planned_monthly_retirement_contribution,
|
||||
financialBase.retirementContribution
|
||||
),
|
||||
monthlyEmergencyContribution: parseScenarioOverride(
|
||||
s.planned_monthly_emergency_contribution,
|
||||
financialBase.emergencyContribution
|
||||
),
|
||||
surplusEmergencyAllocation: parseScenarioOverride(
|
||||
s.planned_surplus_emergency_pct,
|
||||
financialBase.extraCashEmergencyPct
|
||||
),
|
||||
surplusRetirementAllocation: parseScenarioOverride(
|
||||
s.planned_surplus_retirement_pct,
|
||||
financialBase.extraCashRetirementPct
|
||||
),
|
||||
additionalIncome: parseScenarioOverride(
|
||||
s.planned_additional_income,
|
||||
financialBase.additionalIncome
|
||||
)
|
||||
};
|
||||
|
||||
const c = collegeProfile;
|
||||
const collegeData = {
|
||||
@ -587,7 +606,6 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
const { projectionData: pData, loanPaidOffMonth } =
|
||||
simulateFinancialProjection(mergedProfile);
|
||||
|
||||
// Build "cumulativeNetSavings" ourselves, plus each row has .retirementSavings and .emergencySavings
|
||||
let cumu = mergedProfile.emergencySavings || 0;
|
||||
const finalData = pData.map((mo) => {
|
||||
cumu += mo.netSavings || 0;
|
||||
@ -599,27 +617,19 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
} catch (err) {
|
||||
console.error('Error in scenario simulation =>', err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!financialProfile || !scenarioRow || !collegeProfile) return;
|
||||
buildProjection();
|
||||
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, apiURL, simulationYears]);
|
||||
|
||||
// Handlers
|
||||
const handleSimulationYearsChange = (e) => setSimulationYearsInput(e.target.value);
|
||||
const handleSimulationYearsBlur = () => {
|
||||
if (!simulationYearsInput.trim()) setSimulationYearsInput('20');
|
||||
};
|
||||
|
||||
// -- Annotations --
|
||||
// 1) Milestone lines
|
||||
// Build chart datasets / annotations
|
||||
const milestoneAnnotationLines = {};
|
||||
scenarioMilestones.forEach((m) => {
|
||||
if (!m.date) return;
|
||||
const d = new Date(m.date);
|
||||
if (isNaN(d)) return;
|
||||
|
||||
const yyyy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const short = `${yyyy}-${mm}`;
|
||||
@ -640,10 +650,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
};
|
||||
});
|
||||
|
||||
// 2) Check if there's ever a positive loan balance
|
||||
const hasStudentLoan = projectionData.some((p) => p.loanBalance > 0);
|
||||
|
||||
// 3) Conditionally add the loan payoff annotation
|
||||
const annotationConfig = {};
|
||||
if (loanPayoffMonth && hasStudentLoan) {
|
||||
annotationConfig.loanPaidOffLine = {
|
||||
@ -665,19 +672,16 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const allAnnotations = { ...milestoneAnnotationLines, ...annotationConfig };
|
||||
|
||||
// Build the chart datasets:
|
||||
const emergencyData = {
|
||||
label: 'Emergency Savings',
|
||||
data: projectionData.map((p) => p.emergencySavings),
|
||||
borderColor: 'rgba(255, 159, 64, 1)', // orange
|
||||
borderColor: 'rgba(255, 159, 64, 1)',
|
||||
backgroundColor: 'rgba(255, 159, 64, 0.2)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
};
|
||||
|
||||
const retirementData = {
|
||||
label: 'Retirement Savings',
|
||||
data: projectionData.map((p) => p.retirementSavings),
|
||||
@ -686,8 +690,6 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
};
|
||||
|
||||
// The total leftover each month (sum of any net gains so far).
|
||||
const totalSavingsData = {
|
||||
label: 'Total Savings',
|
||||
data: projectionData.map((p) => p.totalSavings),
|
||||
@ -696,8 +698,6 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
};
|
||||
|
||||
// We'll insert the Loan Balance dataset only if they actually have a loan
|
||||
const loanBalanceData = {
|
||||
label: 'Loan Balance',
|
||||
data: projectionData.map((p) => p.loanBalance),
|
||||
@ -711,20 +711,96 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
}
|
||||
};
|
||||
|
||||
// The final dataset array:
|
||||
// 1) Emergency
|
||||
// 2) Retirement
|
||||
// 3) Loan (conditional)
|
||||
// 4) Total
|
||||
const chartDatasets = [emergencyData, retirementData];
|
||||
if (hasStudentLoan) {
|
||||
// Insert loan after the first two lines, or wherever you prefer
|
||||
chartDatasets.push(loanBalanceData);
|
||||
}
|
||||
if (hasStudentLoan) chartDatasets.push(loanBalanceData);
|
||||
chartDatasets.push(totalSavingsData);
|
||||
|
||||
const yearsInCareer = getYearsInCareer(scenarioRow?.start_date);
|
||||
|
||||
// -- AI Handler --
|
||||
async function handleAiClick() {
|
||||
setAiLoading(true);
|
||||
setRecommendations([]);
|
||||
setSelectedIds([]);
|
||||
|
||||
try {
|
||||
const payload = { userProfile, scenarioRow, financialProfile, collegeProfile };
|
||||
const res = await authFetch('/api/premium/ai/next-steps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error('AI request failed');
|
||||
|
||||
const data = await res.json();
|
||||
const rawText = data.recommendations || '';
|
||||
|
||||
// Parse JSON
|
||||
const arr = parseAiJson(rawText);
|
||||
setRecommendations(arr);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error fetching AI recommendations:', err);
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Check/uncheck a recommendation
|
||||
function handleToggle(recId) {
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.includes(recId)) {
|
||||
return prev.filter((x) => x !== recId);
|
||||
} else {
|
||||
return [...prev, recId];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateSelectedMilestones() {
|
||||
if (!careerProfileId) return;
|
||||
const confirm = window.confirm('Convert selected AI suggestions into milestones?');
|
||||
if (!confirm) return;
|
||||
|
||||
// filter out those that are checked
|
||||
const selectedRecs = recommendations.filter((r) => selectedIds.includes(r.id));
|
||||
if (!selectedRecs.length) return;
|
||||
|
||||
const newMils = selectedRecs.map((rec) => ({
|
||||
title: rec.title,
|
||||
description: rec.description || '',
|
||||
date: new Date().toISOString().slice(0, 10), // for demonstration
|
||||
career_profile_id: careerProfileId
|
||||
}));
|
||||
|
||||
try {
|
||||
const r = await authFetch('/api/premium/milestone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ milestones: newMils })
|
||||
});
|
||||
if (!r.ok) throw new Error('Failed to create new milestones');
|
||||
|
||||
// re-run the projection to reflect newly inserted milestones
|
||||
await buildProjection();
|
||||
|
||||
alert('Milestones successfully created! Check your timeline or projection.');
|
||||
|
||||
// optionally clear them
|
||||
setSelectedIds([]);
|
||||
} catch (err) {
|
||||
console.error('Error saving new milestones:', err);
|
||||
alert('Error saving AI milestones.');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSimulationYearsChange(e) {
|
||||
setSimulationYearsInput(e.target.value);
|
||||
}
|
||||
function handleSimulationYearsBlur() {
|
||||
if (!simulationYearsInput.trim()) setSimulationYearsInput('20');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-6">
|
||||
<h2 className="text-2xl font-bold mb-4">Where Am I Now?</h2>
|
||||
@ -760,7 +836,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
{salaryData?.regional && (
|
||||
<div className="bg-white p-4 rounded shadow w-full md:w-1/2">
|
||||
<h4 className="font-medium mb-2">
|
||||
Regional Data ({userArea || 'U.S.'})
|
||||
Regional Salary Data ({userArea || 'U.S.'})
|
||||
</h4>
|
||||
<p>
|
||||
10th percentile:{' '}
|
||||
@ -768,14 +844,12 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
? `$${salaryData.regional.regional_PCT10.toLocaleString()}`
|
||||
: 'N/A'}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Median:{' '}
|
||||
{salaryData.regional.regional_MEDIAN
|
||||
? `$${salaryData.regional.regional_MEDIAN.toLocaleString()}`
|
||||
: 'N/A'}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
90th percentile:{' '}
|
||||
{salaryData.regional.regional_PCT90
|
||||
@ -793,21 +867,19 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
|
||||
{salaryData?.national && (
|
||||
<div className="bg-white p-4 rounded shadow w-full md:w-1/2">
|
||||
<h4 className="font-medium mb-2">National Data</h4>
|
||||
<h4 className="font-medium mb-2">National Salary Data</h4>
|
||||
<p>
|
||||
10th percentile:{' '}
|
||||
{salaryData.national.national_PCT10
|
||||
? `$${salaryData.national.national_PCT10.toLocaleString()}`
|
||||
: 'N/A'}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Median:{' '}
|
||||
{salaryData.national.national_MEDIAN
|
||||
? `$${salaryData.national.national_MEDIAN.toLocaleString()}`
|
||||
: 'N/A'}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
90th percentile:{' '}
|
||||
{salaryData.national.national_PCT90
|
||||
@ -839,15 +911,12 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4) Milestone Timeline */}
|
||||
{/* 4) Career Goals */}
|
||||
<div className="bg-white p-4 rounded shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">Your Milestones</h3>
|
||||
<MilestoneTimeline
|
||||
careerProfileId={careerProfileId}
|
||||
authFetch={authFetch}
|
||||
activeView="Career"
|
||||
onMilestoneUpdated={() => {}}
|
||||
/>
|
||||
<h3 className="text-lg font-semibold mb-2">Your Career Goals</h3>
|
||||
<p className="text-gray-700">
|
||||
{scenarioRow?.career_goals || 'No career goals entered yet.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 5) Financial Projection */}
|
||||
@ -865,9 +934,7 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
tooltip: { mode: 'index', intersect: false },
|
||||
annotation: {
|
||||
annotations: allAnnotations
|
||||
}
|
||||
annotation: { annotations: allAnnotations }
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
@ -920,6 +987,41 @@ export default function MilestoneTracker({ selectedCareer: initialCareer }) {
|
||||
authFetch={authFetch}
|
||||
/>
|
||||
|
||||
{/* 7) AI Next Steps */}
|
||||
<div className="bg-white p-4 rounded shadow mt-4">
|
||||
<Button onClick={handleAiClick}>
|
||||
What Should I Do Next?
|
||||
</Button>
|
||||
{aiLoading && <p>Generating your next steps…</p>}
|
||||
|
||||
{/* If we have structured recs, show checkboxes */}
|
||||
{recommendations.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<h3 className="font-semibold">Select the Advice You Want to Keep</h3>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{recommendations.map((m) => (
|
||||
<li key={m.id} className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(m.id)}
|
||||
onChange={() => handleToggle(m.id)}
|
||||
/>
|
||||
<div className="flex flex-col text-left">
|
||||
<strong>{m.title}</strong>
|
||||
<span>{m.date}</span>
|
||||
<p className="text-sm">{m.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{selectedIds.length > 0 && (
|
||||
<Button className="mt-3" onClick={handleCreateSelectedMilestones}>
|
||||
Create Milestones from Selected
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -152,7 +152,16 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
<option value="prospective_student">Planning to Enroll (Prospective)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block font-medium">Career Goals</label>
|
||||
<textarea
|
||||
name="career_goals"
|
||||
value={data.career_goals || ''}
|
||||
onChange={handleChange}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="Tell us about your goals, aspirations, or next steps..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between pt-4">
|
||||
<button
|
||||
onClick={prevStep}
|
||||
|
@ -1,29 +1,115 @@
|
||||
// ReviewPage.js
|
||||
import React from 'react';
|
||||
// Hypothetical Button component from your UI library
|
||||
import { Button } from '../ui/button.js'; // Adjust path if needed
|
||||
|
||||
function ReviewPage({ careerData, financialData, collegeData, onSubmit, onBack }) {
|
||||
console.log("REVIEW PAGE PROPS:", {
|
||||
careerData,
|
||||
financialData,
|
||||
collegeData,
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<h2>Review Your Info</h2>
|
||||
/**
|
||||
* Helper to format numeric fields for display.
|
||||
* If val is null/undefined or 0 and you want to hide it,
|
||||
* you can adapt the logic below.
|
||||
*/
|
||||
function formatNum(val) {
|
||||
// If val is null or undefined, show 'N/A'
|
||||
if (val == null) return 'N/A';
|
||||
// If you'd like to hide zero, you could do:
|
||||
// if (val === 0) return 'N/A';
|
||||
return val;
|
||||
}
|
||||
|
||||
<h3>Career Info</h3>
|
||||
<pre>{JSON.stringify(careerData, null, 2)}</pre>
|
||||
function ReviewPage({
|
||||
careerData = {},
|
||||
financialData = {},
|
||||
collegeData = {},
|
||||
onSubmit,
|
||||
onBack
|
||||
}) {
|
||||
// Decide if user is in or planning to be in college
|
||||
const inOrPlanningCollege = (
|
||||
careerData.college_enrollment_status === 'currently_enrolled'
|
||||
|| careerData.college_enrollment_status === 'prospective_student'
|
||||
);
|
||||
|
||||
<h3>Financial Info</h3>
|
||||
<pre>{JSON.stringify(financialData, null, 2)}</pre>
|
||||
|
||||
<h3>College Info</h3>
|
||||
<pre>{JSON.stringify(collegeData, null, 2)}</pre>
|
||||
|
||||
<button onClick={onBack}>← Back</button>
|
||||
<button onClick={onSubmit} style={{ marginLeft: '1rem' }}>
|
||||
Submit All
|
||||
</button>
|
||||
return (
|
||||
<div className="max-w-xl mx-auto p-6 space-y-6">
|
||||
<h2 className="text-2xl font-semibold">Review Your Info</h2>
|
||||
|
||||
{/* --- CAREER SECTION --- */}
|
||||
<div className="p-4 border rounded-md space-y-2">
|
||||
<h3 className="text-xl font-semibold">Career Info</h3>
|
||||
<div><strong>Career Name:</strong> {careerData.career_name || 'N/A'}</div>
|
||||
<div><strong>Currently Working:</strong> {careerData.currently_working || 'N/A'}</div>
|
||||
<div><strong>Enrollment Status:</strong> {careerData.college_enrollment_status || 'N/A'}</div>
|
||||
<div><strong>Status:</strong> {careerData.status || 'N/A'}</div>
|
||||
<div><strong>Start Date:</strong> {careerData.start_date || 'N/A'}</div>
|
||||
<div><strong>Projected End Date:</strong> {careerData.projected_end_date || 'N/A'}</div>
|
||||
<div><strong>Career Goals:</strong> {careerData.career_goals || 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
{/* --- FINANCIAL SECTION --- */}
|
||||
<div className="p-4 border rounded-md space-y-2">
|
||||
<h3 className="text-xl font-semibold">Financial Info</h3>
|
||||
|
||||
{/* Current/Additional Income */}
|
||||
{careerData.currently_working === 'yes' ? (
|
||||
<div className="space-y-1">
|
||||
<div><strong>Current Annual Salary:</strong> {formatNum(financialData.current_salary)}</div>
|
||||
<div><strong>Additional Annual Income:</strong> {formatNum(financialData.additional_income)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div><strong>Currently Not Working</strong></div>
|
||||
)}
|
||||
|
||||
{/* Monthly Expenses / Debt */}
|
||||
<div className="mt-2 space-y-1">
|
||||
<div><strong>Monthly Expenses:</strong> {formatNum(financialData.monthly_expenses)}</div>
|
||||
<div><strong>Monthly Debt Payments:</strong> {formatNum(financialData.monthly_debt_payments)}</div>
|
||||
</div>
|
||||
|
||||
{/* Retirement */}
|
||||
<div className="mt-2 space-y-1">
|
||||
<div><strong>Retirement Savings:</strong> {formatNum(financialData.retirement_savings)}</div>
|
||||
<div><strong>Monthly Retirement Contribution:</strong> {formatNum(financialData.retirement_contribution)}</div>
|
||||
</div>
|
||||
|
||||
{/* Emergency Fund */}
|
||||
<div className="mt-2 space-y-1">
|
||||
<div><strong>Emergency Fund Savings:</strong> {formatNum(financialData.emergency_fund)}</div>
|
||||
<div><strong>Monthly Emergency Contribution:</strong> {formatNum(financialData.emergency_contribution)}</div>
|
||||
</div>
|
||||
|
||||
{/* Extra Monthly Cash Allocation */}
|
||||
<div className="mt-2 space-y-1">
|
||||
<div><strong>Extra Monthly Cash to Emergency (%):</strong> {formatNum(financialData.extra_cash_emergency_pct)}</div>
|
||||
<div><strong>Extra Monthly Cash to Retirement (%):</strong> {formatNum(financialData.extra_cash_retirement_pct)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- COLLEGE SECTION --- */}
|
||||
{inOrPlanningCollege && (
|
||||
<div className="p-4 border rounded-md space-y-2">
|
||||
<h3 className="text-xl font-semibold">College Info</h3>
|
||||
<div><strong>College Name:</strong> {collegeData.college_name || 'N/A'}</div>
|
||||
<div><strong>Major:</strong> {collegeData.major || 'N/A'}</div>
|
||||
{/* If you have these fields, show them if they're meaningful */}
|
||||
{collegeData.tuition != null && (
|
||||
<div><strong>Tuition (calculated):</strong> {formatNum(collegeData.tuition)}</div>
|
||||
)}
|
||||
{collegeData.program_length != null && (
|
||||
<div><strong>Program Length (years):</strong> {formatNum(collegeData.program_length)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- ACTION BUTTONS --- */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="secondary" onClick={onBack}>
|
||||
← Back
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onSubmit}>
|
||||
Submit All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user