1444 lines
50 KiB
JavaScript
1444 lines
50 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 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
|
||
<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} 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>
|
||
);
|
||
}
|