1332 lines
45 KiB
JavaScript
1332 lines
45 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { Line } from 'react-chartjs-2';
|
|
import { Chart as ChartJS } from 'chart.js';
|
|
import annotationPlugin from 'chartjs-plugin-annotation';
|
|
|
|
import { Button } from './ui/button.js';
|
|
import authFetch from '../utils/authFetch.js';
|
|
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
|
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
|
|
import ScenarioEditModal from './ScenarioEditModal.js';
|
|
import MilestoneCopyWizard from './MilestoneCopyWizard.js';
|
|
|
|
ChartJS.register(annotationPlugin);
|
|
|
|
export default function ScenarioContainer({
|
|
scenario,
|
|
financialProfile,
|
|
onRemove,
|
|
onClone
|
|
}) {
|
|
// -------------------------------------------------------------
|
|
// 1) States
|
|
// -------------------------------------------------------------
|
|
const [allScenarios, setAllScenarios] = useState([]);
|
|
const [localScenario, setLocalScenario] = useState(scenario || null);
|
|
|
|
const [showScenarioModal, setShowScenarioModal] = useState(false);
|
|
|
|
// Data from DB for college + milestones
|
|
const [collegeProfile, setCollegeProfile] = useState(null);
|
|
const [milestones, setMilestones] = useState([]);
|
|
const [impactsByMilestone, setImpactsByMilestone] = useState({});
|
|
|
|
// Projection
|
|
const [projectionData, setProjectionData] = useState([]);
|
|
const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null);
|
|
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
|
|
|
// Interest
|
|
const [interestStrategy, setInterestStrategy] = useState('NONE');
|
|
const [flatAnnualRate, setFlatAnnualRate] = useState(0.06);
|
|
const [randomRangeMin, setRandomRangeMin] = useState(-0.02);
|
|
const [randomRangeMax, setRandomRangeMax] = useState(0.02);
|
|
|
|
// The “milestone modal” for editing
|
|
const [showMilestoneModal, setShowMilestoneModal] = useState(false);
|
|
|
|
// Tasks
|
|
const [showTaskForm, setShowTaskForm] = useState(null);
|
|
const [editingTask, setEditingTask] = useState({
|
|
id: null,
|
|
title: '',
|
|
description: '',
|
|
due_date: ''
|
|
});
|
|
|
|
// “Inline editing” of existing milestone
|
|
const [editingMilestoneId, setEditingMilestoneId] = useState(null);
|
|
const [newMilestoneMap, setNewMilestoneMap] = useState({});
|
|
const [impactsToDeleteMap, setImpactsToDeleteMap] = useState({});
|
|
|
|
// For brand-new milestone creation
|
|
const [addingNewMilestone, setAddingNewMilestone] = useState(false);
|
|
const [newMilestoneData, setNewMilestoneData] = useState({
|
|
title: '',
|
|
description: '',
|
|
date: '',
|
|
progress: 0,
|
|
newSalary: '',
|
|
impacts: [], // same structure as your “impacts” arrays
|
|
isUniversal: 0
|
|
});
|
|
|
|
// The “Copy Wizard”
|
|
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
|
|
|
// -------------------------------------------------------------
|
|
// 2) Load scenario list
|
|
// -------------------------------------------------------------
|
|
useEffect(() => {
|
|
async function loadScenarios() {
|
|
try {
|
|
const res = await authFetch('/api/premium/career-profile/all');
|
|
if (!res.ok) {
|
|
throw new Error(`Failed fetching scenario list: ${res.status}`);
|
|
}
|
|
const data = await res.json();
|
|
setAllScenarios(data.careerProfiles || []);
|
|
} catch (err) {
|
|
console.error('Error loading allScenarios for dropdown:', err);
|
|
}
|
|
}
|
|
loadScenarios();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setLocalScenario(scenario || null);
|
|
}, [scenario]);
|
|
|
|
function handleScenarioSelect(e) {
|
|
const chosenId = e.target.value;
|
|
const found = allScenarios.find((s) => s.id === chosenId);
|
|
setLocalScenario(found || null);
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 3) College + Milestones
|
|
// -------------------------------------------------------------
|
|
useEffect(() => {
|
|
if (!localScenario?.id) {
|
|
setCollegeProfile(null);
|
|
return;
|
|
}
|
|
async function loadCollegeProfile() {
|
|
try {
|
|
const url = `/api/premium/college-profile?careerProfileId=${localScenario.id}`;
|
|
const res = await authFetch(url);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setCollegeProfile(Array.isArray(data) ? data[0] || {} : data);
|
|
} else {
|
|
setCollegeProfile({});
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading collegeProfile:', err);
|
|
setCollegeProfile({});
|
|
}
|
|
}
|
|
loadCollegeProfile();
|
|
}, [localScenario]);
|
|
|
|
const fetchMilestones = useCallback(async () => {
|
|
if (!localScenario?.id) {
|
|
setMilestones([]);
|
|
setImpactsByMilestone({});
|
|
return;
|
|
}
|
|
try {
|
|
const res = await authFetch(
|
|
`/api/premium/milestones?careerProfileId=${localScenario.id}`
|
|
);
|
|
if (!res.ok) {
|
|
console.error('Failed fetching milestones. Status:', res.status);
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
const mils = data.milestones || [];
|
|
setMilestones(mils);
|
|
|
|
// For each milestone => fetch impacts
|
|
const impactsData = {};
|
|
for (const m of mils) {
|
|
const iRes = await authFetch(
|
|
`/api/premium/milestone-impacts?milestone_id=${m.id}`
|
|
);
|
|
if (iRes.ok) {
|
|
const iData = await iRes.json();
|
|
impactsData[m.id] = iData.impacts || [];
|
|
} else {
|
|
impactsData[m.id] = [];
|
|
}
|
|
}
|
|
setImpactsByMilestone(impactsData);
|
|
|
|
// reset new creation & inline editing states
|
|
setAddingNewMilestone(false);
|
|
setNewMilestoneData({
|
|
title: '',
|
|
description: '',
|
|
date: '',
|
|
progress: 0,
|
|
newSalary: '',
|
|
impacts: [],
|
|
isUniversal: 0
|
|
});
|
|
setEditingMilestoneId(null);
|
|
setNewMilestoneMap({});
|
|
setImpactsToDeleteMap({});
|
|
|
|
} catch (err) {
|
|
console.error('Error fetching milestones:', err);
|
|
}
|
|
}, [localScenario?.id]);
|
|
|
|
useEffect(() => {
|
|
fetchMilestones();
|
|
}, [fetchMilestones]);
|
|
|
|
// -------------------------------------------------------------
|
|
// 4) Simulation
|
|
// -------------------------------------------------------------
|
|
useEffect(() => {
|
|
if (!financialProfile || !localScenario?.id || !collegeProfile) return;
|
|
|
|
function parseScenarioOverride(overrideVal, fallbackVal) {
|
|
if (overrideVal == null) return fallbackVal;
|
|
const parsed = parseFloat(overrideVal);
|
|
return Number.isNaN(parsed) ? fallbackVal : parsed;
|
|
}
|
|
|
|
// Build financial base
|
|
const financialBase = {
|
|
currentSalary: parseFloatOrZero(financialProfile.current_salary, 0),
|
|
monthlyExpenses: parseFloatOrZero(financialProfile.monthly_expenses, 0),
|
|
monthlyDebtPayments: parseFloatOrZero(financialProfile.monthly_debt_payments, 0),
|
|
retirementSavings: parseFloatOrZero(financialProfile.retirement_savings, 0),
|
|
emergencySavings: parseFloatOrZero(financialProfile.emergency_fund, 0),
|
|
retirementContribution: parseFloatOrZero(financialProfile.retirement_contribution, 0),
|
|
emergencyContribution: parseFloatOrZero(financialProfile.emergency_contribution, 0),
|
|
extraCashEmergencyPct: parseFloatOrZero(financialProfile.extra_cash_emergency_pct, 50),
|
|
extraCashRetirementPct: parseFloatOrZero(financialProfile.extra_cash_retirement_pct, 50)
|
|
};
|
|
|
|
// Gather milestoneImpacts
|
|
let allImpacts = [];
|
|
Object.keys(impactsByMilestone).forEach((mId) => {
|
|
allImpacts = allImpacts.concat(impactsByMilestone[mId]);
|
|
});
|
|
|
|
const simYears = parseInt(simulationYearsInput, 10) || 20;
|
|
|
|
// scenario overrides
|
|
const scenarioOverrides = {
|
|
monthlyExpenses: parseScenarioOverride(
|
|
localScenario.planned_monthly_expenses,
|
|
financialBase.monthlyExpenses
|
|
),
|
|
monthlyDebtPayments: parseScenarioOverride(
|
|
localScenario.planned_monthly_debt_payments,
|
|
financialBase.monthlyDebtPayments
|
|
),
|
|
monthlyRetirementContribution: parseScenarioOverride(
|
|
localScenario.planned_monthly_retirement_contribution,
|
|
financialBase.retirementContribution
|
|
),
|
|
monthlyEmergencyContribution: parseScenarioOverride(
|
|
localScenario.planned_monthly_emergency_contribution,
|
|
financialBase.emergencyContribution
|
|
),
|
|
surplusEmergencyAllocation: parseScenarioOverride(
|
|
localScenario.planned_surplus_emergency_pct,
|
|
financialBase.extraCashEmergencyPct
|
|
),
|
|
surplusRetirementAllocation: parseScenarioOverride(
|
|
localScenario.planned_surplus_retirement_pct,
|
|
financialBase.extraCashRetirementPct
|
|
),
|
|
additionalIncome: parseScenarioOverride(
|
|
localScenario.planned_additional_income,
|
|
0
|
|
)
|
|
};
|
|
|
|
// college
|
|
const c = collegeProfile;
|
|
const collegeData = {
|
|
studentLoanAmount: parseFloatOrZero(c.existing_college_debt, 0),
|
|
interestRate: parseFloatOrZero(c.interest_rate, 5),
|
|
loanTerm: parseFloatOrZero(c.loan_term, 10),
|
|
loanDeferralUntilGraduation: !!c.loan_deferral_until_graduation,
|
|
academicCalendar: c.academic_calendar || 'monthly',
|
|
annualFinancialAid: parseFloatOrZero(c.annual_financial_aid, 0),
|
|
calculatedTuition: parseFloatOrZero(c.tuition, 0),
|
|
extraPayment: parseFloatOrZero(c.extra_payment, 0),
|
|
inCollege:
|
|
c.college_enrollment_status === 'currently_enrolled' ||
|
|
c.college_enrollment_status === 'prospective_student',
|
|
gradDate: c.expected_graduation || null,
|
|
programType: c.program_type || null,
|
|
creditHoursPerYear: parseFloatOrZero(c.credit_hours_per_year, 0),
|
|
hoursCompleted: parseFloatOrZero(c.hours_completed, 0),
|
|
programLength: parseFloatOrZero(c.program_length, 0),
|
|
expectedSalary:
|
|
parseFloatOrZero(c.expected_salary) ||
|
|
parseFloatOrZero(financialProfile.current_salary, 0)
|
|
};
|
|
|
|
const mergedProfile = {
|
|
currentSalary: financialBase.currentSalary,
|
|
monthlyExpenses: scenarioOverrides.monthlyExpenses,
|
|
monthlyDebtPayments: scenarioOverrides.monthlyDebtPayments,
|
|
retirementSavings: financialBase.retirementSavings,
|
|
emergencySavings: financialBase.emergencySavings,
|
|
monthlyRetirementContribution: scenarioOverrides.monthlyRetirementContribution,
|
|
monthlyEmergencyContribution: scenarioOverrides.monthlyEmergencyContribution,
|
|
surplusEmergencyAllocation: scenarioOverrides.surplusEmergencyAllocation,
|
|
surplusRetirementAllocation: scenarioOverrides.surplusRetirementAllocation,
|
|
additionalIncome: scenarioOverrides.additionalIncome,
|
|
|
|
studentLoanAmount: collegeData.studentLoanAmount,
|
|
interestRate: collegeData.interestRate,
|
|
loanTerm: collegeData.loanTerm,
|
|
loanDeferralUntilGraduation: collegeData.loanDeferralUntilGraduation,
|
|
academicCalendar: collegeData.academicCalendar,
|
|
annualFinancialAid: collegeData.annualFinancialAid,
|
|
calculatedTuition: collegeData.calculatedTuition,
|
|
extraPayment: collegeData.extraPayment,
|
|
inCollege: collegeData.inCollege,
|
|
gradDate: collegeData.gradDate,
|
|
programType: collegeData.programType,
|
|
creditHoursPerYear: collegeData.creditHoursPerYear,
|
|
hoursCompleted: collegeData.hoursCompleted,
|
|
programLength: collegeData.programLength,
|
|
expectedSalary: collegeData.expectedSalary,
|
|
|
|
startDate: (localScenario.start_date || new Date().toISOString().slice(0,10)),
|
|
simulationYears: simYears,
|
|
|
|
milestoneImpacts: allImpacts,
|
|
|
|
interestStrategy,
|
|
flatAnnualRate,
|
|
randomRangeMin,
|
|
randomRangeMax
|
|
};
|
|
|
|
const { projectionData: pData, loanPaidOffMonth } =
|
|
simulateFinancialProjection(mergedProfile);
|
|
|
|
let cumulative = mergedProfile.emergencySavings || 0;
|
|
const finalData = pData.map((row) => {
|
|
cumulative += row.netSavings || 0;
|
|
return { ...row, cumulativeNetSavings: cumulative };
|
|
});
|
|
|
|
setProjectionData(finalData);
|
|
setLoanPaidOffMonth(loanPaidOffMonth);
|
|
}, [
|
|
financialProfile,
|
|
localScenario,
|
|
collegeProfile,
|
|
impactsByMilestone,
|
|
simulationYearsInput,
|
|
interestStrategy,
|
|
flatAnnualRate,
|
|
randomRangeMin,
|
|
randomRangeMax
|
|
]);
|
|
|
|
// -------------------------------------------------------------
|
|
// 5) Chart
|
|
// -------------------------------------------------------------
|
|
const chartLabels = projectionData.map((p) => p.month);
|
|
const hasStudentLoan = projectionData.some((p) => (p.loanBalance ?? 0) > 0);
|
|
|
|
const emergencyData = {
|
|
label: 'Emergency Savings',
|
|
data: projectionData.map((p) => p.emergencySavings || 0),
|
|
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 || 0),
|
|
borderColor: 'rgba(75, 192, 192, 1)',
|
|
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
|
tension: 0.4,
|
|
fill: true
|
|
};
|
|
const totalSavingsData = {
|
|
label: 'Total Savings',
|
|
data: projectionData.map((p) => p.totalSavings || 0),
|
|
borderColor: 'rgba(54, 162, 235, 1)',
|
|
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
|
tension: 0.4,
|
|
fill: true
|
|
};
|
|
const loanBalanceData = {
|
|
label: 'Loan Balance',
|
|
data: projectionData.map((p) => p.loanBalance || 0),
|
|
borderColor: 'rgba(255, 99, 132, 1)',
|
|
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
|
tension: 0.4,
|
|
fill: {
|
|
target: 'origin',
|
|
above: 'rgba(255,99,132,0.3)',
|
|
below: 'transparent'
|
|
}
|
|
};
|
|
|
|
const chartDatasets = [emergencyData, retirementData];
|
|
if (hasStudentLoan) chartDatasets.push(loanBalanceData);
|
|
chartDatasets.push(totalSavingsData);
|
|
|
|
const milestoneAnnotations = milestones
|
|
.map((m) => {
|
|
if (!m.date) return null;
|
|
const d = new Date(m.date);
|
|
if (isNaN(d)) return null;
|
|
|
|
const year = d.getUTCFullYear();
|
|
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
const short = `${year}-${month}`;
|
|
|
|
if (!chartLabels.includes(short)) return null;
|
|
|
|
return {
|
|
type: 'line',
|
|
xMin: short,
|
|
xMax: short,
|
|
borderColor: 'orange',
|
|
borderWidth: 2,
|
|
label: {
|
|
display: true,
|
|
content: m.title || 'Milestone',
|
|
color: 'orange',
|
|
position: 'end'
|
|
}
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const chartData = {
|
|
labels: chartLabels,
|
|
datasets: chartDatasets
|
|
};
|
|
|
|
const chartOptions = {
|
|
responsive: true,
|
|
plugins: {
|
|
annotation: { annotations: milestoneAnnotations },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (ctx) => `${ctx.dataset.label}: ${ctx.formattedValue}`
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: false,
|
|
ticks: {
|
|
callback: (val) => `$${val.toLocaleString()}`
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// -------------------------------------------------------------
|
|
// 6) Task CRUD
|
|
// -------------------------------------------------------------
|
|
function handleAddTask(milestoneId) {
|
|
setShowTaskForm(milestoneId);
|
|
setEditingTask({
|
|
id: null,
|
|
title: '',
|
|
description: '',
|
|
due_date: ''
|
|
});
|
|
}
|
|
|
|
function handleEditTask(milestoneId, task) {
|
|
setShowTaskForm(milestoneId);
|
|
setEditingTask({
|
|
id: task.id,
|
|
title: task.title,
|
|
description: task.description || '',
|
|
due_date: task.due_date || ''
|
|
});
|
|
}
|
|
|
|
async function saveTask(milestoneId) {
|
|
if (!editingTask.title.trim()) {
|
|
alert('Task needs a title');
|
|
return;
|
|
}
|
|
const payload = {
|
|
milestone_id: milestoneId,
|
|
title: editingTask.title,
|
|
description: editingTask.description,
|
|
due_date: editingTask.due_date
|
|
};
|
|
|
|
try {
|
|
let url = '/api/premium/tasks';
|
|
let method = 'POST';
|
|
if (editingTask.id) {
|
|
url = `/api/premium/tasks/${editingTask.id}`;
|
|
method = 'PUT';
|
|
}
|
|
const res = await authFetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!res.ok) {
|
|
alert('Failed to save task');
|
|
return;
|
|
}
|
|
await fetchMilestones();
|
|
setShowTaskForm(null);
|
|
setEditingTask({
|
|
id: null,
|
|
title: '',
|
|
description: '',
|
|
due_date: ''
|
|
});
|
|
} catch (err) {
|
|
console.error('Error saving task:', err);
|
|
}
|
|
}
|
|
|
|
async function deleteTask(taskId) {
|
|
if (!taskId) return;
|
|
try {
|
|
const res = await authFetch(`/api/premium/tasks/${taskId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
if (!res.ok) {
|
|
alert('Failed to delete task');
|
|
return;
|
|
}
|
|
await fetchMilestones();
|
|
} catch (err) {
|
|
console.error('Error deleting task:', err);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 7) Inline Milestone Editing
|
|
// -------------------------------------------------------------
|
|
function handleEditMilestoneInline(m) {
|
|
if (editingMilestoneId === m.id) {
|
|
setEditingMilestoneId(null);
|
|
return;
|
|
}
|
|
loadMilestoneImpacts(m);
|
|
}
|
|
|
|
async function loadMilestoneImpacts(m) {
|
|
try {
|
|
const impRes = await authFetch(
|
|
`/api/premium/milestone-impacts?milestone_id=${m.id}`
|
|
);
|
|
if (impRes.ok) {
|
|
const data = await impRes.json();
|
|
const fetchedImpacts = data.impacts || [];
|
|
setNewMilestoneMap((prev) => ({
|
|
...prev,
|
|
[m.id]: {
|
|
title: m.title || '',
|
|
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',
|
|
direction: imp.direction || 'subtract',
|
|
amount: imp.amount || 0,
|
|
start_date: imp.start_date || '',
|
|
end_date: imp.end_date || ''
|
|
})),
|
|
isUniversal: m.is_universal ? 1 : 0
|
|
}
|
|
}));
|
|
setEditingMilestoneId(m.id);
|
|
setImpactsToDeleteMap((prev) => ({ ...prev, [m.id]: [] }));
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading milestone impacts:', err);
|
|
}
|
|
}
|
|
|
|
function updateInlineImpact(milestoneId, idx, field, value) {
|
|
setNewMilestoneMap((prev) => {
|
|
const copy = { ...prev };
|
|
const item = copy[milestoneId];
|
|
if (!item) return prev;
|
|
const impactsClone = [...item.impacts];
|
|
impactsClone[idx] = { ...impactsClone[idx], [field]: value };
|
|
copy[milestoneId] = { ...item, impacts: impactsClone };
|
|
return copy;
|
|
});
|
|
}
|
|
|
|
function removeInlineImpact(milestoneId, idx) {
|
|
setNewMilestoneMap((prev) => {
|
|
const copy = { ...prev };
|
|
const item = copy[milestoneId];
|
|
if (!item) return prev;
|
|
const impactsClone = [...item.impacts];
|
|
const removed = impactsClone[idx];
|
|
impactsClone.splice(idx, 1);
|
|
|
|
setImpactsToDeleteMap((old) => {
|
|
const sub = old[milestoneId] || [];
|
|
if (removed.id) {
|
|
return { ...old, [milestoneId]: [...sub, removed.id] };
|
|
}
|
|
return old;
|
|
});
|
|
|
|
copy[milestoneId] = { ...item, impacts: impactsClone };
|
|
return copy;
|
|
});
|
|
}
|
|
|
|
function addInlineImpact(milestoneId) {
|
|
setNewMilestoneMap((prev) => {
|
|
const copy = { ...prev };
|
|
const item = copy[milestoneId];
|
|
if (!item) return prev;
|
|
const impactsClone = [...item.impacts];
|
|
impactsClone.push({
|
|
impact_type: 'ONE_TIME',
|
|
direction: 'subtract',
|
|
amount: 0,
|
|
start_date: '',
|
|
end_date: ''
|
|
});
|
|
copy[milestoneId] = { ...item, impacts: impactsClone };
|
|
return copy;
|
|
});
|
|
}
|
|
|
|
async function saveInlineMilestone(m) {
|
|
if (!localScenario?.id) return;
|
|
const data = newMilestoneMap[m.id];
|
|
const payload = {
|
|
milestone_type: 'Financial',
|
|
title: data.title,
|
|
description: data.description,
|
|
date: data.date,
|
|
career_profile_id: localScenario.id,
|
|
progress: data.progress,
|
|
status: data.progress >= 100 ? 'completed' : 'planned',
|
|
new_salary: data.newSalary ? parseFloat(data.newSalary) : null,
|
|
is_universal: data.isUniversal || 0
|
|
};
|
|
|
|
try {
|
|
const res = await authFetch(`/api/premium/milestones/${m.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!res.ok) {
|
|
const errMsg = await res.text();
|
|
alert(errMsg || 'Error saving milestone');
|
|
return;
|
|
}
|
|
const saved = await res.json();
|
|
|
|
// handle impacts
|
|
const milestoneId = m.id;
|
|
const toDelete = impactsToDeleteMap[milestoneId] || [];
|
|
for (const id of toDelete) {
|
|
await authFetch(`/api/premium/milestone-impacts/${id}`, { method: 'DELETE' });
|
|
}
|
|
for (let i = 0; i < data.impacts.length; i++) {
|
|
const imp = data.impacts[i];
|
|
const impPayload = {
|
|
milestone_id: saved.id,
|
|
impact_type: imp.impact_type,
|
|
direction: imp.direction,
|
|
amount: parseFloat(imp.amount) || 0,
|
|
start_date: imp.start_date || null,
|
|
end_date: imp.end_date || null
|
|
};
|
|
if (imp.id) {
|
|
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(impPayload)
|
|
});
|
|
} else {
|
|
await authFetch('/api/premium/milestone-impacts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(impPayload)
|
|
});
|
|
}
|
|
}
|
|
|
|
await fetchMilestones();
|
|
setEditingMilestoneId(null);
|
|
} catch (err) {
|
|
console.error('Error saving milestone:', err);
|
|
alert('Failed to save milestone');
|
|
}
|
|
}
|
|
|
|
async function handleDeleteMilestone(m) {
|
|
if (!localScenario?.id) return;
|
|
const confirmDel = window.confirm('Delete milestone?');
|
|
if (!confirmDel) return;
|
|
try {
|
|
const res = await authFetch(`/api/premium/milestones/${m.id}`, { method: 'DELETE' });
|
|
if (!res.ok) {
|
|
alert('Failed to delete milestone');
|
|
return;
|
|
}
|
|
await fetchMilestones();
|
|
} catch (err) {
|
|
console.error('Error deleting milestone:', err);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 8) BRAND NEW MILESTONE
|
|
// -------------------------------------------------------------
|
|
function addNewImpactToNewMilestone() {
|
|
setNewMilestoneData((prev) => ({
|
|
...prev,
|
|
impacts: [
|
|
...prev.impacts,
|
|
{
|
|
impact_type: 'ONE_TIME',
|
|
direction: 'subtract',
|
|
amount: 0,
|
|
start_date: '',
|
|
end_date: ''
|
|
}
|
|
]
|
|
}));
|
|
}
|
|
function removeImpactFromNewMilestone(idx) {
|
|
setNewMilestoneData((prev) => {
|
|
const copy = [...prev.impacts];
|
|
copy.splice(idx, 1);
|
|
return { ...prev, impacts: copy };
|
|
});
|
|
}
|
|
async function saveNewMilestone() {
|
|
if (!localScenario?.id) return;
|
|
if (!newMilestoneData.title.trim() || !newMilestoneData.date.trim()) {
|
|
alert('Need title and date');
|
|
return;
|
|
}
|
|
const payload = {
|
|
title: newMilestoneData.title,
|
|
description: newMilestoneData.description,
|
|
date: newMilestoneData.date,
|
|
career_profile_id: localScenario.id,
|
|
progress: newMilestoneData.progress,
|
|
status: newMilestoneData.progress >= 100 ? 'completed' : 'planned',
|
|
is_universal: newMilestoneData.isUniversal || 0
|
|
};
|
|
try {
|
|
// create milestone
|
|
const createRes = await authFetch('/api/premium/milestone', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!createRes.ok) {
|
|
const txt = await createRes.text();
|
|
alert(txt || 'Failed to create milestone');
|
|
return;
|
|
}
|
|
let created = await createRes.json(); // might be single object or array
|
|
|
|
if (Array.isArray(created) && created.length > 0) {
|
|
created = created[0];
|
|
} else if (!Array.isArray(created)) {
|
|
// single object
|
|
}
|
|
// handle impacts
|
|
if (newMilestoneData.impacts.length > 0 && created.id) {
|
|
for (const imp of newMilestoneData.impacts) {
|
|
const impPayload = {
|
|
milestone_id: created.id,
|
|
impact_type: imp.impact_type,
|
|
direction: imp.direction,
|
|
amount: parseFloat(imp.amount) || 0,
|
|
start_date: imp.start_date || null,
|
|
end_date: imp.end_date || null
|
|
};
|
|
await authFetch('/api/premium/milestone-impacts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(impPayload)
|
|
});
|
|
}
|
|
}
|
|
|
|
await fetchMilestones();
|
|
setAddingNewMilestone(false);
|
|
setNewMilestoneData({
|
|
title: '',
|
|
description: '',
|
|
date: '',
|
|
progress: 0,
|
|
newSalary: '',
|
|
impacts: [],
|
|
isUniversal: 0
|
|
});
|
|
} catch (err) {
|
|
console.error('Error creating new milestone =>', err);
|
|
alert('Error saving new milestone');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 9) Scenario Edit
|
|
// -------------------------------------------------------------
|
|
function handleEditScenario() {
|
|
setShowScenarioModal(true);
|
|
}
|
|
function handleScenarioSave(updated) {
|
|
console.log('TODO => Save scenario', updated);
|
|
setShowScenarioModal(false);
|
|
}
|
|
function handleDeleteScenario() {
|
|
if (localScenario) onRemove(localScenario.id);
|
|
}
|
|
function handleCloneScenario() {
|
|
if (localScenario) onClone(localScenario);
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// 10) Render
|
|
// -------------------------------------------------------------
|
|
return (
|
|
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
|
|
<select
|
|
style={{ marginBottom: '0.5rem', width: '100%' }}
|
|
value={localScenario?.id || ''}
|
|
onChange={handleScenarioSelect}
|
|
>
|
|
<option value="">-- Select a Scenario --</option>
|
|
{allScenarios.map((sc) => (
|
|
<option key={sc.id} value={sc.id}>
|
|
{sc.scenario_title || sc.career_name || 'Untitled'}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{localScenario && (
|
|
<>
|
|
<h4>{localScenario.scenario_title || localScenario.career_name}</h4>
|
|
|
|
<div style={{ margin: '0.5rem 0' }}>
|
|
<label>Simulation Length (years): </label>
|
|
<input
|
|
type="text"
|
|
style={{ width: '3rem' }}
|
|
value={simulationYearsInput}
|
|
onChange={(e) => setSimulationYearsInput(e.target.value)}
|
|
onBlur={() => {
|
|
if (!simulationYearsInput.trim()) {
|
|
setSimulationYearsInput('20');
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* interest strategy */}
|
|
<div style={{ margin: '0.5rem 0' }}>
|
|
<label style={{ marginRight: '0.5rem' }}>Apply Interest:</label>
|
|
<select
|
|
value={interestStrategy}
|
|
onChange={(e) => setInterestStrategy(e.target.value)}
|
|
>
|
|
<option value="NONE">No Interest</option>
|
|
<option value="FLAT">Flat Rate</option>
|
|
<option value="MONTE_CARLO">Random</option>
|
|
</select>
|
|
{interestStrategy === 'FLAT' && (
|
|
<div>
|
|
<label>Annual Rate (%):</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={flatAnnualRate}
|
|
onChange={(e) =>
|
|
setFlatAnnualRate(parseFloatOrZero(e.target.value, 0.06))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{interestStrategy === 'MONTE_CARLO' && (
|
|
<div>
|
|
<label>Min Return (%):</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={randomRangeMin}
|
|
onChange={(e) =>
|
|
setRandomRangeMin(parseFloatOrZero(e.target.value, -0.02))
|
|
}
|
|
/>
|
|
<label>Max Return (%):</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={randomRangeMax}
|
|
onChange={(e) =>
|
|
setRandomRangeMax(parseFloatOrZero(e.target.value, 0.02))
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Line data={chartData} options={chartOptions} />
|
|
|
|
{projectionData.length > 0 && (
|
|
<div style={{ marginTop: '0.5rem' }}>
|
|
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br />
|
|
<strong>Final Retirement:</strong>{' '}
|
|
{Math.round(
|
|
projectionData[projectionData.length - 1].retirementSavings
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Button onClick={() => setShowMilestoneModal(true)} style={{ marginTop: '0.5rem' }}>
|
|
Edit Milestones
|
|
</Button>
|
|
|
|
<div style={{ marginTop: '0.5rem' }}>
|
|
<Button onClick={handleEditScenario}>Edit</Button>
|
|
<Button onClick={handleCloneScenario} style={{ marginLeft: '0.5rem' }}>
|
|
Clone
|
|
</Button>
|
|
<Button
|
|
onClick={handleDeleteScenario}
|
|
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* scenario edit modal */}
|
|
<ScenarioEditModal
|
|
show={showScenarioModal}
|
|
onClose={() => setShowScenarioModal(false)}
|
|
scenario={localScenario}
|
|
onSave={handleScenarioSave}
|
|
/>
|
|
|
|
{/* The “Milestone Modal” => inline editing + new milestone + copy wizard */}
|
|
{showMilestoneModal && (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'rgba(0,0,0,0.4)',
|
|
zIndex: 9999,
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
justifyContent: 'center',
|
|
overflowY: 'auto'
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
background: '#fff',
|
|
width: '800px',
|
|
padding: '1rem',
|
|
margin: '2rem auto',
|
|
borderRadius: '4px'
|
|
}}
|
|
>
|
|
<h3>Edit Milestones</h3>
|
|
|
|
{milestones.map((m) => {
|
|
const hasEditOpen = editingMilestoneId === m.id;
|
|
const data = newMilestoneMap[m.id] || null;
|
|
|
|
return (
|
|
<div
|
|
key={m.id}
|
|
style={{
|
|
border: '1px solid #ccc',
|
|
padding: '0.5rem',
|
|
marginBottom: '1rem'
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<h5 style={{ margin: 0 }}>{m.title}</h5>
|
|
<Button onClick={() => handleEditMilestoneInline(m)}>
|
|
{hasEditOpen ? 'Cancel' : 'Edit'}
|
|
</Button>
|
|
</div>
|
|
<p>{m.description}</p>
|
|
<p>
|
|
<strong>Date:</strong> {m.date}
|
|
</p>
|
|
<p>Progress: {m.progress}%</p>
|
|
|
|
{/* tasks */}
|
|
{(m.tasks || []).map((t) => (
|
|
<div key={t.id}>
|
|
<strong>{t.title}</strong>{' '}
|
|
{t.description ? ` - ${t.description}` : ''}
|
|
{t.due_date ? ` (Due: ${t.due_date})` : ''}
|
|
<Button onClick={() => handleEditTask(m.id, t)}>Edit</Button>
|
|
<Button onClick={() => deleteTask(t.id)} style={{ color: 'red' }}>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button onClick={() => handleAddTask(m.id)}>+ Task</Button>
|
|
|
|
{/* The Copy button */}
|
|
<Button
|
|
onClick={() => setCopyWizardMilestone(m)}
|
|
style={{ marginLeft: '0.5rem' }}
|
|
>
|
|
Copy
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleDeleteMilestone(m)}
|
|
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
|
|
>
|
|
Delete
|
|
</Button>
|
|
|
|
{/* inline edit form */}
|
|
{hasEditOpen && data && (
|
|
<div
|
|
style={{
|
|
border: '1px solid #aaa',
|
|
marginTop: '1rem',
|
|
padding: '0.5rem'
|
|
}}
|
|
>
|
|
<h5>Edit Milestone: {m.title}</h5>
|
|
<input
|
|
type="text"
|
|
placeholder="Title"
|
|
style={{ display: 'block', marginBottom: '0.5rem' }}
|
|
value={data.title}
|
|
onChange={(e) => {
|
|
setNewMilestoneMap((prev) => ({
|
|
...prev,
|
|
[m.id]: { ...prev[m.id], title: e.target.value }
|
|
}));
|
|
}}
|
|
/>
|
|
<textarea
|
|
placeholder="Description"
|
|
style={{ display: 'block', width: '100%', marginBottom: '0.5rem' }}
|
|
value={data.description}
|
|
onChange={(e) => {
|
|
setNewMilestoneMap((prev) => ({
|
|
...prev,
|
|
[m.id]: { ...prev[m.id], description: e.target.value }
|
|
}));
|
|
}}
|
|
/>
|
|
<label>Date:</label>
|
|
<input
|
|
type="date"
|
|
style={{ display: 'block', marginBottom: '0.5rem' }}
|
|
value={data.date || ''}
|
|
onChange={(e) => {
|
|
setNewMilestoneMap((prev) => ({
|
|
...prev,
|
|
[m.id]: { ...prev[m.id], date: e.target.value }
|
|
}));
|
|
}}
|
|
/>
|
|
<label>Progress:</label>
|
|
<input
|
|
type="number"
|
|
style={{ display: 'block', marginBottom: '0.5rem' }}
|
|
value={data.progress}
|
|
onChange={(e) => {
|
|
const val = parseInt(e.target.value || '0', 10);
|
|
setNewMilestoneMap((prev) => ({
|
|
...prev,
|
|
[m.id]: { ...prev[m.id], progress: val }
|
|
}));
|
|
}}
|
|
/>
|
|
|
|
{/* impacts */}
|
|
<div
|
|
style={{
|
|
border: '1px solid #ccc',
|
|
padding: '0.5rem',
|
|
marginBottom: '0.5rem'
|
|
}}
|
|
>
|
|
<h6>Impacts:</h6>
|
|
{data.impacts.map((imp, idx) => (
|
|
<div
|
|
key={idx}
|
|
style={{ border: '1px solid #bbb', margin: '0.5rem 0', padding: '0.3rem' }}
|
|
>
|
|
<label>Type:</label>
|
|
<select
|
|
value={imp.impact_type}
|
|
onChange={(e) =>
|
|
updateInlineImpact(m.id, idx, 'impact_type', e.target.value)
|
|
}
|
|
>
|
|
<option value="ONE_TIME">One-Time</option>
|
|
<option value="MONTHLY">Monthly</option>
|
|
</select>
|
|
|
|
<label>Direction:</label>
|
|
<select
|
|
value={imp.direction}
|
|
onChange={(e) =>
|
|
updateInlineImpact(m.id, idx, 'direction', e.target.value)
|
|
}
|
|
>
|
|
<option value="add">Add (Income)</option>
|
|
<option value="subtract">Subtract (Expense)</option>
|
|
</select>
|
|
|
|
<label>Amount:</label>
|
|
<input
|
|
type="number"
|
|
value={imp.amount}
|
|
onChange={(e) =>
|
|
updateInlineImpact(m.id, idx, 'amount', e.target.value)
|
|
}
|
|
/>
|
|
|
|
<label>Start Date:</label>
|
|
<input
|
|
type="date"
|
|
value={imp.start_date || ''}
|
|
onChange={(e) =>
|
|
updateInlineImpact(m.id, idx, 'start_date', e.target.value)
|
|
}
|
|
/>
|
|
|
|
{imp.impact_type === 'MONTHLY' && (
|
|
<>
|
|
<label>End Date:</label>
|
|
<input
|
|
type="date"
|
|
value={imp.end_date || ''}
|
|
onChange={(e) =>
|
|
updateInlineImpact(m.id, idx, 'end_date', e.target.value)
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<Button
|
|
onClick={() => removeInlineImpact(m.id, idx)}
|
|
style={{ color: 'red', marginLeft: '0.5rem' }}
|
|
>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button onClick={() => addInlineImpact(m.id)}>+ Add Impact</Button>
|
|
</div>
|
|
|
|
<Button onClick={() => saveInlineMilestone(m)}>Save</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* The toggle for brand-new milestone */}
|
|
<Button onClick={() => setAddingNewMilestone((p) => !p)}>
|
|
{addingNewMilestone ? 'Cancel New Milestone' : 'Add Milestone'}
|
|
</Button>
|
|
|
|
{/* If user is adding a new milestone */}
|
|
{addingNewMilestone && (
|
|
<div
|
|
style={{ border: '1px solid #aaa', padding: '0.5rem', marginTop: '0.5rem' }}
|
|
>
|
|
<h5>New Milestone</h5>
|
|
<input
|
|
type="text"
|
|
placeholder="Title"
|
|
style={{ display: 'block', marginBottom: '0.5rem' }}
|
|
value={newMilestoneData.title}
|
|
onChange={(e) =>
|
|
setNewMilestoneData((prev) => ({ ...prev, title: e.target.value }))
|
|
}
|
|
/>
|
|
<textarea
|
|
placeholder="Description"
|
|
style={{ display: 'block', width: '100%', marginBottom: '0.5rem' }}
|
|
value={newMilestoneData.description}
|
|
onChange={(e) =>
|
|
setNewMilestoneData((prev) => ({ ...prev, description: e.target.value }))
|
|
}
|
|
/>
|
|
<label>Date:</label>
|
|
<input
|
|
type="date"
|
|
style={{ display: 'block', marginBottom: '0.5rem' }}
|
|
value={newMilestoneData.date}
|
|
onChange={(e) =>
|
|
setNewMilestoneData((prev) => ({ ...prev, date: e.target.value }))
|
|
}
|
|
/>
|
|
<label>Progress:</label>
|
|
<input
|
|
type="number"
|
|
style={{ display: 'block', marginBottom: '0.5rem' }}
|
|
value={newMilestoneData.progress}
|
|
onChange={(e) =>
|
|
setNewMilestoneData((prev) => ({
|
|
...prev,
|
|
progress: parseInt(e.target.value || '0', 10)
|
|
}))
|
|
}
|
|
/>
|
|
|
|
{/* new impacts */}
|
|
<div style={{ border: '1px solid #ccc', padding: '0.5rem', marginBottom: '0.5rem' }}>
|
|
<h6>Impacts:</h6>
|
|
{newMilestoneData.impacts.map((imp, idx) => (
|
|
<div
|
|
key={idx}
|
|
style={{ border: '1px solid #bbb', margin: '0.5rem 0', padding: '0.3rem' }}
|
|
>
|
|
<label>Type:</label>
|
|
<select
|
|
value={imp.impact_type}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setNewMilestoneData((prev) => {
|
|
const copy = [...prev.impacts];
|
|
copy[idx] = { ...copy[idx], impact_type: val };
|
|
return { ...prev, impacts: copy };
|
|
});
|
|
}}
|
|
>
|
|
<option value="ONE_TIME">One-Time</option>
|
|
<option value="MONTHLY">Monthly</option>
|
|
</select>
|
|
|
|
<label>Direction:</label>
|
|
<select
|
|
value={imp.direction}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setNewMilestoneData((prev) => {
|
|
const copy = [...prev.impacts];
|
|
copy[idx] = { ...copy[idx], direction: val };
|
|
return { ...prev, impacts: copy };
|
|
});
|
|
}}
|
|
>
|
|
<option value="add">Add (Income)</option>
|
|
<option value="subtract">Subtract (Expense)</option>
|
|
</select>
|
|
|
|
<label>Amount:</label>
|
|
<input
|
|
type="number"
|
|
value={imp.amount}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setNewMilestoneData((prev) => {
|
|
const copy = [...prev.impacts];
|
|
copy[idx] = { ...copy[idx], amount: val };
|
|
return { ...prev, impacts: copy };
|
|
});
|
|
}}
|
|
/>
|
|
|
|
<label>Start Date:</label>
|
|
<input
|
|
type="date"
|
|
value={imp.start_date || ''}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setNewMilestoneData((prev) => {
|
|
const copy = [...prev.impacts];
|
|
copy[idx] = { ...copy[idx], start_date: val };
|
|
return { ...prev, impacts: copy };
|
|
});
|
|
}}
|
|
/>
|
|
|
|
{imp.impact_type === 'MONTHLY' && (
|
|
<>
|
|
<label>End Date:</label>
|
|
<input
|
|
type="date"
|
|
value={imp.end_date || ''}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setNewMilestoneData((prev) => {
|
|
const copy = [...prev.impacts];
|
|
copy[idx] = { ...copy[idx], end_date: val };
|
|
return { ...prev, impacts: copy };
|
|
});
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
<Button
|
|
onClick={() => removeImpactFromNewMilestone(idx)}
|
|
style={{ color: 'red', marginLeft: '0.5rem' }}
|
|
>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button onClick={addNewImpactToNewMilestone}>+ Add Impact</Button>
|
|
</div>
|
|
|
|
<Button onClick={saveNewMilestone}>Add Milestone</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* The Copy Wizard */}
|
|
{copyWizardMilestone && (
|
|
<MilestoneCopyWizard
|
|
milestone={copyWizardMilestone}
|
|
onClose={(didCopy) => {
|
|
setCopyWizardMilestone(null);
|
|
if (didCopy) {
|
|
// re-fetch
|
|
fetchMilestones();
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<div style={{ marginTop: '1rem', textAlign: 'right' }}>
|
|
<Button onClick={() => setShowMilestoneModal(false)}>Close</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|