1122 lines
34 KiB
JavaScript
1122 lines
34 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 AISuggestedMilestones from './AISuggestedMilestones.js';
|
|
import ScenarioEditModal from './ScenarioEditModal.js';
|
|
|
|
// Register the annotation plugin globally
|
|
ChartJS.register(annotationPlugin);
|
|
|
|
export default function ScenarioContainer({
|
|
scenario,
|
|
financialProfile,
|
|
onRemove,
|
|
onClone,
|
|
onEdit
|
|
}) {
|
|
/*************************************************************
|
|
* 1) Scenario Dropdown
|
|
*************************************************************/
|
|
const [allScenarios, setAllScenarios] = useState([]);
|
|
const [localScenario, setLocalScenario] = useState(scenario || null);
|
|
|
|
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.careerPaths || []);
|
|
} 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);
|
|
}
|
|
|
|
/*************************************************************
|
|
* 2) College Profile + Milestones
|
|
*************************************************************/
|
|
const [collegeProfile, setCollegeProfile] = useState(null);
|
|
const [milestones, setMilestones] = useState([]);
|
|
const [impactsByMilestone, setImpactsByMilestone] = useState({});
|
|
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [editingScenarioData, setEditingScenarioData] = useState({
|
|
scenario: null,
|
|
collegeProfile: null
|
|
});
|
|
|
|
// load the college profile
|
|
useEffect(() => {
|
|
if (!localScenario?.id) {
|
|
setCollegeProfile(null);
|
|
return;
|
|
}
|
|
async function loadCollegeProfile() {
|
|
try {
|
|
const url = `/api/premium/college-profile?careerPathId=${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]);
|
|
|
|
// load milestones (and each milestone's impacts)
|
|
const fetchMilestones = useCallback(async () => {
|
|
if (!localScenario?.id) {
|
|
setMilestones([]);
|
|
setImpactsByMilestone({});
|
|
return;
|
|
}
|
|
try {
|
|
const res = await authFetch(
|
|
`/api/premium/milestones?careerPathId=${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);
|
|
} catch (err) {
|
|
console.error('Error fetching milestones:', err);
|
|
}
|
|
}, [localScenario?.id]);
|
|
|
|
useEffect(() => {
|
|
fetchMilestones();
|
|
}, [fetchMilestones]);
|
|
|
|
/*************************************************************
|
|
* 3) Run Simulation
|
|
*************************************************************/
|
|
const [projectionData, setProjectionData] = useState([]);
|
|
const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null);
|
|
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
|
|
|
|
useEffect(() => {
|
|
if (!financialProfile || !localScenario?.id || !collegeProfile) return;
|
|
|
|
// gather all milestoneImpacts
|
|
let allImpacts = [];
|
|
Object.keys(impactsByMilestone).forEach((mId) => {
|
|
allImpacts = allImpacts.concat(impactsByMilestone[mId]);
|
|
});
|
|
|
|
const simYears = parseInt(simulationYearsInput, 10) || 20;
|
|
|
|
// Merge scenario + user financial + college + milestone
|
|
const mergedProfile = {
|
|
currentSalary: financialProfile.current_salary || 0,
|
|
monthlyExpenses:
|
|
localScenario.planned_monthly_expenses ??
|
|
financialProfile.monthly_expenses ??
|
|
0,
|
|
monthlyDebtPayments:
|
|
localScenario.planned_monthly_debt_payments ??
|
|
financialProfile.monthly_debt_payments ??
|
|
0,
|
|
retirementSavings: financialProfile.retirement_savings ?? 0,
|
|
emergencySavings: financialProfile.emergency_fund ?? 0,
|
|
monthlyRetirementContribution:
|
|
localScenario.planned_monthly_retirement_contribution ??
|
|
financialProfile.retirement_contribution ??
|
|
0,
|
|
monthlyEmergencyContribution:
|
|
localScenario.planned_monthly_emergency_contribution ??
|
|
financialProfile.emergency_contribution ??
|
|
0,
|
|
surplusEmergencyAllocation:
|
|
localScenario.planned_surplus_emergency_pct ??
|
|
financialProfile.extra_cash_emergency_pct ??
|
|
50,
|
|
surplusRetirementAllocation:
|
|
localScenario.planned_surplus_retirement_pct ??
|
|
financialProfile.extra_cash_retirement_pct ??
|
|
50,
|
|
additionalIncome:
|
|
localScenario.planned_additional_income ??
|
|
financialProfile.additional_income ??
|
|
0,
|
|
|
|
// college
|
|
studentLoanAmount: collegeProfile.existing_college_debt || 0,
|
|
interestRate: collegeProfile.interest_rate || 5,
|
|
loanTerm: collegeProfile.loan_term || 10,
|
|
loanDeferralUntilGraduation: !!collegeProfile.loan_deferral_until_graduation,
|
|
academicCalendar: collegeProfile.academic_calendar || 'monthly',
|
|
annualFinancialAid: collegeProfile.annual_financial_aid || 0,
|
|
calculatedTuition: collegeProfile.tuition || 0,
|
|
extraPayment: collegeProfile.extra_payment || 0,
|
|
inCollege:
|
|
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
|
|
collegeProfile.college_enrollment_status === 'prospective_student',
|
|
gradDate: collegeProfile.expected_graduation || null,
|
|
programType: collegeProfile.program_type || null,
|
|
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
|
|
hoursCompleted: collegeProfile.hours_completed || 0,
|
|
programLength: collegeProfile.program_length || 0,
|
|
expectedSalary:
|
|
collegeProfile.expected_salary || financialProfile.current_salary || 0,
|
|
|
|
// scenario horizon
|
|
startDate: localScenario.start_date || new Date().toISOString(),
|
|
simulationYears: simYears,
|
|
|
|
milestoneImpacts: allImpacts
|
|
};
|
|
|
|
const { projectionData, loanPaidOffMonth } =
|
|
simulateFinancialProjection(mergedProfile);
|
|
|
|
let cumulative = mergedProfile.emergencySavings || 0;
|
|
const finalData = projectionData.map((monthRow) => {
|
|
cumulative += monthRow.netSavings || 0;
|
|
return { ...monthRow, cumulativeNetSavings: cumulative };
|
|
});
|
|
|
|
setProjectionData(finalData);
|
|
setLoanPaidOffMonth(loanPaidOffMonth);
|
|
}, [
|
|
financialProfile,
|
|
localScenario,
|
|
collegeProfile,
|
|
impactsByMilestone,
|
|
simulationYearsInput
|
|
]);
|
|
|
|
function handleSimulationYearsChange(e) {
|
|
setSimulationYearsInput(e.target.value);
|
|
}
|
|
function handleSimulationYearsBlur() {
|
|
if (!simulationYearsInput.trim()) {
|
|
setSimulationYearsInput('20');
|
|
}
|
|
}
|
|
|
|
/*************************************************************
|
|
* 4) Chart + Annotations
|
|
*************************************************************/
|
|
const chartLabels = projectionData.map((p) => p.month);
|
|
|
|
const netSavingsData = projectionData.map((p) => p.cumulativeNetSavings || 0);
|
|
const retData = projectionData.map((p) => p.retirementSavings || 0);
|
|
const loanData = projectionData.map((p) => p.loanBalance || 0);
|
|
|
|
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'
|
|
},
|
|
milestoneObj: m,
|
|
onClick: () => handleEditMilestone(m)
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const chartData = {
|
|
labels: chartLabels,
|
|
datasets: [
|
|
{
|
|
label: 'Net Savings',
|
|
data: netSavingsData,
|
|
borderColor: 'blue',
|
|
fill: false
|
|
},
|
|
{
|
|
label: 'Retirement',
|
|
data: retData,
|
|
borderColor: 'green',
|
|
fill: false
|
|
},
|
|
{
|
|
label: 'Loan',
|
|
data: loanData,
|
|
borderColor: 'red',
|
|
fill: false
|
|
}
|
|
]
|
|
};
|
|
|
|
const chartOptions = {
|
|
responsive: true,
|
|
scales: {
|
|
x: { type: 'category' },
|
|
y: { title: { display: true, text: 'Amount ($)' } }
|
|
},
|
|
plugins: {
|
|
annotation: {
|
|
annotations: milestoneAnnotations
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (context) =>
|
|
`${context.dataset.label}: ${context.formattedValue}`
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/*************************************************************
|
|
* 5) MILESTONE CRUD
|
|
*************************************************************/
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editingMilestone, setEditingMilestone] = useState(null);
|
|
const [newMilestone, setNewMilestone] = useState({
|
|
title: '',
|
|
description: '',
|
|
date: '',
|
|
progress: 0,
|
|
newSalary: '',
|
|
impacts: [],
|
|
isUniversal: 0
|
|
});
|
|
const [impactsToDelete, setImpactsToDelete] = useState([]);
|
|
|
|
// tasks
|
|
const [showTaskForm, setShowTaskForm] = useState(null);
|
|
const [editingTask, setEditingTask] = useState({
|
|
id: null,
|
|
title: '',
|
|
description: '',
|
|
due_date: ''
|
|
});
|
|
|
|
// copy wizard
|
|
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
|
|
|
function handleNewMilestone() {
|
|
setEditingMilestone(null);
|
|
setNewMilestone({
|
|
title: '',
|
|
description: '',
|
|
date: '',
|
|
progress: 0,
|
|
newSalary: '',
|
|
impacts: [],
|
|
isUniversal: 0
|
|
});
|
|
setImpactsToDelete([]);
|
|
setShowForm(true);
|
|
}
|
|
|
|
async function handleEditMilestone(m) {
|
|
if (!localScenario?.id) return;
|
|
setEditingMilestone(m);
|
|
setImpactsToDelete([]);
|
|
|
|
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 || [];
|
|
setNewMilestone({
|
|
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
|
|
});
|
|
setShowForm(true);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading milestone impacts:', err);
|
|
}
|
|
}
|
|
|
|
function addNewImpact() {
|
|
setNewMilestone((prev) => ({
|
|
...prev,
|
|
impacts: [
|
|
...prev.impacts,
|
|
{
|
|
impact_type: 'ONE_TIME',
|
|
direction: 'subtract',
|
|
amount: 0,
|
|
start_date: '',
|
|
end_date: ''
|
|
}
|
|
]
|
|
}));
|
|
}
|
|
|
|
function removeImpact(idx) {
|
|
setNewMilestone((prev) => {
|
|
const copy = [...prev.impacts];
|
|
const removed = copy[idx];
|
|
if (removed && removed.id) {
|
|
setImpactsToDelete((old) => [...old, removed.id]);
|
|
}
|
|
copy.splice(idx, 1);
|
|
return { ...prev, impacts: copy };
|
|
});
|
|
}
|
|
|
|
function updateImpact(idx, field, value) {
|
|
setNewMilestone((prev) => {
|
|
const copy = [...prev.impacts];
|
|
copy[idx] = { ...copy[idx], [field]: value };
|
|
return { ...prev, impacts: copy };
|
|
});
|
|
}
|
|
|
|
async function saveMilestone() {
|
|
if (!localScenario?.id) return;
|
|
|
|
const url = editingMilestone
|
|
? `/api/premium/milestones/${editingMilestone.id}`
|
|
: `/api/premium/milestone`;
|
|
const method = editingMilestone ? 'PUT' : 'POST';
|
|
|
|
const payload = {
|
|
milestone_type: 'Financial',
|
|
title: newMilestone.title,
|
|
description: newMilestone.description,
|
|
date: newMilestone.date,
|
|
career_path_id: localScenario.id,
|
|
progress: newMilestone.progress,
|
|
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
|
|
new_salary: newMilestone.newSalary
|
|
? parseFloat(newMilestone.newSalary)
|
|
: null,
|
|
is_universal: newMilestone.isUniversal || 0
|
|
};
|
|
|
|
try {
|
|
const res = await authFetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!res.ok) {
|
|
const errData = await res.json();
|
|
alert(errData.error || 'Error saving milestone');
|
|
return;
|
|
}
|
|
const savedMilestone = await res.json();
|
|
|
|
// handle impacts
|
|
for (const id of impactsToDelete) {
|
|
await authFetch(`/api/premium/milestone-impacts/${id}`, {
|
|
method: 'DELETE'
|
|
});
|
|
}
|
|
for (let i = 0; i < newMilestone.impacts.length; i++) {
|
|
const imp = newMilestone.impacts[i];
|
|
const impPayload = {
|
|
milestone_id: savedMilestone.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)
|
|
});
|
|
}
|
|
}
|
|
|
|
// re-fetch
|
|
await fetchMilestones();
|
|
|
|
// reset form
|
|
setShowForm(false);
|
|
setEditingMilestone(null);
|
|
setNewMilestone({
|
|
title: '',
|
|
description: '',
|
|
date: '',
|
|
progress: 0,
|
|
newSalary: '',
|
|
impacts: [],
|
|
isUniversal: 0
|
|
});
|
|
setImpactsToDelete([]);
|
|
} catch (err) {
|
|
console.error('Error saving milestone:', err);
|
|
alert('Failed to save milestone');
|
|
}
|
|
}
|
|
|
|
async function handleDeleteMilestone(m) {
|
|
if (m.is_universal === 1) {
|
|
const userChoice = window.confirm(
|
|
'Universal milestone. OK => remove from ALL scenarios, or Cancel => just remove from this scenario.'
|
|
);
|
|
if (userChoice) {
|
|
try {
|
|
await authFetch(`/api/premium/milestones/${m.id}/all`, {
|
|
method: 'DELETE'
|
|
});
|
|
} catch (err) {
|
|
console.error('Error removing universal milestone from all:', err);
|
|
}
|
|
} else {
|
|
await deleteSingleMilestone(m);
|
|
}
|
|
} else {
|
|
await deleteSingleMilestone(m);
|
|
}
|
|
await fetchMilestones();
|
|
}
|
|
|
|
async function deleteSingleMilestone(m) {
|
|
try {
|
|
await authFetch(`/api/premium/milestones/${m.id}`, { method: 'DELETE' });
|
|
} catch (err) {
|
|
console.error('Error removing milestone:', err);
|
|
}
|
|
}
|
|
|
|
/*************************************************************
|
|
* 6) TASK CRUD
|
|
*************************************************************/
|
|
// handle both new and existing tasks
|
|
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
|
|
};
|
|
|
|
// If we have editingTask.id => PUT, else => POST
|
|
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);
|
|
}
|
|
}
|
|
|
|
// scenario-level editing
|
|
function handleEditScenario() {
|
|
if (!localScenario) return;
|
|
setEditingScenarioData({
|
|
scenario: localScenario,
|
|
collegeProfile
|
|
});
|
|
setShowEditModal(true);
|
|
}
|
|
|
|
function handleDeleteScenario() {
|
|
if (localScenario) onRemove(localScenario.id);
|
|
}
|
|
function handleCloneScenario() {
|
|
if (localScenario) onClone(localScenario);
|
|
}
|
|
|
|
/*************************************************************
|
|
* 7) COPY WIZARD
|
|
*************************************************************/
|
|
function CopyMilestoneWizard({ milestone, scenarios, onClose }) {
|
|
const [selectedScenarios, setSelectedScenarios] = useState([]);
|
|
|
|
if (!milestone) return null;
|
|
|
|
function toggleScenario(id) {
|
|
setSelectedScenarios((prev) =>
|
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
|
);
|
|
}
|
|
|
|
async function handleCopy() {
|
|
try {
|
|
const res = await authFetch('/api/premium/milestone/copy', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
milestoneId: milestone.id,
|
|
scenarioIds: selectedScenarios
|
|
})
|
|
});
|
|
if (!res.ok) throw new Error('Failed to copy milestone');
|
|
onClose();
|
|
window.location.reload();
|
|
} catch (err) {
|
|
console.error('Error copying milestone:', err);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100vw',
|
|
height: '100vh',
|
|
background: 'rgba(0,0,0,0.4)',
|
|
zIndex: 9999
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
background: '#fff',
|
|
width: '400px',
|
|
padding: '1rem',
|
|
margin: '100px auto',
|
|
borderRadius: '4px'
|
|
}}
|
|
>
|
|
<h4>Copy Milestone to Other Scenarios</h4>
|
|
<p>
|
|
Milestone: <strong>{milestone.title}</strong>
|
|
</p>
|
|
{scenarios.map((s) => (
|
|
<div key={s.id}>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedScenarios.includes(s.id)}
|
|
onChange={() => toggleScenario(s.id)}
|
|
/>
|
|
{s.scenario_title || s.career_name || '(untitled)'}
|
|
</label>
|
|
</div>
|
|
))}
|
|
<div style={{ marginTop: '1rem' }}>
|
|
<Button onClick={onClose} style={{ marginRight: '0.5rem' }}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleCopy}>Copy</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/*************************************************************
|
|
* 8) RENDER
|
|
*************************************************************/
|
|
return (
|
|
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
|
|
{/* scenario dropdown */}
|
|
<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>
|
|
<p>
|
|
Status: {localScenario.status} <br />
|
|
Start: {localScenario.start_date} <br />
|
|
End: {localScenario.projected_end_date}
|
|
</p>
|
|
|
|
<div style={{ margin: '0.5rem 0' }}>
|
|
<label>Simulation Length (years): </label>
|
|
<input
|
|
type="text"
|
|
style={{ width: '3rem' }}
|
|
value={simulationYearsInput}
|
|
onChange={handleSimulationYearsChange}
|
|
onBlur={handleSimulationYearsBlur}
|
|
/>
|
|
</div>
|
|
|
|
{/* The line chart */}
|
|
<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={handleNewMilestone} style={{ marginTop: '0.5rem' }}>
|
|
+ New Milestone
|
|
</Button>
|
|
|
|
<AISuggestedMilestones
|
|
career={
|
|
localScenario.career_name || localScenario.scenario_title || ''
|
|
}
|
|
careerPathId={localScenario.id}
|
|
authFetch={authFetch}
|
|
activeView="Financial"
|
|
projectionData={projectionData}
|
|
/>
|
|
|
|
<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>
|
|
|
|
{/* The milestone form */}
|
|
{showForm && (
|
|
<div className="form border p-2 my-2">
|
|
<h4>
|
|
{editingMilestone ? 'Edit Milestone' : 'New Milestone'}
|
|
</h4>
|
|
|
|
<input
|
|
type="text"
|
|
placeholder="Title"
|
|
value={newMilestone.title}
|
|
onChange={(e) =>
|
|
setNewMilestone({ ...newMilestone, title: e.target.value })
|
|
}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Description"
|
|
value={newMilestone.description}
|
|
onChange={(e) =>
|
|
setNewMilestone({
|
|
...newMilestone,
|
|
description: e.target.value
|
|
})
|
|
}
|
|
/>
|
|
<input
|
|
type="date"
|
|
placeholder="Milestone Date"
|
|
value={newMilestone.date}
|
|
onChange={(e) =>
|
|
setNewMilestone({ ...newMilestone, date: e.target.value })
|
|
}
|
|
/>
|
|
<input
|
|
type="number"
|
|
placeholder="Progress (%)"
|
|
value={
|
|
newMilestone.progress === 0 ? '' : newMilestone.progress
|
|
}
|
|
onChange={(e) =>
|
|
setNewMilestone((prev) => ({
|
|
...prev,
|
|
progress: parseInt(e.target.value || '0', 10)
|
|
}))
|
|
}
|
|
/>
|
|
|
|
{/* Impacts sub-form */}
|
|
<div
|
|
style={{
|
|
border: '1px solid #ccc',
|
|
padding: '1rem',
|
|
marginTop: '1rem'
|
|
}}
|
|
>
|
|
<h5>Financial Impacts</h5>
|
|
{newMilestone.impacts.map((imp, idx) => (
|
|
<div
|
|
key={idx}
|
|
style={{
|
|
border: '1px solid #bbb',
|
|
margin: '0.5rem',
|
|
padding: '0.5rem'
|
|
}}
|
|
>
|
|
{imp.id && (
|
|
<p style={{ fontSize: '0.8rem' }}>ID: {imp.id}</p>
|
|
)}
|
|
<div>
|
|
<label>Type: </label>
|
|
<select
|
|
value={imp.impact_type}
|
|
onChange={(e) =>
|
|
updateImpact(idx, 'impact_type', e.target.value)
|
|
}
|
|
>
|
|
<option value="ONE_TIME">One-Time</option>
|
|
<option value="MONTHLY">Monthly</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label>Direction: </label>
|
|
<select
|
|
value={imp.direction}
|
|
onChange={(e) =>
|
|
updateImpact(idx, 'direction', e.target.value)
|
|
}
|
|
>
|
|
<option value="add">Add (Income)</option>
|
|
<option value="subtract">Subtract (Expense)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label>Amount: </label>
|
|
<input
|
|
type="number"
|
|
value={imp.amount}
|
|
onChange={(e) =>
|
|
updateImpact(idx, 'amount', e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label>Start Date: </label>
|
|
<input
|
|
type="date"
|
|
value={imp.start_date || ''}
|
|
onChange={(e) =>
|
|
updateImpact(idx, 'start_date', e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
{imp.impact_type === 'MONTHLY' && (
|
|
<div>
|
|
<label>End Date (blank indefinite): </label>
|
|
<input
|
|
type="date"
|
|
value={imp.end_date || ''}
|
|
onChange={(e) =>
|
|
updateImpact(idx, 'end_date', e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
<Button
|
|
style={{ marginLeft: '0.5rem', color: 'red' }}
|
|
onClick={() => removeImpact(idx)}
|
|
>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button onClick={addNewImpact}>+ Add Impact</Button>
|
|
</div>
|
|
|
|
<div style={{ marginTop: '1rem' }}>
|
|
<Button onClick={saveMilestone}>
|
|
{editingMilestone ? 'Update' : 'Add'} Milestone
|
|
</Button>
|
|
<Button
|
|
style={{ marginLeft: '0.5rem' }}
|
|
onClick={() => {
|
|
setShowForm(false);
|
|
setEditingMilestone(null);
|
|
setNewMilestone({
|
|
title: '',
|
|
description: '',
|
|
date: '',
|
|
progress: 0,
|
|
newSalary: '',
|
|
impacts: [],
|
|
isUniversal: 0
|
|
});
|
|
setImpactsToDelete([]);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Render existing milestones */}
|
|
{milestones.map((m) => {
|
|
const tasks = m.tasks || [];
|
|
return (
|
|
<div
|
|
key={m.id}
|
|
style={{
|
|
border: '1px solid #ccc',
|
|
marginTop: '1rem',
|
|
padding: '0.5rem'
|
|
}}
|
|
>
|
|
<h5>{m.title}</h5>
|
|
{m.description && <p>{m.description}</p>}
|
|
<p>
|
|
<strong>Date:</strong> {m.date} — <strong>Progress:</strong> {m.progress}%
|
|
</p>
|
|
|
|
{/* tasks list */}
|
|
{tasks.length > 0 && (
|
|
<ul>
|
|
{tasks.map((t) => (
|
|
<li key={t.id}>
|
|
<strong>{t.title}</strong>
|
|
{t.description ? ` - ${t.description}` : ''}
|
|
{t.due_date ? ` (Due: ${t.due_date})` : ''}{' '}
|
|
<Button
|
|
style={{ marginLeft: '0.5rem' }}
|
|
onClick={() => handleEditTask(m.id, t)}
|
|
>
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
style={{ marginLeft: '0.5rem', color: 'red' }}
|
|
onClick={() => deleteTask(t.id)}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<Button
|
|
onClick={() => handleAddTask(m.id)}
|
|
style={{ marginRight: '0.5rem' }}
|
|
>
|
|
+ Task
|
|
</Button>
|
|
<Button onClick={() => handleEditMilestone(m)}>Edit</Button>
|
|
<Button
|
|
style={{ marginLeft: '0.5rem' }}
|
|
onClick={() => setCopyWizardMilestone(m)}
|
|
>
|
|
Copy
|
|
</Button>
|
|
<Button
|
|
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
|
|
onClick={() => handleDeleteMilestone(m)}
|
|
>
|
|
Delete
|
|
</Button>
|
|
|
|
{/* The "Add/Edit Task" form if showTaskForm === this milestone */}
|
|
{showTaskForm === m.id && (
|
|
<div
|
|
style={{
|
|
marginTop: '0.5rem',
|
|
border: '1px solid #aaa',
|
|
padding: '0.5rem'
|
|
}}
|
|
>
|
|
<h5>{editingTask.id ? 'Edit Task' : 'New Task'}</h5>
|
|
<input
|
|
type="text"
|
|
placeholder="Task Title"
|
|
value={editingTask.title}
|
|
onChange={(e) =>
|
|
setEditingTask({ ...editingTask, title: e.target.value })
|
|
}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Task Description"
|
|
value={editingTask.description}
|
|
onChange={(e) =>
|
|
setEditingTask({
|
|
...editingTask,
|
|
description: e.target.value
|
|
})
|
|
}
|
|
/>
|
|
<input
|
|
type="date"
|
|
value={editingTask.due_date}
|
|
onChange={(e) =>
|
|
setEditingTask({
|
|
...editingTask,
|
|
due_date: e.target.value
|
|
})
|
|
}
|
|
/>
|
|
<Button onClick={() => saveTask(m.id)}>
|
|
{editingTask.id ? 'Update' : 'Add'} Task
|
|
</Button>
|
|
<Button
|
|
style={{ marginLeft: '0.5rem' }}
|
|
onClick={() => {
|
|
setShowTaskForm(null);
|
|
setEditingTask({
|
|
id: null,
|
|
title: '',
|
|
description: '',
|
|
due_date: ''
|
|
});
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Scenario edit modal */}
|
|
<ScenarioEditModal
|
|
show={showEditModal}
|
|
onClose={() => setShowEditModal(false)}
|
|
scenario={editingScenarioData.scenario}
|
|
collegeProfile={editingScenarioData.collegeProfile}
|
|
/>
|
|
|
|
{/* Copy wizard */}
|
|
{copyWizardMilestone && (
|
|
<CopyMilestoneWizard
|
|
milestone={copyWizardMilestone}
|
|
scenarios={allScenarios}
|
|
onClose={() => setCopyWizardMilestone(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|