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 (
{m.description}
Date: {m.date}
Progress: {m.progress}%
{/* tasks */} {(m.tasks || []).map((t) => (