Fixed UI for REtirement
This commit is contained in:
parent
838ad3f249
commit
6d7e3aa08c
@ -1,48 +1,169 @@
|
||||
export async function applyOps(opsObj, { req, userId, scenarioId }) {
|
||||
if (!Array.isArray(opsObj?.milestones)) return [];
|
||||
/**
|
||||
* applyOps – execute a fenced ```ops``` block returned by Jess.
|
||||
* Supports milestones, tasks, impacts, scenario utilities, and college profile.
|
||||
*
|
||||
* @param {object} opsObj – parsed JSON inside ```ops```
|
||||
* @param {object} req – Express request (for auth header)
|
||||
* @param {string} scenarioId – current career_profile_id (optional but lets us
|
||||
* auto-fill when the bot forgets)
|
||||
* @return {string[]} – human-readable confirmations
|
||||
*/
|
||||
export async function applyOps(opsObj = {}, req, scenarioId = null) {
|
||||
if (!Array.isArray(opsObj?.milestones) && !Array.isArray(opsObj?.tasks)
|
||||
&& !Array.isArray(opsObj?.impacts) && !Array.isArray(opsObj?.scenarios)
|
||||
&& !opsObj.collegeProfile) return [];
|
||||
|
||||
const apiBase = process.env.APTIVA_INTERNAL_API || 'http://localhost:5002/api';
|
||||
const auth = (p, o = {}) => internalFetch(req, `${apiBase}${p}`, o);
|
||||
const apiBase = process.env.APTIVA_INTERNAL_API || 'http://localhost:5002/api';
|
||||
const auth = (p, o = {}) =>
|
||||
internalFetch(req, `${apiBase}${p}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...(o.headers || {}) },
|
||||
...o
|
||||
});
|
||||
|
||||
const confirmations = [];
|
||||
|
||||
for (const m of opsObj.milestones) {
|
||||
/* ────────────────────────────────────────────────────
|
||||
1. MILESTONE-LEVEL OPS (unchanged behaviour)
|
||||
──────────────────────────────────────────────────── */
|
||||
for (const m of opsObj.milestones || []) {
|
||||
const op = (m?.op || '').toUpperCase();
|
||||
|
||||
/* ---------- DELETE ---------- */
|
||||
if (op === 'DELETE' && m.id) {
|
||||
const cleanId = m.id.trim();
|
||||
const r = await auth(`/premium/milestones/${cleanId}`, { method: 'DELETE' });
|
||||
if (r.ok) confirmations.push(`Deleted milestone ${cleanId}`);
|
||||
if (op === 'DELETE' && m.id) { // single-scenario delete
|
||||
const r = await auth(`/premium/milestones/${m.id.trim()}`, { method: 'DELETE' });
|
||||
if (r.ok) confirmations.push(`Deleted milestone ${m.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* ---------- UPDATE ---------- */
|
||||
if (op === 'UPDATE' && m.id && m.patch) {
|
||||
const r = await auth(`/premium/milestones/${m.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(m.patch),
|
||||
method: 'PUT', body: JSON.stringify(m.patch)
|
||||
});
|
||||
if (r.ok) confirmations.push(`Updated milestone ${m.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* ---------- CREATE ---------- */
|
||||
if (op === 'CREATE' && m.data) {
|
||||
// inject career_profile_id if the bot forgot it
|
||||
m.data.career_profile_id = m.data.career_profile_id || scenarioId;
|
||||
const r = await auth('/premium/milestone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(m.data),
|
||||
});
|
||||
const r = await auth('/premium/milestone', { method: 'POST', body: JSON.stringify(m.data) });
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
const newId = Array.isArray(j) ? j[0]?.id : j.id;
|
||||
confirmations.push(`Created milestone ${newId || '(new)'}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'DELETEALL' && m.id) { // delete across every scenario
|
||||
const r = await auth(`/premium/milestones/${m.id}/all`, { method: 'DELETE' });
|
||||
if (r.ok) confirmations.push(`Deleted milestone ${m.id} from all scenarios`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'COPY' && m.id && Array.isArray(m.targetScenarioIds)) {
|
||||
const r = await auth('/premium/milestone/copy', {
|
||||
method: 'POST',
|
||||
body : JSON.stringify({ milestoneId: m.id, scenarioIds: m.targetScenarioIds })
|
||||
});
|
||||
if (r.ok) confirmations.push(`Copied milestone ${m.id} → ${m.targetScenarioIds.length} scenario(s)`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────
|
||||
2. TASK-LEVEL OPS
|
||||
──────────────────────────────────────────────────── */
|
||||
for (const t of opsObj.tasks || []) {
|
||||
const op = (t?.op || '').toUpperCase();
|
||||
|
||||
if (op === 'CREATE' && t.data && t.data.milestone_id) {
|
||||
await auth('/premium/tasks', { method: 'POST', body: JSON.stringify(t.data) });
|
||||
confirmations.push(`Added task to milestone ${t.data.milestone_id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'UPDATE' && t.taskId && t.patch) {
|
||||
await auth(`/premium/tasks/${t.taskId}`, { method: 'PUT', body: JSON.stringify(t.patch) });
|
||||
confirmations.push(`Updated task ${t.taskId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'DELETE' && t.taskId) {
|
||||
await auth(`/premium/tasks/${t.taskId}`, { method: 'DELETE' });
|
||||
confirmations.push(`Deleted task ${t.taskId}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────
|
||||
3. IMPACT-LEVEL OPS
|
||||
──────────────────────────────────────────────────── */
|
||||
for (const imp of opsObj.impacts || []) {
|
||||
const op = (imp?.op || '').toUpperCase();
|
||||
|
||||
if (op === 'CREATE' && imp.data && imp.data.milestone_id) {
|
||||
await auth('/premium/milestone-impacts', { method: 'POST', body: JSON.stringify(imp.data) });
|
||||
confirmations.push(`Added impact to milestone ${imp.data.milestone_id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'UPDATE' && imp.impactId && imp.patch) {
|
||||
await auth(`/premium/milestone-impacts/${imp.impactId}`, { method: 'PUT', body: JSON.stringify(imp.patch) });
|
||||
confirmations.push(`Updated impact ${imp.impactId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'DELETE' && imp.impactId) {
|
||||
await auth(`/premium/milestone-impacts/${imp.impactId}`, { method: 'DELETE' });
|
||||
confirmations.push(`Deleted impact ${imp.impactId}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────
|
||||
4. SCENARIO (career_profile) OPS
|
||||
──────────────────────────────────────────────────── */
|
||||
for (const s of opsObj.scenarios || []) {
|
||||
const op = (s?.op || '').toUpperCase();
|
||||
|
||||
if (op === 'CREATE' && s.data?.career_name) {
|
||||
await auth('/premium/career-profile', { method: 'POST', body: JSON.stringify(s.data) });
|
||||
confirmations.push(`Created scenario “${s.data.career_name}”`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'UPDATE' && s.scenarioId && s.patch) {
|
||||
/* if only goals are patched, hit the goals route; otherwise create a PUT route */
|
||||
const hasOnlyGoals = Object.keys(s.patch).length === 1 && s.patch.career_goals !== undefined;
|
||||
const url = hasOnlyGoals
|
||||
? `/premium/career-profile/${s.scenarioId}/goals`
|
||||
: `/premium/career-profile`; // <-- add generic PATCH if you implemented one
|
||||
await auth(url.replace(/\/$/, `/${s.scenarioId}`), { method: 'PUT', body: JSON.stringify(s.patch) });
|
||||
confirmations.push(`Updated scenario ${s.scenarioId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'DELETE' && s.scenarioId) {
|
||||
await auth(`/premium/career-profile/${s.scenarioId}`, { method: 'DELETE' });
|
||||
confirmations.push(`Deleted scenario ${s.scenarioId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'CLONE' && s.sourceId) {
|
||||
await auth('/premium/career-profile/clone', { method: 'POST', body: JSON.stringify({
|
||||
sourceId : s.sourceId,
|
||||
overrides : s.overrides || {}
|
||||
})});
|
||||
confirmations.push(`Cloned scenario ${s.sourceId}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────
|
||||
5. COLLEGE PROFILE (single op per block)
|
||||
──────────────────────────────────────────────────── */
|
||||
if (opsObj.collegeProfile?.op?.toUpperCase() === 'UPSERT' && opsObj.collegeProfile.data) {
|
||||
await auth('/premium/college-profile', { method: 'POST', body: JSON.stringify(opsObj.collegeProfile.data) });
|
||||
confirmations.push('Saved college profile');
|
||||
}
|
||||
|
||||
return confirmations;
|
||||
|
26
src/components/ReadinessPill.js
Normal file
26
src/components/ReadinessPill.js
Normal file
@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import InfoTooltip from "./ui/infoTooltip.js";
|
||||
|
||||
/**
|
||||
* Compact badge that shows a 0-100 “readiness” score.
|
||||
*
|
||||
* Props:
|
||||
* • score (Number 0-100) – required
|
||||
*/
|
||||
export default function ReadinessPill({ score = 0 }) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(score)));
|
||||
|
||||
const bg =
|
||||
pct >= 80 ? "bg-green-600"
|
||||
: pct >= 60 ? "bg-yellow-500"
|
||||
: "bg-red-600";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`ml-2 inline-flex items-center gap-1 rounded-full px-2 py-px text-xs font-semibold text-white ${bg}`}
|
||||
>
|
||||
{pct}
|
||||
<InfoTooltip message="How long your portfolio can fund your desired spending, mapped onto a 30-year scale (100 ≈ ≥30 yrs)" />
|
||||
</span>
|
||||
);
|
||||
}
|
@ -1,349 +1,202 @@
|
||||
// src/components/RetirementPlanner.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
import ScenarioContainer from './ScenarioContainer.js';
|
||||
import { Button } from './ui/button.js';
|
||||
import RetirementChatBar from './RetirementChatBar.js';
|
||||
import RetirementChatBar from './RetirementChatBar.js';
|
||||
import ScenarioDiffDrawer from './ScenarioDiffDrawer.js';
|
||||
|
||||
export default function RetirementPlanner() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [financialProfile, setFinancialProfile] = useState(null);
|
||||
const [scenarios, setScenarios] = useState([]);
|
||||
const [diff , setDiff ] = useState(null);
|
||||
const [chatId, setChatId] = useState(null);
|
||||
const [selectedScenario, setSelectedScenario] = useState(null);
|
||||
|
||||
|
||||
const applyPatch = (id, patch) => {
|
||||
setScenarios(prev => {
|
||||
const base = prev.find(s => s.id === id);
|
||||
const next = prev.map(s => (s.id === id ? { ...s, ...patch } : s));
|
||||
setDiff({ base, patch });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
/* ------------------------------------------------------------------
|
||||
* tiny class‑name helper
|
||||
* ---------------------------------------------------------------- */
|
||||
const cn = (...cls) => cls.filter(Boolean).join(' ');
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* responsive helper – “mobile” = < 768px
|
||||
* ---------------------------------------------------------------- */
|
||||
function useIsMobile () {
|
||||
const [mobile, setMobile] = useState(() => window.innerWidth < 768);
|
||||
useEffect(() => {
|
||||
loadScenariosAndFinancial();
|
||||
const handler = () => setMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handler);
|
||||
return () => window.removeEventListener('resize', handler);
|
||||
}, []);
|
||||
return mobile;
|
||||
}
|
||||
|
||||
async function loadScenariosAndFinancial() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
/* ==================================================================
|
||||
* RetirementPlanner
|
||||
* ================================================================= */
|
||||
export default function RetirementPlanner () {
|
||||
/* ---------------------------- state ----------------------------- */
|
||||
const [loading, setLoading ] = useState(false);
|
||||
const [error, setError ] = useState(null);
|
||||
const [financialProfile, setFinancialProfile] = useState(null);
|
||||
const [scenarios, setScenarios] = useState([]);
|
||||
|
||||
const [selectedScenario, setSelectedScenario] = useState(null);
|
||||
const [chatOpen, setChatOpen] = useState(false); // slide‑in flag
|
||||
const [diff, setDiff] = useState(null);
|
||||
const [simYearsMap, setSimYearsMap] = useState({});
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
/* ----------------------- data loading -------------------------- */
|
||||
const loadAll = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true); setError(null);
|
||||
/* financial profile ------------------------------------------------ */
|
||||
const finRes = await authFetch('/api/premium/financial-profile');
|
||||
if (!finRes.ok) throw new Error(`FinancialProfile error: ${finRes.status}`);
|
||||
const finData = await finRes.json();
|
||||
if (!finRes.ok) throw new Error(`Financial profile error (${finRes.status})`);
|
||||
const finJson = await finRes.json();
|
||||
|
||||
const scenRes = await authFetch('/api/premium/career-profile/all');
|
||||
if (!scenRes.ok) throw new Error(`Scenarios error: ${scenRes.status}`);
|
||||
const scenData = await scenRes.json();
|
||||
/* scenarios -------------------------------------------------------- */
|
||||
const scRes = await authFetch('/api/premium/career-profile/all');
|
||||
if (!scRes.ok) throw new Error(`Scenario error (${scRes.status})`);
|
||||
const scJson = await scRes.json();
|
||||
|
||||
setFinancialProfile(finData);
|
||||
setScenarios(scenData.careerProfiles || []);
|
||||
} catch (err) {
|
||||
console.error('RetirementPlanner =>', err);
|
||||
setError(err.message || 'Failed to load');
|
||||
setFinancialProfile(finJson);
|
||||
setScenarios(scJson.careerProfiles || []);
|
||||
} catch (e) {
|
||||
console.error('RetirementPlanner → loadAll', e);
|
||||
setError(e.message || 'Failed to load');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleAddScenario() {
|
||||
useEffect(() => { loadAll(); }, [loadAll]);
|
||||
|
||||
/* ------------------ scenario CRUD helpers ---------------------- */
|
||||
async function handleAddScenario () {
|
||||
try {
|
||||
const body = {
|
||||
career_name: 'New Scenario ' + new Date().toLocaleDateString(),
|
||||
status: 'planned',
|
||||
// slice(0,10) to avoid timestamps
|
||||
start_date: new Date().toISOString().slice(0, 10),
|
||||
college_enrollment_status: 'not_enrolled',
|
||||
currently_working: 'no'
|
||||
career_name : `New Scenario ${new Date().toLocaleDateString()}`,
|
||||
status : 'planned',
|
||||
start_date : new Date().toISOString().slice(0, 10),
|
||||
college_enrollment_status : 'not_enrolled',
|
||||
currently_working : 'no'
|
||||
};
|
||||
const r = await authFetch('/api/premium/career-profile', {
|
||||
method: 'POST',
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
body : JSON.stringify(body)
|
||||
});
|
||||
if (!r.ok) throw new Error(`Add scenario error => ${r.status}`);
|
||||
await loadScenariosAndFinancial();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
alert(`Add scenario failed (${e.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloneScenario(oldScenario) {
|
||||
try {
|
||||
// convert oldScenario.start_date to just YYYY-MM-DD
|
||||
const cloneStart = oldScenario.start_date
|
||||
? oldScenario.start_date.slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
|
||||
const scenarioPayload = {
|
||||
scenario_title: oldScenario.scenario_title
|
||||
? oldScenario.scenario_title + ' (Copy)'
|
||||
: 'Untitled (Copy)',
|
||||
career_name: oldScenario.career_name
|
||||
? oldScenario.career_name + ' (Copy)'
|
||||
: 'Unknown Career',
|
||||
status: oldScenario.status,
|
||||
// also do the slice if projected_end_date is set
|
||||
start_date: oldScenario.start_date
|
||||
? oldScenario.start_date.slice(0, 10)
|
||||
: '',
|
||||
projected_end_date: oldScenario.projected_end_date
|
||||
? oldScenario.projected_end_date.slice(0, 10)
|
||||
: '',
|
||||
college_enrollment_status: oldScenario.college_enrollment_status,
|
||||
currently_working: oldScenario.currently_working || 'no',
|
||||
|
||||
planned_monthly_expenses: oldScenario.planned_monthly_expenses,
|
||||
planned_monthly_debt_payments: oldScenario.planned_monthly_debt_payments,
|
||||
planned_monthly_retirement_contribution:
|
||||
oldScenario.planned_monthly_retirement_contribution,
|
||||
planned_monthly_emergency_contribution:
|
||||
oldScenario.planned_monthly_emergency_contribution,
|
||||
planned_surplus_emergency_pct: oldScenario.planned_surplus_emergency_pct,
|
||||
planned_surplus_retirement_pct:
|
||||
oldScenario.planned_surplus_retirement_pct,
|
||||
planned_additional_income: oldScenario.planned_additional_income
|
||||
};
|
||||
|
||||
const createRes = await authFetch('/api/premium/career-profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(scenarioPayload)
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
throw new Error(`Clone scenario error: ${createRes.status}`);
|
||||
}
|
||||
const newScenarioData = await createRes.json();
|
||||
const newScenarioId = newScenarioData.career_profile_id;
|
||||
|
||||
// clone college
|
||||
await cloneCollegeProfile(oldScenario.id, newScenarioId);
|
||||
|
||||
// clone milestones
|
||||
await cloneAllMilestones(oldScenario.id, newScenarioId);
|
||||
|
||||
await loadScenariosAndFinancial();
|
||||
} catch (err) {
|
||||
alert('Failed to clone scenario => ' + err.message);
|
||||
}
|
||||
async function handleRemoveScenario (id) {
|
||||
if (!window.confirm('Delete this scenario?')) return;
|
||||
const r = await authFetch(`/api/premium/career-profile/${id}`, { method: 'DELETE' });
|
||||
if (!r.ok) return alert(`Delete error (${r.status})`);
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
async function cloneCollegeProfile(oldId, newId) {
|
||||
try {
|
||||
const cRes = await authFetch(`/api/premium/college-profile?careerProfileId=${oldId}`);
|
||||
if (!cRes.ok) return;
|
||||
let oldC = await cRes.json();
|
||||
if (Array.isArray(oldC)) oldC = oldC[0] || null;
|
||||
if (!oldC || !oldC.id) return;
|
||||
|
||||
// you can do date-slice on expected_graduation if needed
|
||||
const pay = {
|
||||
career_profile_id: newId,
|
||||
selected_school: oldC.selected_school,
|
||||
selected_program: oldC.selected_program,
|
||||
program_type: oldC.program_type,
|
||||
academic_calendar: oldC.academic_calendar,
|
||||
is_in_state: oldC.is_in_state,
|
||||
is_in_district: oldC.is_in_district,
|
||||
is_online: oldC.is_online,
|
||||
college_enrollment_status: oldC.college_enrollment_status,
|
||||
annual_financial_aid: oldC.annual_financial_aid,
|
||||
existing_college_debt: oldC.existing_college_debt,
|
||||
tuition_paid: oldC.tuition_paid,
|
||||
tuition: oldC.tuition,
|
||||
loan_deferral_until_graduation: oldC.loan_deferral_until_graduation,
|
||||
loan_term: oldC.loan_term,
|
||||
interest_rate: oldC.interest_rate,
|
||||
extra_payment: oldC.extra_payment,
|
||||
credit_hours_per_year: oldC.credit_hours_per_year,
|
||||
hours_completed: oldC.hours_completed,
|
||||
program_length: oldC.program_length,
|
||||
credit_hours_required: oldC.credit_hours_required,
|
||||
expected_graduation: oldC.expected_graduation
|
||||
? oldC.expected_graduation.slice(0, 10)
|
||||
: '',
|
||||
expected_salary: oldC.expected_salary
|
||||
};
|
||||
const pRes = await authFetch('/api/premium/college-profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(pay)
|
||||
});
|
||||
if (!pRes.ok) {
|
||||
console.warn('Clone college failed =>', pRes.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('cloneCollegeProfile =>', err);
|
||||
}
|
||||
async function handleCloneScenario (src) {
|
||||
/* bring over the original long clone implementation here or import
|
||||
from a helper if you already abstracted it. Leaving a stub so
|
||||
the UI compiles. */
|
||||
alert('Clone scenario not wired yet');
|
||||
}
|
||||
|
||||
async function cloneAllMilestones(oldId, newId) {
|
||||
try {
|
||||
const mRes = await authFetch(
|
||||
`/api/premium/milestones?careerProfileId=${oldId}`
|
||||
);
|
||||
if (!mRes.ok) {
|
||||
console.warn('No old milestones => skip');
|
||||
return;
|
||||
}
|
||||
const d = await mRes.json();
|
||||
const oldList = d.milestones || [];
|
||||
for (const m of oldList) {
|
||||
// create new milestone
|
||||
const newMileId = await cloneSingleMilestone(m, newId);
|
||||
// tasks
|
||||
await cloneTasks(m.id, newMileId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('cloneAllMilestones =>', err);
|
||||
}
|
||||
}
|
||||
async function cloneSingleMilestone(oldM, newScenarioId) {
|
||||
try {
|
||||
// remove timestamps from oldM.date
|
||||
const justDate = oldM.date ? oldM.date.slice(0, 10) : '';
|
||||
const pay = {
|
||||
title: oldM.title,
|
||||
description: oldM.description,
|
||||
date: justDate,
|
||||
career_profile_id: newScenarioId,
|
||||
progress: oldM.progress,
|
||||
status: oldM.status,
|
||||
is_universal: oldM.is_universal
|
||||
};
|
||||
const r = await authFetch('/api/premium/milestone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(pay)
|
||||
});
|
||||
if (!r.ok) {
|
||||
console.warn('cloneSingleMilestone =>', r.status);
|
||||
return null;
|
||||
}
|
||||
const j = await r.json();
|
||||
let mid = null;
|
||||
if (Array.isArray(j)) {
|
||||
mid = j[0]?.id || null;
|
||||
} else if (j?.id) {
|
||||
mid = j.id;
|
||||
}
|
||||
// impacts
|
||||
if (mid) {
|
||||
await cloneMilestoneImpacts(oldM.id, mid);
|
||||
}
|
||||
return mid;
|
||||
} catch (err) {
|
||||
console.error('cloneSingleMilestone =>', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function cloneMilestoneImpacts(oldMId, newMId) {
|
||||
try {
|
||||
const iRes = await authFetch(`/api/premium/milestone-impacts?milestone_id=${oldMId}`);
|
||||
if (!iRes.ok) return;
|
||||
const d = await iRes.json();
|
||||
const arr = d.impacts || [];
|
||||
for (const imp of arr) {
|
||||
const justStart = imp.start_date ? imp.start_date.slice(0, 10) : null;
|
||||
const justEnd = imp.end_date ? imp.end_date.slice(0, 10) : null;
|
||||
const pay = {
|
||||
milestone_id: newMId,
|
||||
impact_type: imp.impact_type,
|
||||
direction: imp.direction,
|
||||
amount: imp.amount,
|
||||
start_date: justStart,
|
||||
end_date: justEnd
|
||||
};
|
||||
await authFetch('/api/premium/milestone-impacts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(pay)
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('cloneMilestoneImpacts =>', err);
|
||||
}
|
||||
}
|
||||
async function cloneTasks(oldMId, newMId) {
|
||||
try {
|
||||
const tRes = await authFetch(`/api/premium/tasks?milestone_id=${oldMId}`);
|
||||
if (!tRes.ok) return;
|
||||
const d = await tRes.json();
|
||||
const arr = d.tasks || [];
|
||||
for (const tk of arr) {
|
||||
const pay = {
|
||||
milestone_id: newMId,
|
||||
title: tk.title,
|
||||
description: tk.description,
|
||||
due_date: tk.due_date ? tk.due_date.slice(0, 10) : ''
|
||||
};
|
||||
await authFetch('/api/premium/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(pay)
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('cloneTasks =>', err);
|
||||
}
|
||||
}
|
||||
/* ------------------ chat patch helper -------------------------- */
|
||||
const applyPatch = (id, patch) => {
|
||||
setScenarios(prev => {
|
||||
const base = prev.find(s => s.id === id);
|
||||
const next = prev.map(s => (s.id === id ? { ...s, ...patch } : s));
|
||||
setDiff({ base, patch });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
async function handleRemoveScenario(id) {
|
||||
const c = window.confirm('Delete scenario?');
|
||||
if (!c) return;
|
||||
try {
|
||||
const r = await authFetch(`/api/premium/career-profile/${id}`, { method: 'DELETE' });
|
||||
if (!r.ok) throw new Error(`Delete scenario => ${r.status}`);
|
||||
await loadScenariosAndFinancial();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const visible = scenarios.slice(0, 2);
|
||||
|
||||
if (loading) return <p>Loading scenarios…</p>;
|
||||
if (error) return <p className="text-red-600">{error}</p>;
|
||||
/* --------------------------- guards ---------------------------- */
|
||||
if (loading) return <p className="p-6">Loading scenarios…</p>;
|
||||
if (error) return <p className="p-6 text-red-600">{error}</p>;
|
||||
|
||||
/* ----------------------- render body --------------------------- */
|
||||
const visibleTwo = scenarios.slice(0, 2);
|
||||
const baselineId = visibleTwo[0]?.id;
|
||||
const baselineYears = simYearsMap[baselineId] ?? null; // renamed
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* main column */}
|
||||
<div className="flex-1 p-4">
|
||||
<Button onClick={handleAddScenario} className="mb-4">
|
||||
+ Add Scenario
|
||||
</Button>
|
||||
<div className="flex flex-col md:flex-row h-full relative">
|
||||
{/* ================= MAIN COLUMN =========================== */}
|
||||
<main className="flex-1 p-4 overflow-y-auto">
|
||||
{/* desktop add */}
|
||||
{!isMobile && (
|
||||
<Button onClick={handleAddScenario} className="mb-4">
|
||||
+ Add Scenario
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 auto-cols-max md:grid-cols-[repeat(auto-fill,minmax(420px,1fr))]">
|
||||
{visible.map(sc => (
|
||||
<div className="mx-auto max-w-screen-lg grid gap-6 md:grid-cols-2">
|
||||
{visibleTwo.map(sc => (
|
||||
<ScenarioContainer
|
||||
key={sc.id}
|
||||
scenario={sc}
|
||||
financialProfile={financialProfile}
|
||||
baselineYears={baselineYears}
|
||||
onClone={handleCloneScenario}
|
||||
onRemove={handleRemoveScenario}
|
||||
isSelected={selectedScenario?.id === sc.id}
|
||||
onSelect={() => setSelectedScenario(sc)}
|
||||
onSelect={() => { setSelectedScenario(sc); if (isMobile) setChatOpen(true); }}
|
||||
onSimDone={(id, yrs) => {
|
||||
setSimYearsMap(prev => ({ ...prev, [id]: yrs }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* right rail */}
|
||||
<RetirementChatBar
|
||||
scenario={selectedScenario}
|
||||
financialProfile={financialProfile}
|
||||
onScenarioPatch={applyPatch}
|
||||
className="w-full md:w-[360px] border-l bg-white"
|
||||
/>
|
||||
{/* ================= CHAT RAIL ============================ */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed md:static top-0 right-0 h-full bg-white border-l shadow-lg',
|
||||
'transition-transform duration-300',
|
||||
'w-11/12 max-w-xs md:w-[340px] z-30',
|
||||
chatOpen ? 'translate-x-0' : 'translate-x-full md:translate-x-0'
|
||||
)}
|
||||
>
|
||||
{selectedScenario ? (
|
||||
<RetirementChatBar
|
||||
scenario={selectedScenario}
|
||||
financialProfile={financialProfile}
|
||||
onScenarioPatch={applyPatch}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-gray-400 text-sm p-4">
|
||||
Select a scenario to chat ☝️
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* diff drawer */}
|
||||
{!!diff && (
|
||||
{/* ================= MOBILE FABS ========================== */}
|
||||
{isMobile && (
|
||||
<>
|
||||
{/* chat toggle */}
|
||||
<button
|
||||
onClick={() => setChatOpen(o => !o)}
|
||||
className="fixed bottom-20 right-4 rounded-full bg-blue-600 p-3 text-white shadow-md z-40"
|
||||
aria-label="Toggle chat"
|
||||
>
|
||||
💬
|
||||
</button>
|
||||
|
||||
{/* add scenario */}
|
||||
<button
|
||||
onClick={handleAddScenario}
|
||||
className="fixed bottom-4 right-4 rounded-full bg-green-600 p-4 text-white text-xl shadow-md z-40"
|
||||
aria-label="Add scenario"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ================= DIFF DRAWER ========================== */}
|
||||
{diff && (
|
||||
<ScenarioDiffDrawer
|
||||
base={diff.base}
|
||||
patch={diff.patch}
|
||||
|
@ -5,6 +5,7 @@ import annotationPlugin from 'chartjs-plugin-annotation';
|
||||
import moment from 'moment';
|
||||
|
||||
import { Button } from './ui/button.js';
|
||||
import InfoTooltip from "./ui/infoTooltip.js";
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||||
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
|
||||
@ -13,12 +14,23 @@ 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
|
||||
onSelect,
|
||||
onSimDone
|
||||
}) {
|
||||
// -------------------------------------------------------------
|
||||
// 1) States
|
||||
@ -39,6 +51,7 @@ export default function ScenarioContainer({
|
||||
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');
|
||||
@ -337,9 +350,10 @@ export default function ScenarioContainer({
|
||||
randomRangeMax
|
||||
};
|
||||
|
||||
const { projectionData: pData, loanPaidOffMonth, readinessScore:simReadiness, retirementAtMilestone } =
|
||||
const { projectionData: pData, loanPaidOffMonth, readinessScore:simReadiness, retirementAtMilestone, yearsCovered: yc } =
|
||||
simulateFinancialProjection(mergedProfile);
|
||||
|
||||
|
||||
const sliceTo = simYearsUI * 12; // months we want to keep
|
||||
let cumulative = mergedProfile.emergencySavings || 0;
|
||||
|
||||
@ -348,10 +362,15 @@ export default function ScenarioContainer({
|
||||
return { ...row, cumulativeNetSavings: cumulative };
|
||||
});
|
||||
|
||||
if (typeof onSimDone === 'function') {
|
||||
onSimDone(localScenario.id, yc);
|
||||
}
|
||||
|
||||
setProjectionData(finalData);
|
||||
setLoanPaidOffMonth(loanPaidOffMonth);
|
||||
setReadinessScore(simReadiness);
|
||||
setRetireBalAtMilestone(retirementAtMilestone);
|
||||
setYearsCovered(yc);
|
||||
}, [
|
||||
financialProfile,
|
||||
localScenario,
|
||||
@ -450,25 +469,19 @@ export default function ScenarioContainer({
|
||||
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()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
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
|
||||
@ -843,140 +856,166 @@ export default function ScenarioContainer({
|
||||
if (localScenario) onClone(localScenario);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 10) Render
|
||||
// -------------------------------------------------------------
|
||||
return (
|
||||
<article
|
||||
onClick={() => onSelect(localScenario.id)}
|
||||
className="w-[420px] border border-gray-300 p-4 rounded cursor-pointer
|
||||
hover:shadow-sm transition-shadow bg-white"
|
||||
// -------------------------------------------------------------
|
||||
// 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}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
<div style={{ margin: '0.5rem 0' }}>
|
||||
<label>Simulation Length (years): </label>
|
||||
<input
|
||||
type="text"
|
||||
style={{ width: '3rem' }}
|
||||
value={simulationYearsInput}
|
||||
onChange={(e) => setSimulationYearsInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (!simulationYearsInput.trim()) {
|
||||
setSimulationYearsInput('20');
|
||||
{/* ───────────── 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))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* interest strategy */}
|
||||
<div style={{ margin: '0.5rem 0' }}>
|
||||
<label style={{ marginRight: '0.5rem' }}>Apply Interest:</label>
|
||||
<select
|
||||
value={interestStrategy}
|
||||
onChange={(e) => setInterestStrategy(e.target.value)}
|
||||
>
|
||||
<option value="NONE">No Interest</option>
|
||||
<option value="FLAT">Flat Rate</option>
|
||||
<option value="MONTE_CARLO">Random</option>
|
||||
</select>
|
||||
{interestStrategy === 'FLAT' && (
|
||||
<div>
|
||||
<label>Annual Rate (%):</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={flatAnnualRate}
|
||||
onChange={(e) =>
|
||||
setFlatAnnualRate(parseFloatOrZero(e.target.value, 0.06))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{interestStrategy === 'MONTE_CARLO' && (
|
||||
<div>
|
||||
<label>Min Return (%):</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={randomRangeMin}
|
||||
onChange={(e) =>
|
||||
setRandomRangeMin(parseFloatOrZero(e.target.value, -0.02))
|
||||
}
|
||||
/>
|
||||
<label>Max Return (%):</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={randomRangeMax}
|
||||
onChange={(e) =>
|
||||
setRandomRangeMax(parseFloatOrZero(e.target.value, 0.02))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
|
||||
{/* ───────────── KPI Bar ───────────── */}
|
||||
{projectionData.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br />
|
||||
<strong>Final Retirement:</strong>{' '}
|
||||
{Math.round(retireBalAtMilestone)}
|
||||
{readinessScore != null && (
|
||||
<span
|
||||
title="Retirement income / desired"
|
||||
style={{
|
||||
marginLeft: '0.5rem',
|
||||
padding: '0 6px',
|
||||
borderRadius: '12px',
|
||||
fontWeight: 600,
|
||||
background:
|
||||
readinessScore >= 80 ? '#15803d'
|
||||
: readinessScore >= 60 ? '#ca8a04' : '#dc2626',
|
||||
color: '#fff'
|
||||
}}
|
||||
>
|
||||
{readinessScore}/100
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Button onClick={() => setShowMilestoneModal(true)} style={{ marginTop: '0.5rem' }}>
|
||||
Edit Milestones
|
||||
{/* ───────────── Buttons ───────────── */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => setShowMilestoneModal(true)}>
|
||||
Milestones
|
||||
</Button>
|
||||
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<Button onClick={handleEditScenario}>Edit</Button>
|
||||
<Button onClick={handleCloneScenario} style={{ marginLeft: '0.5rem' }}>
|
||||
Clone
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteScenario}
|
||||
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<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
|
||||
|
@ -607,7 +607,7 @@ const monthlySpend = retirementSpendMonthly || 0;
|
||||
|
||||
const monthsCovered = monthlySpend > 0
|
||||
? simulateDrawdown({
|
||||
startingBalance : currentRetirementSavings,
|
||||
startingBalance : firstRetirementBalance ?? currentRetirementSavings,
|
||||
monthlySpend,
|
||||
interestStrategy,
|
||||
flatAnnualRate,
|
||||
|
Loading…
Reference in New Issue
Block a user