dev1/src/components/ScenarioContainer.js

1444 lines
50 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 moment from 'moment';
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);
/* ---------- currency helper (whole-file scope) ---------- */
const usd = (val) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}).format(val ?? 0);
export default function ScenarioContainer({
scenario,
financialProfile,
baselineYears,
onRemove,
onClone,
onSelect,
onSimDone
}) {
// -------------------------------------------------------------
// 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('50');
const [readinessScore, setReadinessScore] = useState(null);
const [retireBalAtMilestone, setRetireBalAtMilestone] = useState(0);
const [yearsCovered, setYearsCovered] = useState(0)
// 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
const allImpacts = Object.values(impactsByMilestone).flat(); // safe even if []
const simYears = parseInt(simulationYearsInput, 10) || 20;
const simYearsUI = Math.max(1, parseInt(simulationYearsInput, 10) || 20);
const yearsUntilRet = localScenario.retirement_start_date
? Math.ceil(
moment(localScenario.retirement_start_date)
.startOf('month')
.diff(moment().startOf('month'), 'months') / 12
)
: 0;
const simYearsEngine = Math.max(simYearsUI, yearsUntilRet + 1);
// 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,
retirement_start_date:
localScenario.retirement_start_date // user picked
|| (localScenario.projected_end_date // often set for college scenarios
? moment(localScenario.projected_end_date)
.startOf('month')
.add(1,'month') // start drawing a month later
.format('YYYY-MM-DD')
: null),
desired_retirement_income_monthly:
parseScenarioOverride(
localScenario.desired_retirement_income_monthly,
scenarioOverrides.monthlyExpenses // ← fallback to current spend
),
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: simYearsEngine,
milestoneImpacts: allImpacts,
interestStrategy,
flatAnnualRate,
randomRangeMin,
randomRangeMax
};
const { projectionData: pData, loanPaidOffMonth, readinessScore:simReadiness, retirementAtMilestone, yearsCovered: yc } =
simulateFinancialProjection(mergedProfile);
const sliceTo = simYearsUI * 12;
let cumulative = mergedProfile.emergencySavings || 0;
const finalData = pData.map(row => {
cumulative += row.netSavings || 0;
return { ...row, cumulativeNetSavings: cumulative };
}).slice(0, sliceTo);
if (typeof onSimDone === 'function') {
onSimDone(localScenario.id, yc);
}
setProjectionData(finalData);
setLoanPaidOffMonth(loanPaidOffMonth);
setReadinessScore(simReadiness);
setRetireBalAtMilestone(retirementAtMilestone);
setYearsCovered(yc);
}, [
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
.filter((m) => // <-- filter FIRST
m.title === "Retirement" ||
(impactsByMilestone[m.id] ?? []).length > 0
)
.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: m.title === 'Retirement' ? 'black' : 'orange',
borderWidth: 2,
label: {
display: true,
content: m.title || 'Milestone',
backgroundColor: m.title === 'Retirement' ? 'black' : 'orange',
color: 'white',
position: 'end',
padding: 4
}
};
})
.filter(Boolean);
const chartData = {
labels: chartLabels,
datasets: chartDatasets
};
const chartOptions={
responsive:true,
maintainAspectRatio:false,
plugins:{
legend:{display:false},
annotation:{annotations:milestoneAnnotations},
tooltip:{callbacks:{label:(ctx)=>`${ctx.dataset.label}: $${ctx.formattedValue}`}}
},
scales:{
x:{ticks:{maxTicksLimit:10,callback:(v)=>chartLabels[v]?.slice(0,7)}},
y:{ticks:{callback:(v)=>`$${v.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 (
<article
onClick={() => onSelect(localScenario.id)}
className="w-full md:max-w-md border p-3 pb-4 rounded bg-white
hover:shadow transition-shadow"
>
{/* ───────────────── Scenario Picker ───────────────── */}
<select
className="mb-2 w-full"
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 && (
<>
{/* ───────────── Title ───────────── */}
<h4
className="font-semibold text-lg leading-tight truncate"
title={localScenario.scenario_title || localScenario.career_name}
>
{localScenario.scenario_title || localScenario.career_name}
</h4>
{/* ───────────── Sim length & interest controls (unchanged) ───────────── */}
<div className="my-2 text-sm">
<label>Simulation (yrs): </label>
<input
type="text"
className="w-12 border rounded text-center"
value={simulationYearsInput}
onChange={e => setSimulationYearsInput(e.target.value)}
onBlur={() => !simulationYearsInput.trim() && setSimulationYearsInput('20')}
/>
</div>
<div className="my-2 text-sm">
<label className="mr-2">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' && (
<span className="ml-2">
<label className="mr-1">Rate %:</label>
<input
type="number"
step="0.01"
className="w-16 border rounded text-center"
value={flatAnnualRate}
onChange={e =>
setFlatAnnualRate(parseFloatOrZero(e.target.value, 0.06))
}
/>
</span>
)}
{interestStrategy === 'MONTE_CARLO' && (
<span className="ml-2 space-x-1">
<label>Min %:</label>
<input
type="number"
step="0.01"
className="w-14 border rounded text-center"
value={randomRangeMin}
onChange={e =>
setRandomRangeMin(parseFloatOrZero(e.target.value, -0.02))
}
/>
<label>Max %:</label>
<input
type="number"
step="0.01"
className="w-14 border rounded text-center"
value={randomRangeMax}
onChange={e =>
setRandomRangeMax(parseFloatOrZero(e.target.value, 0.02))
}
/>
</span>
)}
</div>
{/* ───────────── Chart ───────────── */}
<div className="relative h-56 sm:h-64 md:h-72 my-4 px-1">
<Line data={chartData} options={chartOptions} />
</div>
{(!localScenario?.retirement_start_date ||
!localScenario?.desired_retirement_income_monthly) && (
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-3 rounded mb-3 text-sm">
<p className="text-gray-800">
Add a retirement date and spending goal to see&nbsp;
<em>Money Lasts</em>.
</p>
<Button
size="sm"
className="mt-1"
onClick={() => setShowScenarioModal(true)}
>
Edit Scenario
</Button>
</div>
)}
{/* ───────────── KPI Bar ───────────── */}
{projectionData.length > 0 && (
<div className="space-y-1 text-sm">
{/* Nest-egg */}
<p className="uppercase text-gray-500 text-[11px] tracking-wide">Nest Egg</p>
<p className="text-lg font-semibold">{usd(retireBalAtMilestone)}</p>
{/* Money lasts */}
<p className="uppercase text-gray-500 text-[11px] tracking-wide mt-2">Money Lasts</p>
<p className="text-lg font-semibold inline-flex items-center">
{yearsCovered > 0 ? (
<>
{yearsCovered}&nbsp;yrs
{ baselineYears != null && yearsCovered != null && baselineYears !== yearsCovered && (
<span
className={
'ml-1 text-sm font-bold ' +
(yearsCovered > baselineYears ? 'text-green-600' : 'text-red-600')
}
>
{yearsCovered > baselineYears ? '▲' : '▼'}
{Math.abs(yearsCovered - baselineYears)}
</span>
)}
</>
) : (
<span title="Set a retirement-income goal to see how long the money lasts"></span>
)}
</p>
{/* Loan payoff only when relevant */}
{hasStudentLoan && loanPaidOffMonth && (
<>
<p className="uppercase text-gray-500 text-[11px] tracking-wide mt-2">
Loan Paid Off
</p>
<p className="text-lg">{loanPaidOffMonth}</p>
</>
)}
</div>
)}
{/* ───────────── Buttons ───────────── */}
<div className="flex flex-wrap gap-2">
<Button onClick={() => setShowMilestoneModal(true)}>
Milestones
</Button>
<Button onClick={handleEditScenario}>Edit</Button>
<Button onClick={handleCloneScenario}>Clone</Button>
<Button
onClick={handleDeleteScenario}
style={{ background: 'red', color: 'black' }}
>
Delete
</Button>
</div>
</>
)}
{/* scenario edit modal */}
<ScenarioEditModal
show={showScenarioModal}
onClose={() => setShowScenarioModal(false)}
scenario={localScenario}
collegeProfile={collegeProfile}
financialProfile={financialProfile}
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>
)}
</article>
);
}