dev1/src/components/ScenarioContainer.js

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>
);
}