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
app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res) => {
const {
scenario_title,
career_name,
status,
start_date,
@ -125,7 +126,6 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
college_enrollment_status,
currently_working,
// NEW planned columns
planned_monthly_expenses,
planned_monthly_debt_payments,
planned_monthly_retirement_contribution,
@ -149,6 +149,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
INSERT INTO career_paths (
id,
user_id,
scenario_title,
career_name,
status,
start_date,
@ -190,6 +191,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
`, [
newCareerPathId,
req.userId,
scenario_title || null,
career_name,
status || 'planned',
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
------------------------------------------------------------------ */

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 authFetch from '../utils/authFetch.js';
import ScenarioContainer from './ScenarioContainer.js';
import ScenarioEditModal from './ScenarioEditModal.js'; // The floating modal
export default function MultiScenarioView() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// The users single overall financial profile
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(() => {
async function loadData() {
setLoading(true);
setError(null);
try {
// A) fetch financial profile
let finRes = await authFetch('/api/premium/financial-profile');
if (!finRes.ok) throw new Error(`FIN profile error: ${finRes.status}`);
let finData = await finRes.json();
// 1) Fetch the users overall 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();
// B) fetch all career_paths (scenarios)
let scenRes = await authFetch('/api/premium/career-profile/all');
// 2) Fetch all scenarios (career_paths)
const scenRes = await authFetch('/api/premium/career-profile/all');
if (!scenRes.ok) throw new Error(`Scenarios error: ${scenRes.status}`);
let scenData = await scenRes.json();
const scenData = await scenRes.json();
setFinancialProfile(finData);
setScenarios(scenData.careerPaths || []);
} catch (err) {
console.error('Error loading premium data:', err);
setError(err.message || 'Failed to load data');
console.error('Error loading data in MultiScenarioView:', err);
setError(err.message || 'Failed to load scenarios/financial');
} finally {
setLoading(false);
}
@ -36,7 +46,9 @@ export default function MultiScenarioView() {
loadData();
}, []);
// “Add Scenario” => create a brand new row in career_paths
// ---------------------------
// Add a new scenario
// ---------------------------
async function handleAddScenario() {
try {
const body = {
@ -54,9 +66,9 @@ export default function MultiScenarioView() {
if (!res.ok) throw new Error(`Add scenario error: ${res.status}`);
const data = await res.json();
// Insert the new row into local state
const newRow = {
id: data.career_path_id,
user_id: null,
career_name: body.career_name,
status: body.status,
start_date: body.start_date,
@ -67,11 +79,13 @@ export default function MultiScenarioView() {
setScenarios((prev) => [...prev, newRow]);
} catch (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) {
try {
const body = {
@ -90,8 +104,9 @@ export default function MultiScenarioView() {
if (!res.ok) throw new Error(`Clone scenario error: ${res.status}`);
const data = await res.json();
const newScenarioId = data.career_path_id;
const newRow = {
id: data.career_path_id,
id: newScenarioId,
career_name: body.career_name,
status: body.status,
start_date: body.start_date,
@ -102,23 +117,70 @@ export default function MultiScenarioView() {
setScenarios((prev) => [...prev, newRow]);
} catch (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) {
// confirm
const confirmDel = window.confirm(
'Delete this scenario (and associated collegeProfile/milestones)?'
);
if (!confirmDel) return;
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));
// Optionally do an API call: DELETE /api/premium/career-profile/:id
} catch (err) {
console.error('Failed removing scenario:', err);
alert('Could not remove scenario');
console.error('Delete scenario error:', err);
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 (error) return <p className="text-red-600">Error: {error}</p>;
if (error) return <p style={{ color: 'red' }}>Error: {error}</p>;
return (
<div className="multi-scenario-view" style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
@ -126,15 +188,26 @@ export default function MultiScenarioView() {
<ScenarioContainer
key={scen.id}
scenario={scen}
financialProfile={financialProfile} // shared for all
financialProfile={financialProfile}
onClone={() => handleCloneScenario(scen)}
onRemove={() => handleRemoveScenario(scen.id)}
onEdit={() => handleEditScenario(scen)} // new callback
/>
))}
<div style={{ alignSelf: 'flex-start' }}>
<button onClick={handleAddScenario}>+ Add Scenario</button>
</div>
{/* The floating modal at the bottom => only if editingScenario != null */}
{editingScenario && (
<ScenarioEditModal
show={true}
scenario={editingScenario}
collegeProfile={editingCollegeProfile}
onClose={handleModalClose}
/>
)}
</div>
);
}

View File

@ -2,160 +2,151 @@
import React, { useState, useEffect } from 'react';
import { Line } from 'react-chartjs-2';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import ScenarioEditModal from './ScenarioEditModal.js';
import MilestoneTimeline from './MilestoneTimeline.js';
import AISuggestedMilestones from './AISuggestedMilestones.js';
import authFetch from '../utils/authFetch.js';
export default function ScenarioContainer({
scenario, // from career_paths row
financialProfile, // single row, shared across user
scenario,
financialProfile,
onRemove,
onClone,
onRemove
onEdit // <-- new callback to open the floating modal
}) {
const [localScenario, setLocalScenario] = useState(scenario);
const [collegeProfile, setCollegeProfile] = useState(null);
const [projectionData, setProjectionData] = useState([]);
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(() => {
setLocalScenario(scenario);
}, [scenario]);
// 1) Fetch the college profile for this scenario
useEffect(() => {
if (!localScenario?.id) return;
if (!scenario?.id) {
setCollegeProfile(null);
return;
}
async function loadCollegeProfile() {
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) {
const data = await res.json();
setCollegeProfile(data);
} else {
console.warn('No college profile found or error:', res.status);
setCollegeProfile({});
}
} catch (err) {
console.error('Failed fetching college profile:', err);
console.error('Error loading collegeProfile:', err);
}
}
loadCollegeProfile();
}, [localScenario]);
}, [scenario]);
// 2) Whenever we have financialProfile + collegeProfile => run the simulation
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 = {
currentSalary: financialProfile.current_salary || 0,
monthlyExpenses: financialProfile.monthly_expenses || 0,
monthlyDebtPayments: financialProfile.monthly_debt_payments || 0,
retirementSavings: financialProfile.retirement_savings || 0,
emergencySavings: financialProfile.emergency_fund || 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)
monthlyExpenses:
scenario.planned_monthly_expenses ?? financialProfile.monthly_expenses ?? 0,
monthlyDebtPayments:
scenario.planned_monthly_debt_payments ?? financialProfile.monthly_debt_payments ?? 0,
// ...
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 || 'semester',
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
// ...
simulationYears: parseInt(simulationYearsInput, 10) || 20,
milestoneImpacts: []
};
// 3) run the simulation
const { projectionData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile);
setProjectionData(projectionData);
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 (
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
<h3>{localScenario.career_name || 'Untitled Scenario'}</h3>
<p>Status: {localScenario.status}</p>
<h4>{scenario.scenario_title || scenario.career_name || 'Untitled Scenario'}</h4>
<Line
data={{
labels: projectionData.map((p) => p.month),
datasets: [
{
label: 'Net Savings',
data: projectionData.map((p) => p.cumulativeNetSavings || 0),
borderColor: 'blue',
fill: false
}
]
}}
options={{ responsive: true }}
<div style={{ margin: '0.5rem 0' }}>
<label>Simulation Length (yrs): </label>
<input
type="text"
style={{ width: '3rem' }}
value={simulationYearsInput}
onChange={e => setSimulationYearsInput(e.target.value)}
onBlur={handleSimulationYearsBlur}
/>
<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>
{/* The timeline that fetches scenario/universal milestones for display */}
<Line data={chartData} options={{ responsive: true }} />
<div style={{ marginTop: '0.5rem' }}>
<strong>Loan Paid Off:</strong> {loanPaidOffMonth || 'N/A'}
{projectionData.length > 0 && (
<>
<br />
<strong>Final Retirement:</strong> {projectionData[projectionData.length - 1].retirementSavings.toFixed(0)}
</>
)}
</div>
{scenario?.id && (
<MilestoneTimeline
careerPathId={localScenario.id}
careerPathId={scenario.id}
authFetch={authFetch}
activeView="Financial"
setActiveView={() => {}}
onMilestoneUpdated={() => {
// might do scenario changes if you want
}}
onMilestoneUpdated={() => {}}
/>
)}
{scenario?.id && (
<AISuggestedMilestones
career={localScenario.career_name}
careerPathId={localScenario.id}
career={scenario.career_name || scenario.scenario_title || ''}
careerPathId={scenario.id}
authFetch={authFetch}
activeView="Financial"
projectionData={projectionData}
/>
)}
<div style={{ marginTop: '0.5rem' }}>
<button onClick={() => setEditOpen(true)}>Edit</button>
<button onClick={onClone} style={{ marginLeft: '0.5rem' }}>
<button onClick={() => onEdit(scenario)}>Edit</button>
<button onClick={() => onClone(scenario)} style={{ marginLeft: '0.5rem' }}>
Clone
</button>
<button onClick={onRemove} style={{ marginLeft: '0.5rem', color: 'red' }}>
Remove
<button onClick={handleDeleteScenario} style={{ marginLeft: '0.5rem', color: 'red' }}>
Delete
</button>
</div>
{/* If you do scenario-level editing for planned fields, show scenario edit modal */}
{editOpen && (
<ScenarioEditModal
show={editOpen}
onClose={() => setEditOpen(false)}
scenario={localScenario}
setScenario={setLocalScenario}
apiURL="/api"
/>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

Binary file not shown.