Made modal float with scroll.

This commit is contained in:
Josh 2025-04-25 16:18:05 +00:00
parent 733dba46a8
commit d48f33572a
6 changed files with 1234 additions and 287 deletions

View File

@ -118,6 +118,7 @@ app.get('/api/premium/career-profile/:careerPathId', authenticatePremiumUser, as
// server3.js // server3.js
app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res) => { app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res) => {
const { const {
scenario_title,
career_name, career_name,
status, status,
start_date, start_date,
@ -125,7 +126,6 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
college_enrollment_status, college_enrollment_status,
currently_working, currently_working,
// NEW planned columns
planned_monthly_expenses, planned_monthly_expenses,
planned_monthly_debt_payments, planned_monthly_debt_payments,
planned_monthly_retirement_contribution, planned_monthly_retirement_contribution,
@ -149,6 +149,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
INSERT INTO career_paths ( INSERT INTO career_paths (
id, id,
user_id, user_id,
scenario_title,
career_name, career_name,
status, status,
start_date, start_date,
@ -190,6 +191,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
`, [ `, [
newCareerPathId, newCareerPathId,
req.userId, req.userId,
scenario_title || null,
career_name, career_name,
status || 'planned', status || 'planned',
start_date || now, start_date || now,
@ -229,6 +231,103 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
} }
}); });
// server3.js (or your premium server file)
// Delete a career path (scenario) by ID
app.delete('/api/premium/career-profile/:careerPathId', authenticatePremiumUser, async (req, res) => {
const { careerPathId } = req.params;
try {
// 1) Confirm that this career_path belongs to the user
const existing = await db.get(
`
SELECT id
FROM career_paths
WHERE id = ?
AND user_id = ?
`,
[careerPathId, req.userId]
);
if (!existing) {
return res.status(404).json({ error: 'Career path not found or not yours.' });
}
// 2) Optionally delete the college_profile for this scenario
// (If you always keep 1-to-1 relationship: careerPathId => college_profile)
await db.run(
`
DELETE FROM college_profiles
WHERE user_id = ?
AND career_path_id = ?
`,
[req.userId, careerPathId]
);
// 3) Optionally delete scenarios milestones
// (and any associated tasks, impacts, etc.)
// If you store tasks in tasks table, and impacts in milestone_impacts table:
// First find scenario milestones
const scenarioMilestones = await db.all(
`
SELECT id
FROM milestones
WHERE user_id = ?
AND career_path_id = ?
`,
[req.userId, careerPathId]
);
const milestoneIds = scenarioMilestones.map((m) => m.id);
if (milestoneIds.length > 0) {
// Delete tasks for these milestones
const placeholders = milestoneIds.map(() => '?').join(',');
await db.run(
`
DELETE FROM tasks
WHERE milestone_id IN (${placeholders})
`,
milestoneIds
);
// Delete impacts for these milestones
await db.run(
`
DELETE FROM milestone_impacts
WHERE milestone_id IN (${placeholders})
`,
milestoneIds
);
// Finally delete the milestones themselves
await db.run(
`
DELETE FROM milestones
WHERE id IN (${placeholders})
`,
milestoneIds
);
}
// 4) Finally delete the career_path row
await db.run(
`
DELETE FROM career_paths
WHERE user_id = ?
AND id = ?
`,
[req.userId, careerPathId]
);
res.json({ message: 'Career path and related data successfully deleted.' });
} catch (error) {
console.error('Error deleting career path:', error);
res.status(500).json({ error: 'Failed to delete career path.' });
}
});
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
Milestone ENDPOINTS Milestone ENDPOINTS
------------------------------------------------------------------ */ ------------------------------------------------------------------ */

View File

@ -0,0 +1,18 @@
.modal-backdrop {
position: fixed;
top:0; left:0;
width:100vw; height:100vh;
background: rgba(0,0,0,0.5);
z-index: 9999;
}
.modal-container {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%,-50%);
background: #fff;
width: 600px;
max-height: 80vh;
overflow-y: auto;
padding: 1rem;
}

View File

@ -2,33 +2,43 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import ScenarioContainer from './ScenarioContainer.js'; import ScenarioContainer from './ScenarioContainer.js';
import ScenarioEditModal from './ScenarioEditModal.js'; // The floating modal
export default function MultiScenarioView() { export default function MultiScenarioView() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// The users single overall financial profile
const [financialProfile, setFinancialProfile] = useState(null); const [financialProfile, setFinancialProfile] = useState(null);
const [scenarios, setScenarios] = useState([]); // each scenario is a row in career_paths
// All scenario rows (from career_paths)
const [scenarios, setScenarios] = useState([]);
// The scenario were currently editing in a top-level modal:
const [editingScenario, setEditingScenario] = useState(null);
// The collegeProfile we load for that scenario (passed to edit modal)
const [editingCollegeProfile, setEditingCollegeProfile] = useState(null);
useEffect(() => { useEffect(() => {
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
// A) fetch financial profile // 1) Fetch the users overall financial profile
let finRes = await authFetch('/api/premium/financial-profile'); const finRes = await authFetch('/api/premium/financial-profile');
if (!finRes.ok) throw new Error(`FIN profile error: ${finRes.status}`); if (!finRes.ok) throw new Error(`FinancialProfile error: ${finRes.status}`);
let finData = await finRes.json(); const finData = await finRes.json();
// B) fetch all career_paths (scenarios) // 2) Fetch all scenarios (career_paths)
let scenRes = await authFetch('/api/premium/career-profile/all'); const scenRes = await authFetch('/api/premium/career-profile/all');
if (!scenRes.ok) throw new Error(`Scenarios error: ${scenRes.status}`); if (!scenRes.ok) throw new Error(`Scenarios error: ${scenRes.status}`);
let scenData = await scenRes.json(); const scenData = await scenRes.json();
setFinancialProfile(finData); setFinancialProfile(finData);
setScenarios(scenData.careerPaths || []); setScenarios(scenData.careerPaths || []);
} catch (err) { } catch (err) {
console.error('Error loading premium data:', err); console.error('Error loading data in MultiScenarioView:', err);
setError(err.message || 'Failed to load data'); setError(err.message || 'Failed to load scenarios/financial');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -36,7 +46,9 @@ export default function MultiScenarioView() {
loadData(); loadData();
}, []); }, []);
// “Add Scenario” => create a brand new row in career_paths // ---------------------------
// Add a new scenario
// ---------------------------
async function handleAddScenario() { async function handleAddScenario() {
try { try {
const body = { const body = {
@ -54,9 +66,9 @@ export default function MultiScenarioView() {
if (!res.ok) throw new Error(`Add scenario error: ${res.status}`); if (!res.ok) throw new Error(`Add scenario error: ${res.status}`);
const data = await res.json(); const data = await res.json();
// Insert the new row into local state
const newRow = { const newRow = {
id: data.career_path_id, id: data.career_path_id,
user_id: null,
career_name: body.career_name, career_name: body.career_name,
status: body.status, status: body.status,
start_date: body.start_date, start_date: body.start_date,
@ -67,11 +79,13 @@ export default function MultiScenarioView() {
setScenarios((prev) => [...prev, newRow]); setScenarios((prev) => [...prev, newRow]);
} catch (err) { } catch (err) {
console.error('Failed adding scenario:', err); console.error('Failed adding scenario:', err);
alert('Could not add scenario'); alert(err.message || 'Could not add scenario');
} }
} }
// “Clone” => create a new row in career_paths with copied fields // ---------------------------
// Clone scenario
// ---------------------------
async function handleCloneScenario(sourceScenario) { async function handleCloneScenario(sourceScenario) {
try { try {
const body = { const body = {
@ -90,8 +104,9 @@ export default function MultiScenarioView() {
if (!res.ok) throw new Error(`Clone scenario error: ${res.status}`); if (!res.ok) throw new Error(`Clone scenario error: ${res.status}`);
const data = await res.json(); const data = await res.json();
const newScenarioId = data.career_path_id;
const newRow = { const newRow = {
id: data.career_path_id, id: newScenarioId,
career_name: body.career_name, career_name: body.career_name,
status: body.status, status: body.status,
start_date: body.start_date, start_date: body.start_date,
@ -102,23 +117,70 @@ export default function MultiScenarioView() {
setScenarios((prev) => [...prev, newRow]); setScenarios((prev) => [...prev, newRow]);
} catch (err) { } catch (err) {
console.error('Failed cloning scenario:', err); console.error('Failed cloning scenario:', err);
alert('Could not clone scenario'); alert(err.message || 'Could not clone scenario');
} }
} }
// “Remove” => possibly remove from DB or just local // ---------------------------
// Delete scenario
// ---------------------------
async function handleRemoveScenario(scenarioId) { async function handleRemoveScenario(scenarioId) {
// confirm
const confirmDel = window.confirm(
'Delete this scenario (and associated collegeProfile/milestones)?'
);
if (!confirmDel) return;
try { try {
const res = await authFetch(`/api/premium/career-profile/${scenarioId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error(`Delete scenario error: ${res.status}`);
// remove from local
setScenarios((prev) => prev.filter((s) => s.id !== scenarioId)); setScenarios((prev) => prev.filter((s) => s.id !== scenarioId));
// Optionally do an API call: DELETE /api/premium/career-profile/:id
} catch (err) { } catch (err) {
console.error('Failed removing scenario:', err); console.error('Delete scenario error:', err);
alert('Could not remove scenario'); alert(err.message || 'Could not delete scenario');
} }
} }
// ---------------------------
// User clicks "Edit" in ScenarioContainer => we fetch that scenarios collegeProfile
// set editingScenario / editingCollegeProfile => modal
// ---------------------------
async function handleEditScenario(scenarioObj) {
if (!scenarioObj?.id) return;
try {
// fetch the collegeProfile
const colResp = await authFetch(`/api/premium/college-profile?careerPathId=${scenarioObj.id}`);
let colData = {};
if (colResp.ok) {
colData = await colResp.json();
}
setEditingScenario(scenarioObj);
setEditingCollegeProfile(colData);
} catch (err) {
console.error('Error loading collegeProfile for editing:', err);
setEditingScenario(scenarioObj);
setEditingCollegeProfile({});
}
}
// Called by <ScenarioEditModal> on close => we optionally update local scenario
function handleModalClose(updatedScenario, updatedCollege) {
if (updatedScenario) {
setScenarios(prev =>
prev.map((s) => (s.id === updatedScenario.id ? { ...s, ...updatedScenario } : s))
);
}
// We might not store the updatedCollege in local state unless we want to re-simulate immediately
// For now, do nothing or re-fetch if needed
setEditingScenario(null);
setEditingCollegeProfile(null);
}
if (loading) return <p>Loading scenarios...</p>; if (loading) return <p>Loading scenarios...</p>;
if (error) return <p className="text-red-600">Error: {error}</p>; if (error) return <p style={{ color: 'red' }}>Error: {error}</p>;
return ( return (
<div className="multi-scenario-view" style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}> <div className="multi-scenario-view" style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
@ -126,15 +188,26 @@ export default function MultiScenarioView() {
<ScenarioContainer <ScenarioContainer
key={scen.id} key={scen.id}
scenario={scen} scenario={scen}
financialProfile={financialProfile} // shared for all financialProfile={financialProfile}
onClone={() => handleCloneScenario(scen)} onClone={() => handleCloneScenario(scen)}
onRemove={() => handleRemoveScenario(scen.id)} onRemove={() => handleRemoveScenario(scen.id)}
onEdit={() => handleEditScenario(scen)} // new callback
/> />
))} ))}
<div style={{ alignSelf: 'flex-start' }}> <div style={{ alignSelf: 'flex-start' }}>
<button onClick={handleAddScenario}>+ Add Scenario</button> <button onClick={handleAddScenario}>+ Add Scenario</button>
</div> </div>
{/* The floating modal at the bottom => only if editingScenario != null */}
{editingScenario && (
<ScenarioEditModal
show={true}
scenario={editingScenario}
collegeProfile={editingCollegeProfile}
onClose={handleModalClose}
/>
)}
</div> </div>
); );
} }

View File

@ -2,160 +2,151 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import ScenarioEditModal from './ScenarioEditModal.js';
import MilestoneTimeline from './MilestoneTimeline.js'; import MilestoneTimeline from './MilestoneTimeline.js';
import AISuggestedMilestones from './AISuggestedMilestones.js'; import AISuggestedMilestones from './AISuggestedMilestones.js';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
export default function ScenarioContainer({ export default function ScenarioContainer({
scenario, // from career_paths row scenario,
financialProfile, // single row, shared across user financialProfile,
onRemove,
onClone, onClone,
onRemove onEdit // <-- new callback to open the floating modal
}) { }) {
const [localScenario, setLocalScenario] = useState(scenario);
const [collegeProfile, setCollegeProfile] = useState(null); const [collegeProfile, setCollegeProfile] = useState(null);
const [projectionData, setProjectionData] = useState([]); const [projectionData, setProjectionData] = useState([]);
const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null); const [loanPaidOffMonth, setLoanPaidOffMonth] = useState(null);
const [editOpen, setEditOpen] = useState(false); // An input for sim length
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
// Re-sync if parent updates scenario
useEffect(() => { useEffect(() => {
setLocalScenario(scenario); if (!scenario?.id) {
}, [scenario]); setCollegeProfile(null);
return;
// 1) Fetch the college profile for this scenario }
useEffect(() => {
if (!localScenario?.id) return;
async function loadCollegeProfile() { async function loadCollegeProfile() {
try { try {
const res = await authFetch(`/api/premium/college-profile?careerPathId=${localScenario.id}`); const url = `/api/premium/college-profile?careerPathId=${scenario.id}`;
const res = await authFetch(url);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setCollegeProfile(data); setCollegeProfile(data);
} else { } else {
console.warn('No college profile found or error:', res.status);
setCollegeProfile({}); setCollegeProfile({});
} }
} catch (err) { } catch (err) {
console.error('Failed fetching college profile:', err); console.error('Error loading collegeProfile:', err);
} }
} }
loadCollegeProfile(); loadCollegeProfile();
}, [localScenario]); }, [scenario]);
// 2) Whenever we have financialProfile + collegeProfile => run the simulation
useEffect(() => { useEffect(() => {
if (!financialProfile || !collegeProfile) return; if (!financialProfile || !collegeProfile || !scenario?.id) return;
// Merge them into the userProfile object for the simulator: // Merge the users base financial profile + scenario overwrites + college profile
const mergedProfile = { const mergedProfile = {
currentSalary: financialProfile.current_salary || 0, currentSalary: financialProfile.current_salary || 0,
monthlyExpenses: financialProfile.monthly_expenses || 0, monthlyExpenses:
monthlyDebtPayments: financialProfile.monthly_debt_payments || 0, scenario.planned_monthly_expenses ?? financialProfile.monthly_expenses ?? 0,
retirementSavings: financialProfile.retirement_savings || 0, monthlyDebtPayments:
emergencySavings: financialProfile.emergency_fund || 0, scenario.planned_monthly_debt_payments ?? financialProfile.monthly_debt_payments ?? 0,
monthlyRetirementContribution: financialProfile.retirement_contribution || 0, // ...
monthlyEmergencyContribution: financialProfile.emergency_contribution || 0,
surplusEmergencyAllocation: financialProfile.extra_cash_emergency_pct || 50,
surplusRetirementAllocation: financialProfile.extra_cash_retirement_pct || 50,
// College fields (scenario-based)
studentLoanAmount: collegeProfile.existing_college_debt || 0, studentLoanAmount: collegeProfile.existing_college_debt || 0,
interestRate: collegeProfile.interest_rate || 5, interestRate: collegeProfile.interest_rate || 5,
loanTerm: collegeProfile.loan_term || 10, loanTerm: collegeProfile.loan_term || 10,
loanDeferralUntilGraduation: !!collegeProfile.loan_deferral_until_graduation, // ...
academicCalendar: collegeProfile.academic_calendar || 'semester', simulationYears: parseInt(simulationYearsInput, 10) || 20,
annualFinancialAid: collegeProfile.annual_financial_aid || 0,
calculatedTuition: collegeProfile.tuition || 0,
extraPayment: collegeProfile.extra_payment || 0,
gradDate: collegeProfile.expected_graduation || null,
programType: collegeProfile.program_type || '',
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
hoursCompleted: collegeProfile.hours_completed || 0,
programLength: collegeProfile.program_length || 0,
inCollege:
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
collegeProfile.college_enrollment_status === 'prospective_student',
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary || 0,
// milestoneImpacts is fetched & merged in MilestoneTimeline, not here
milestoneImpacts: [] milestoneImpacts: []
}; };
// 3) run the simulation
const { projectionData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile); const { projectionData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile);
setProjectionData(projectionData); setProjectionData(projectionData);
setLoanPaidOffMonth(loanPaidOffMonth); setLoanPaidOffMonth(loanPaidOffMonth);
}, [financialProfile, collegeProfile]); }, [financialProfile, collegeProfile, scenario, simulationYearsInput]);
function handleDeleteScenario() {
// let the parent actually do the DB deletion
onRemove(scenario.id);
}
function handleSimulationYearsBlur() {
if (simulationYearsInput.trim() === '') {
setSimulationYearsInput('20');
}
}
// chart data
const labels = projectionData.map(p => p.month);
const netSavData = projectionData.map(p => p.cumulativeNetSavings || 0);
const retData = projectionData.map(p => p.retirementSavings || 0);
const loanData = projectionData.map(p => p.loanBalance || 0);
const chartData = {
labels,
datasets: [
{ label: 'Net Savings', data: netSavData, borderColor: 'blue', fill: false },
{ label: 'Retirement', data: retData, borderColor: 'green', fill: false },
{ label: 'Loan', data: loanData, borderColor: 'red', fill: false }
]
};
return ( return (
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}> <div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
<h3>{localScenario.career_name || 'Untitled Scenario'}</h3> <h4>{scenario.scenario_title || scenario.career_name || 'Untitled Scenario'}</h4>
<p>Status: {localScenario.status}</p>
<Line <div style={{ margin: '0.5rem 0' }}>
data={{ <label>Simulation Length (yrs): </label>
labels: projectionData.map((p) => p.month), <input
datasets: [ type="text"
{ style={{ width: '3rem' }}
label: 'Net Savings', value={simulationYearsInput}
data: projectionData.map((p) => p.cumulativeNetSavings || 0), onChange={e => setSimulationYearsInput(e.target.value)}
borderColor: 'blue', onBlur={handleSimulationYearsBlur}
fill: false />
}
]
}}
options={{ responsive: true }}
/>
<div style={{ marginTop: '0.5rem' }}>
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'} <br />
<strong>Final Retirement:</strong>{' '}
{projectionData[projectionData.length - 1]?.retirementSavings?.toFixed(0) || 0}
</div> </div>
{/* The timeline that fetches scenario/universal milestones for display */} <Line data={chartData} options={{ responsive: true }} />
<MilestoneTimeline
careerPathId={localScenario.id}
authFetch={authFetch}
activeView="Financial"
setActiveView={() => {}}
onMilestoneUpdated={() => {
// might do scenario changes if you want
}}
/>
<AISuggestedMilestones
career={localScenario.career_name}
careerPathId={localScenario.id}
authFetch={authFetch}
activeView="Financial"
projectionData={projectionData}
/>
<div style={{ marginTop: '0.5rem' }}> <div style={{ marginTop: '0.5rem' }}>
<button onClick={() => setEditOpen(true)}>Edit</button> <strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'}
<button onClick={onClone} style={{ marginLeft: '0.5rem' }}> {projectionData.length > 0 && (
Clone <>
</button> <br />
<button onClick={onRemove} style={{ marginLeft: '0.5rem', color: 'red' }}> <strong>Final Retirement:</strong> {projectionData[projectionData.length - 1].retirementSavings.toFixed(0)}
Remove </>
</button> )}
</div> </div>
{/* If you do scenario-level editing for planned fields, show scenario edit modal */} {scenario?.id && (
{editOpen && ( <MilestoneTimeline
<ScenarioEditModal careerPathId={scenario.id}
show={editOpen} authFetch={authFetch}
onClose={() => setEditOpen(false)} activeView="Financial"
scenario={localScenario} setActiveView={() => {}}
setScenario={setLocalScenario} onMilestoneUpdated={() => {}}
apiURL="/api"
/> />
)} )}
{scenario?.id && (
<AISuggestedMilestones
career={scenario.career_name || scenario.scenario_title || ''}
careerPathId={scenario.id}
authFetch={authFetch}
activeView="Financial"
projectionData={projectionData}
/>
)}
<div style={{ marginTop: '0.5rem' }}>
<button onClick={() => onEdit(scenario)}>Edit</button>
<button onClick={() => onClone(scenario)} style={{ marginLeft: '0.5rem' }}>
Clone
</button>
<button onClick={handleDeleteScenario} style={{ marginLeft: '0.5rem', color: 'red' }}>
Delete
</button>
</div>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

Binary file not shown.