Updated impacts data flow to incorporate generated/saved UUIDs for each impact on creation.
This commit is contained in:
parent
f0de365358
commit
253dbee9fe
@ -912,6 +912,246 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
|
||||
}
|
||||
});
|
||||
|
||||
/************************************************************************
|
||||
* MILESTONE IMPACTS ENDPOINTS
|
||||
************************************************************************/
|
||||
app.get('/api/premium/milestone-impacts', authenticatePremiumUser, async (req, res) => {
|
||||
try {
|
||||
// Example: GET /api/premium/milestone-impacts?milestone_id=12345
|
||||
const { milestone_id } = req.query;
|
||||
if (!milestone_id) {
|
||||
return res.status(400).json({ error: 'milestone_id is required.' });
|
||||
}
|
||||
|
||||
// Verify the milestone belongs to this user
|
||||
const milestoneRow = await db.get(`
|
||||
SELECT user_id
|
||||
FROM milestones
|
||||
WHERE id = ?
|
||||
`, [milestone_id]);
|
||||
if (!milestoneRow || milestoneRow.user_id !== req.userId) {
|
||||
return res.status(404).json({ error: 'Milestone not found or not owned by this user.' });
|
||||
}
|
||||
|
||||
// Fetch all impacts for that milestone
|
||||
const impacts = await db.all(`
|
||||
SELECT
|
||||
id,
|
||||
milestone_id,
|
||||
impact_type,
|
||||
direction,
|
||||
amount,
|
||||
start_date,
|
||||
end_date,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM milestone_impacts
|
||||
WHERE milestone_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`, [milestone_id]);
|
||||
|
||||
res.json({ impacts });
|
||||
} catch (err) {
|
||||
console.error('Error fetching milestone impacts:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch milestone impacts.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/premium/milestone-impacts', authenticatePremiumUser, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
milestone_id,
|
||||
impact_type,
|
||||
direction = 'subtract',
|
||||
amount = 0,
|
||||
start_date = null,
|
||||
end_date = null,
|
||||
created_at,
|
||||
updated_at
|
||||
} = req.body;
|
||||
|
||||
// Basic checks
|
||||
if (!milestone_id || !impact_type) {
|
||||
return res.status(400).json({
|
||||
error: 'milestone_id and impact_type are required.'
|
||||
});
|
||||
}
|
||||
|
||||
// Confirm user owns the milestone
|
||||
const milestoneRow = await db.get(`
|
||||
SELECT user_id
|
||||
FROM milestones
|
||||
WHERE id = ?
|
||||
`, [milestone_id]);
|
||||
if (!milestoneRow || milestoneRow.user_id !== req.userId) {
|
||||
return res.status(403).json({ error: 'Milestone not found or not owned by this user.' });
|
||||
}
|
||||
|
||||
// Generate UUID for this new Impact
|
||||
const newUUID = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
const finalCreated = created_at || now;
|
||||
const finalUpdated = updated_at || now;
|
||||
|
||||
// Insert row WITH that UUID into the "id" column
|
||||
await db.run(`
|
||||
INSERT INTO milestone_impacts (
|
||||
id,
|
||||
milestone_id,
|
||||
impact_type,
|
||||
direction,
|
||||
amount,
|
||||
start_date,
|
||||
end_date,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
newUUID,
|
||||
milestone_id,
|
||||
impact_type,
|
||||
direction,
|
||||
amount,
|
||||
start_date,
|
||||
end_date,
|
||||
finalCreated,
|
||||
finalUpdated
|
||||
]);
|
||||
|
||||
// Fetch & return the inserted row
|
||||
const insertedRow = await db.get(`
|
||||
SELECT
|
||||
id,
|
||||
milestone_id,
|
||||
impact_type,
|
||||
direction,
|
||||
amount,
|
||||
start_date,
|
||||
end_date,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM milestone_impacts
|
||||
WHERE id = ?
|
||||
`, [newUUID]);
|
||||
|
||||
return res.status(201).json(insertedRow);
|
||||
} catch (err) {
|
||||
console.error('Error creating milestone impact:', err);
|
||||
return res.status(500).json({ error: 'Failed to create milestone impact.' });
|
||||
}
|
||||
});
|
||||
|
||||
/************************************************************************
|
||||
* UPDATE an existing milestone impact (PUT)
|
||||
************************************************************************/
|
||||
app.put('/api/premium/milestone-impacts/:impactId', authenticatePremiumUser, async (req, res) => {
|
||||
try {
|
||||
const { impactId } = req.params;
|
||||
const {
|
||||
milestone_id,
|
||||
impact_type,
|
||||
direction = 'subtract',
|
||||
amount = 0,
|
||||
start_date = null,
|
||||
end_date = null
|
||||
} = req.body;
|
||||
|
||||
// 1) Check this impact belongs to user
|
||||
const existing = await db.get(`
|
||||
SELECT mi.id, m.user_id
|
||||
FROM milestone_impacts mi
|
||||
JOIN milestones m ON mi.milestone_id = m.id
|
||||
WHERE mi.id = ?
|
||||
`, [impactId]);
|
||||
if (!existing || existing.user_id !== req.userId) {
|
||||
return res.status(404).json({ error: 'Impact not found or not owned by user.' });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 2) Update
|
||||
await db.run(`
|
||||
UPDATE milestone_impacts
|
||||
SET
|
||||
milestone_id = ?,
|
||||
impact_type = ?,
|
||||
direction = ?,
|
||||
amount = ?,
|
||||
start_date = ?,
|
||||
end_date = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
milestone_id,
|
||||
impact_type,
|
||||
direction,
|
||||
amount,
|
||||
start_date,
|
||||
end_date,
|
||||
now,
|
||||
impactId
|
||||
]);
|
||||
|
||||
// 3) Return updated
|
||||
const updatedRow = await db.get(`
|
||||
SELECT
|
||||
id,
|
||||
milestone_id,
|
||||
impact_type,
|
||||
direction,
|
||||
amount,
|
||||
start_date,
|
||||
end_date,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM milestone_impacts
|
||||
WHERE id = ?
|
||||
`, [impactId]);
|
||||
|
||||
res.json(updatedRow);
|
||||
} catch (err) {
|
||||
console.error('Error updating milestone impact:', err);
|
||||
res.status(500).json({ error: 'Failed to update milestone impact.' });
|
||||
}
|
||||
});
|
||||
|
||||
/************************************************************************
|
||||
* DELETE an existing milestone impact
|
||||
************************************************************************/
|
||||
app.delete('/api/premium/milestone-impacts/:impactId', authenticatePremiumUser, async (req, res) => {
|
||||
try {
|
||||
const { impactId } = req.params;
|
||||
|
||||
// 1) check ownership
|
||||
const existing = await db.get(`
|
||||
SELECT mi.id, m.user_id
|
||||
FROM milestone_impacts mi
|
||||
JOIN milestones m ON mi.milestone_id = m.id
|
||||
WHERE mi.id = ?
|
||||
`, [impactId]);
|
||||
|
||||
if (!existing || existing.user_id !== req.userId) {
|
||||
return res.status(404).json({ error: 'Impact not found or not owned by user.' });
|
||||
}
|
||||
|
||||
// 2) Delete
|
||||
await db.run(`
|
||||
DELETE FROM milestone_impacts
|
||||
WHERE id = ?
|
||||
`, [impactId]);
|
||||
|
||||
res.json({ message: 'Impact deleted successfully.' });
|
||||
} catch (err) {
|
||||
console.error('Error deleting milestone impact:', err);
|
||||
res.status(500).json({ error: 'Failed to delete milestone impact.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.use((req, res) => {
|
||||
console.warn(`No route matched for ${req.method} ${req.originalUrl}`);
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Premium server running on http://localhost:${PORT}`);
|
||||
|
265
src/components/MilestoneAddModal.js
Normal file
265
src/components/MilestoneAddModal.js
Normal file
@ -0,0 +1,265 @@
|
||||
// src/components/MilestoneAddModal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
|
||||
const MilestoneAddModal = ({
|
||||
show,
|
||||
onClose,
|
||||
defaultScenarioId,
|
||||
scenarioId, // which scenario this milestone applies to
|
||||
editMilestone, // if editing an existing milestone, pass its data
|
||||
apiURL
|
||||
}) => {
|
||||
// Basic milestone fields
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// We'll store an array of impacts. Each impact is { impact_type, direction, amount, start_month, end_month }
|
||||
const [impacts, setImpacts] = useState([]);
|
||||
|
||||
// On open, if editing, fill in existing fields
|
||||
useEffect(() => {
|
||||
if (!show) return; // if modal is hidden, do nothing
|
||||
|
||||
if (editMilestone) {
|
||||
setTitle(editMilestone.title || '');
|
||||
setDescription(editMilestone.description || '');
|
||||
// If editing, you might fetch existing impacts from the server or they could be passed in
|
||||
if (editMilestone.impacts) {
|
||||
setImpacts(editMilestone.impacts);
|
||||
} else {
|
||||
// fetch from backend if needed
|
||||
// e.g. GET /api/premium/milestones/:id/impacts
|
||||
}
|
||||
} else {
|
||||
// Creating a new milestone
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setImpacts([]);
|
||||
}
|
||||
}, [show, editMilestone]);
|
||||
|
||||
// Handler: add a new blank impact
|
||||
const handleAddImpact = () => {
|
||||
setImpacts((prev) => [
|
||||
...prev,
|
||||
{
|
||||
impact_type: 'ONE_TIME',
|
||||
direction: 'subtract',
|
||||
amount: 0,
|
||||
start_month: 0,
|
||||
end_month: null
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
// Handler: update a single impact in the array
|
||||
const handleImpactChange = (index, field, value) => {
|
||||
setImpacts((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Handler: remove an impact row
|
||||
const handleRemoveImpact = (index) => {
|
||||
setImpacts((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Handler: Save everything to the server
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
let milestoneId;
|
||||
if (editMilestone) {
|
||||
// 1) Update existing milestone
|
||||
milestoneId = editMilestone.id;
|
||||
await authFetch(`${apiURL}/premium/milestones/${milestoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
scenario_id: scenarioId,
|
||||
// Possibly other fields
|
||||
})
|
||||
});
|
||||
// Then handle impacts below...
|
||||
} else {
|
||||
// 1) Create new milestone
|
||||
const res = await authFetch(`${apiURL}/premium/milestones`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
scenario_id: scenarioId
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create milestone');
|
||||
const created = await res.json();
|
||||
milestoneId = created.id; // assuming the response returns { id: newMilestoneId }
|
||||
}
|
||||
|
||||
// 2) For the impacts, we can do a batch approach or individual calls
|
||||
// For simplicity, let's do multiple POST calls
|
||||
for (const impact of impacts) {
|
||||
// If editing, you might do a PUT if the impact already has an id
|
||||
await authFetch(`${apiURL}/premium/milestone-impacts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
milestone_id: milestoneId,
|
||||
impact_type: impact.impact_type,
|
||||
direction: impact.direction,
|
||||
amount: parseFloat(impact.amount) || 0,
|
||||
start_month: parseInt(impact.start_month, 10) || 0,
|
||||
end_month: impact.end_month !== null
|
||||
? parseInt(impact.end_month, 10)
|
||||
: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Done, close modal
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to save milestone + impacts:', err);
|
||||
// Show some UI error if needed
|
||||
}
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal-container">
|
||||
<h2 className="text-xl font-bold mb-2">
|
||||
{editMilestone ? 'Edit Milestone' : 'Add Milestone'}
|
||||
</h2>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="block font-semibold">Title</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="border w-full px-2 py-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="block font-semibold">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="border w-full px-2 py-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Impacts Section */}
|
||||
<h3 className="text-lg font-semibold mt-4">Financial Impacts</h3>
|
||||
{impacts.map((impact, i) => (
|
||||
<div key={i} className="border rounded p-2 my-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p>Impact #{i + 1}</p>
|
||||
<button
|
||||
className="text-red-500"
|
||||
onClick={() => handleRemoveImpact(i)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Impact Type */}
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">Type</label>
|
||||
<select
|
||||
value={impact.impact_type}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'impact_type', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="ONE_TIME">One-Time</option>
|
||||
<option value="MONTHLY">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Direction */}
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">Direction</label>
|
||||
<select
|
||||
value={impact.direction}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'direction', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="add">Add (Income)</option>
|
||||
<option value="subtract">Subtract (Expense)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
value={impact.amount}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'amount', e.target.value)
|
||||
}
|
||||
className="border px-2 py-1 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start Month */}
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">Start Month</label>
|
||||
<input
|
||||
type="number"
|
||||
value={impact.start_month}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'start_month', e.target.value)
|
||||
}
|
||||
className="border px-2 py-1 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* End Month (for MONTHLY, can be null/blank if indefinite) */}
|
||||
{impact.impact_type === 'MONTHLY' && (
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">End Month (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={impact.end_month || ''}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'end_month', e.target.value || null)
|
||||
}
|
||||
className="border px-2 py-1 w-full"
|
||||
placeholder="Leave blank for indefinite"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button onClick={handleAddImpact} className="bg-gray-200 px-3 py-1 my-2">
|
||||
+ Add Impact
|
||||
</button>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="flex justify-end mt-4">
|
||||
<button className="mr-2" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={handleSave}>
|
||||
Save Milestone
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MilestoneAddModal;
|
@ -5,20 +5,30 @@ const today = new Date();
|
||||
|
||||
const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView }) => {
|
||||
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
|
||||
|
||||
// The "new or edit" milestone form state
|
||||
const [newMilestone, setNewMilestone] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
progress: '',
|
||||
newSalary: ''
|
||||
progress: 0,
|
||||
newSalary: '',
|
||||
// Each impact can have: { id?, impact_type, direction, amount, start_date, end_date }
|
||||
impacts: []
|
||||
});
|
||||
|
||||
// We track which existing impacts the user removed, so we can DELETE them
|
||||
const [impactsToDelete, setImpactsToDelete] = useState([]);
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingMilestone, setEditingMilestone] = useState(null);
|
||||
const [showTaskForm, setShowTaskForm] = useState(null); // store milestoneId or null
|
||||
|
||||
// For tasks
|
||||
const [showTaskForm, setShowTaskForm] = useState(null);
|
||||
const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' });
|
||||
|
||||
|
||||
/**
|
||||
* Fetch all milestones (and their tasks) for this careerPathId
|
||||
* Fetch all milestones (and their tasks) for this careerPathId.
|
||||
* Then categorize them by milestone_type: 'Career' or 'Financial'.
|
||||
*/
|
||||
const fetchMilestones = useCallback(async () => {
|
||||
@ -35,10 +45,6 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
return;
|
||||
}
|
||||
|
||||
// data.milestones = [ { id, milestone_type, date, ..., tasks: [ ... ] }, ... ]
|
||||
console.log('Fetched milestones with tasks:', data.milestones);
|
||||
|
||||
// Categorize by milestone_type
|
||||
const categorized = { Career: [], Financial: [] };
|
||||
data.milestones.forEach((m) => {
|
||||
if (categorized[m.milestone_type]) {
|
||||
@ -54,19 +60,61 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
}
|
||||
}, [careerPathId, authFetch]);
|
||||
|
||||
// Run fetchMilestones on mount or when careerPathId changes
|
||||
useEffect(() => {
|
||||
fetchMilestones();
|
||||
}, [fetchMilestones]);
|
||||
|
||||
/**
|
||||
* Create or update a milestone.
|
||||
* If editingMilestone is set, we do PUT -> /api/premium/milestones/:id
|
||||
* Else we do POST -> /api/premium/milestone
|
||||
* Async function to edit an existing milestone.
|
||||
* Fetch its impacts, populate newMilestone, show the form.
|
||||
*/
|
||||
const handleEditMilestone = async (m) => {
|
||||
try {
|
||||
// Reset impactsToDelete whenever we edit a new milestone
|
||||
setImpactsToDelete([]);
|
||||
|
||||
// Fetch existing impacts for milestone "m"
|
||||
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch milestone impacts, status:', res.status);
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const fetchedImpacts = data.impacts || [];
|
||||
|
||||
// Populate the newMilestone form
|
||||
setNewMilestone({
|
||||
title: m.title || '',
|
||||
description: m.description || '',
|
||||
date: m.date || '',
|
||||
progress: m.progress || 0,
|
||||
newSalary: m.new_salary || '',
|
||||
impacts: fetchedImpacts.map((imp) => ({
|
||||
// If the DB row has id, we'll store it for PUT or DELETE
|
||||
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 || ''
|
||||
}))
|
||||
});
|
||||
|
||||
setEditingMilestone(m);
|
||||
setShowForm(true);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error in handleEditMilestone:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create or update a milestone (plus handle impacts).
|
||||
*/
|
||||
const saveMilestone = async () => {
|
||||
if (!activeView) return;
|
||||
|
||||
// If editing, we do PUT; otherwise POST
|
||||
const url = editingMilestone
|
||||
? `/api/premium/milestones/${editingMilestone.id}`
|
||||
: `/api/premium/milestone`;
|
||||
@ -80,9 +128,10 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
career_path_id: careerPathId,
|
||||
progress: newMilestone.progress,
|
||||
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
|
||||
new_salary: activeView === 'Financial' && newMilestone.newSalary
|
||||
? parseFloat(newMilestone.newSalary)
|
||||
: null
|
||||
new_salary:
|
||||
activeView === 'Financial' && newMilestone.newSalary
|
||||
? parseFloat(newMilestone.newSalary)
|
||||
: null
|
||||
};
|
||||
|
||||
try {
|
||||
@ -103,6 +152,83 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
const savedMilestone = await res.json();
|
||||
console.log('Milestone saved/updated:', savedMilestone);
|
||||
|
||||
// If Financial, handle the "impacts"
|
||||
if (activeView === 'Financial') {
|
||||
// 1) Delete impacts that user removed
|
||||
for (const impactId of impactsToDelete) {
|
||||
if (impactId) {
|
||||
console.log('Deleting old impact', impactId);
|
||||
const delRes = await authFetch(`/api/premium/milestone-impacts/${impactId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!delRes.ok) {
|
||||
console.error('Failed to delete old impact', impactId, await delRes.text());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) For each current impact in newMilestone.impacts
|
||||
// We'll track the index so we can store the newly created ID if needed
|
||||
for (let i = 0; i < newMilestone.impacts.length; i++) {
|
||||
const impact = newMilestone.impacts[i];
|
||||
if (impact.id) {
|
||||
// existing row => PUT
|
||||
const putPayload = {
|
||||
milestone_id: savedMilestone.id,
|
||||
impact_type: impact.impact_type,
|
||||
direction: impact.direction,
|
||||
amount: parseFloat(impact.amount) || 0,
|
||||
start_date: impact.start_date || null,
|
||||
end_date: impact.end_date || null
|
||||
};
|
||||
console.log('Updating milestone impact:', impact.id, putPayload);
|
||||
const impRes = await authFetch(`/api/premium/milestone-impacts/${impact.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(putPayload)
|
||||
});
|
||||
if (!impRes.ok) {
|
||||
const errImp = await impRes.json();
|
||||
console.error('Failed to update milestone impact:', errImp);
|
||||
} else {
|
||||
const updatedImpact = await impRes.json();
|
||||
console.log('Updated Impact:', updatedImpact);
|
||||
}
|
||||
} else {
|
||||
// [FIX HERE] If no id => POST to create new
|
||||
const impactPayload = {
|
||||
milestone_id: savedMilestone.id,
|
||||
impact_type: impact.impact_type,
|
||||
direction: impact.direction,
|
||||
amount: parseFloat(impact.amount) || 0,
|
||||
start_date: impact.start_date || null,
|
||||
end_date: impact.end_date || null
|
||||
};
|
||||
console.log('Creating milestone impact:', impactPayload);
|
||||
const impRes = await authFetch('/api/premium/milestone-impacts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(impactPayload)
|
||||
});
|
||||
|
||||
if (!impRes.ok) {
|
||||
const errImp = await impRes.json();
|
||||
console.error('Failed to create milestone impact:', errImp);
|
||||
} else {
|
||||
const createdImpact = await impRes.json();
|
||||
if (createdImpact && createdImpact.id) {
|
||||
setNewMilestone(prev => {
|
||||
const newImpacts = [...prev.impacts];
|
||||
newImpacts[i] = { ...newImpacts[i], id: createdImpact.id };
|
||||
return { ...prev, impacts: newImpacts };
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update local state so we don't have to refetch everything
|
||||
setMilestones((prev) => {
|
||||
const updated = { ...prev };
|
||||
@ -119,18 +245,28 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
// Reset form
|
||||
setShowForm(false);
|
||||
setEditingMilestone(null);
|
||||
|
||||
// [FIX HERE] The next line ensures the updated or newly created impact IDs
|
||||
// stay in the local state if the user tries to edit the milestone again
|
||||
// in the same session.
|
||||
setNewMilestone({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
progress: 0,
|
||||
newSalary: ''
|
||||
newSalary: '',
|
||||
impacts: []
|
||||
});
|
||||
setImpactsToDelete([]);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error saving milestone:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a new task to an existing milestone
|
||||
*/
|
||||
const addTask = async (milestoneId) => {
|
||||
try {
|
||||
const taskPayload = {
|
||||
@ -157,15 +293,13 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
|
||||
// Update the milestone's tasks in local state
|
||||
setMilestones((prev) => {
|
||||
// We need to find which classification this milestone belongs to
|
||||
const newState = { ...prev };
|
||||
// Could be Career or Financial
|
||||
['Career', 'Financial'].forEach((category) => {
|
||||
newState[category] = newState[category].map((m) => {
|
||||
if (m.id === milestoneId) {
|
||||
return {
|
||||
...m,
|
||||
tasks: [...m.tasks, createdTask]
|
||||
tasks: [...(m.tasks || []), createdTask]
|
||||
};
|
||||
}
|
||||
return m;
|
||||
@ -174,17 +308,14 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
return newState;
|
||||
});
|
||||
|
||||
// Reset the addTask form
|
||||
setNewTask({ title: '', description: '', due_date: '' });
|
||||
setShowTaskForm(null); // close the task form
|
||||
setShowTaskForm(null);
|
||||
} catch (err) {
|
||||
console.error('Error adding task:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Figure out the timeline's "end" date by scanning all milestones.
|
||||
*/
|
||||
// For timeline
|
||||
const allMilestonesCombined = [...milestones.Career, ...milestones.Financial];
|
||||
const lastDate = allMilestonesCombined.reduce((latest, m) => {
|
||||
const d = new Date(m.date);
|
||||
@ -195,12 +326,54 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
const start = today.getTime();
|
||||
const end = lastDate.getTime();
|
||||
const dateVal = new Date(dateString).getTime();
|
||||
if (end === start) return 0; // edge case if only one date
|
||||
if (end === start) return 0;
|
||||
const ratio = (dateVal - start) / (end - start);
|
||||
return Math.min(Math.max(ratio * 100, 0), 100);
|
||||
};
|
||||
|
||||
// If activeView not set or the array is missing, show a loading or empty state
|
||||
/**
|
||||
* Add a new empty impact (no id => new)
|
||||
*/
|
||||
const addNewImpact = () => {
|
||||
setNewMilestone((prev) => ({
|
||||
...prev,
|
||||
impacts: [
|
||||
...prev.impacts,
|
||||
{
|
||||
// no 'id' => brand new
|
||||
impact_type: 'ONE_TIME',
|
||||
direction: 'subtract',
|
||||
amount: 0,
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
}
|
||||
]
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove an impact from the UI. If it had an `id`, track it in impactsToDelete for later DELETE call.
|
||||
*/
|
||||
const removeImpact = (idx) => {
|
||||
setNewMilestone((prev) => {
|
||||
const newImpacts = [...prev.impacts];
|
||||
const removed = newImpacts[idx];
|
||||
if (removed.id) {
|
||||
setImpactsToDelete((old) => [...old, removed.id]);
|
||||
}
|
||||
newImpacts.splice(idx, 1);
|
||||
return { ...prev, impacts: newImpacts };
|
||||
});
|
||||
};
|
||||
|
||||
const updateImpact = (idx, field, value) => {
|
||||
setNewMilestone((prev) => {
|
||||
const newImpacts = [...prev.impacts];
|
||||
newImpacts[idx] = { ...newImpacts[idx], [field]: value };
|
||||
return { ...prev, impacts: newImpacts };
|
||||
});
|
||||
};
|
||||
|
||||
if (!activeView || !milestones[activeView]) {
|
||||
return (
|
||||
<div className="milestone-timeline">
|
||||
@ -211,7 +384,6 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
|
||||
return (
|
||||
<div className="milestone-timeline">
|
||||
{/* View selector */}
|
||||
<div className="view-selector">
|
||||
{['Career', 'Financial'].map((view) => (
|
||||
<button
|
||||
@ -224,15 +396,23 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* New Milestone button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (showForm) {
|
||||
// Cancel form
|
||||
setShowForm(false);
|
||||
setEditingMilestone(null);
|
||||
setNewMilestone({ title: '', description: '', date: '', progress: 0, newSalary: '' });
|
||||
setNewMilestone({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
progress: 0,
|
||||
newSalary: '',
|
||||
impacts: []
|
||||
});
|
||||
setImpactsToDelete([]);
|
||||
} else {
|
||||
// Show form
|
||||
setShowForm(true);
|
||||
}
|
||||
}}
|
||||
@ -255,15 +435,10 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="mm/dd/yyyy"
|
||||
type="date"
|
||||
placeholder="Milestone Date"
|
||||
value={newMilestone.date}
|
||||
onChange={(e) =>
|
||||
setNewMilestone((prev) => ({
|
||||
...prev,
|
||||
date: e.target.value
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setNewMilestone((prev) => ({ ...prev, date: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
@ -274,6 +449,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
setNewMilestone((prev) => ({ ...prev, progress: val }));
|
||||
}}
|
||||
/>
|
||||
|
||||
{activeView === 'Financial' && (
|
||||
<div>
|
||||
<input
|
||||
@ -283,17 +459,90 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })}
|
||||
/>
|
||||
<p>Enter the full new salary (not just the increase) after the milestone occurs.</p>
|
||||
|
||||
<div className="impacts-section border p-2 mt-3">
|
||||
<h4>Financial Impacts</h4>
|
||||
{newMilestone.impacts.map((imp, idx) => (
|
||||
<div key={idx} className="impact-item border p-2 my-2">
|
||||
{imp.id && (
|
||||
<p className="text-xs text-gray-500">Impact 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 if indefinite): </label>
|
||||
<input
|
||||
type="date"
|
||||
value={imp.end_date || ''}
|
||||
onChange={(e) => updateImpact(idx, 'end_date', e.target.value || '')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="text-red-500 mt-2"
|
||||
onClick={() => removeImpact(idx)}
|
||||
>
|
||||
Remove Impact
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button onClick={addNewImpact} className="bg-gray-200 px-2 py-1 mt-2">
|
||||
+ Add Impact
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={saveMilestone}>
|
||||
{editingMilestone ? 'Update' : 'Add'} Milestone
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline rendering */}
|
||||
<div className="milestone-timeline-container">
|
||||
<div className="milestone-timeline-line" />
|
||||
|
||||
{milestones[activeView].map((m) => {
|
||||
const leftPos = calcPosition(m.date);
|
||||
return (
|
||||
@ -304,28 +553,17 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
>
|
||||
<div
|
||||
className="milestone-timeline-dot"
|
||||
onClick={() => {
|
||||
// Clicking a milestone => edit it
|
||||
setEditingMilestone(m);
|
||||
setNewMilestone({
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
date: m.date,
|
||||
progress: m.progress,
|
||||
newSalary: m.new_salary || ''
|
||||
});
|
||||
setShowForm(true);
|
||||
}}
|
||||
onClick={() => handleEditMilestone(m)}
|
||||
/>
|
||||
<div className="milestone-content">
|
||||
<div className="title">{m.title}</div>
|
||||
{m.description && <p>{m.description}</p>}
|
||||
|
||||
<div className="progress-bar">
|
||||
<div className="progress" style={{ width: `${m.progress}%` }} />
|
||||
</div>
|
||||
<div className="date">{m.date}</div>
|
||||
|
||||
{/* Existing tasks */}
|
||||
{m.tasks && m.tasks.length > 0 && (
|
||||
<ul>
|
||||
{m.tasks.map((t) => (
|
||||
@ -338,15 +576,15 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Button to show/hide Add Task form */}
|
||||
<button onClick={() => {
|
||||
setShowTaskForm(showTaskForm === m.id ? null : m.id);
|
||||
setNewTask({ title: '', description: '', due_date: '' });
|
||||
}}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTaskForm(showTaskForm === m.id ? null : m.id);
|
||||
setNewTask({ title: '', description: '', due_date: '' });
|
||||
}}
|
||||
>
|
||||
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
|
||||
</button>
|
||||
|
||||
{/* Conditionally render the Add Task form for this milestone */}
|
||||
{showTaskForm === m.id && (
|
||||
<div className="task-form">
|
||||
<input
|
||||
|
@ -14,6 +14,7 @@ import AISuggestedMilestones from './AISuggestedMilestones.js';
|
||||
import './MilestoneTracker.css';
|
||||
import './MilestoneTimeline.css';
|
||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||||
import ScenarioEditModal from './ScenarioEditModal.js';
|
||||
|
||||
ChartJS.register(LineElement, CategoryScale, LinearScale, Filler, PointElement, Tooltip, Legend, annotationPlugin);
|
||||
|
||||
@ -35,6 +36,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
||||
const [projectionData, setProjectionData] = useState([]);
|
||||
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
||||
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
const apiURL = process.env.REACT_APP_API_URL;
|
||||
|
||||
// Possibly loaded from location.state
|
||||
@ -305,6 +308,19 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
||||
authFetch={authFetch}
|
||||
/>
|
||||
|
||||
|
||||
{/* SCENARIO EDIT MODAL */}
|
||||
<ScenarioEditModal
|
||||
show={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
financialProfile={financialProfile}
|
||||
setFinancialProfile={setFinancialProfile}
|
||||
collegeProfile={collegeProfile}
|
||||
setCollegeProfile={setCollegeProfile}
|
||||
apiURL={apiURL}
|
||||
authFetch={authFetch}
|
||||
/>
|
||||
|
||||
{pendingCareerForModal && (
|
||||
<button onClick={() => {
|
||||
// handleConfirmCareerSelection logic
|
||||
|
@ -1,6 +1,6 @@
|
||||
// src/components/ScenarioEditModal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import authFetch from '../utils/authFetch';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
|
||||
const ScenarioEditModal = ({
|
||||
show,
|
||||
|
@ -2,7 +2,7 @@ import moment from 'moment';
|
||||
|
||||
/**
|
||||
* Single-filer federal tax calculation (2023).
|
||||
* Includes standard deduction ($13,850).
|
||||
* Includes standard deduction ($13,850).
|
||||
*/
|
||||
const APPROX_STATE_TAX_RATES = {
|
||||
AL: 0.05,
|
||||
@ -58,10 +58,6 @@ const APPROX_STATE_TAX_RATES = {
|
||||
DC: 0.05
|
||||
};
|
||||
|
||||
/**
|
||||
* 2) Single-filer federal tax calculation (2023).
|
||||
* Includes standard deduction ($13,850).
|
||||
*/
|
||||
function calculateAnnualFederalTaxSingle(annualIncome) {
|
||||
const STANDARD_DEDUCTION_SINGLE = 13850;
|
||||
const taxableIncome = Math.max(0, annualIncome - STANDARD_DEDUCTION_SINGLE);
|
||||
@ -92,20 +88,11 @@ function calculateAnnualFederalTaxSingle(annualIncome) {
|
||||
return tax;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3) Example approximate state tax calculation.
|
||||
* Retrieves a single "effective" tax rate from the dictionary
|
||||
* and returns a simple multiplication of annualIncome * rate.
|
||||
*/
|
||||
function calculateAnnualStateTax(annualIncome, stateCode) {
|
||||
// Default to 5% if not found in dictionary
|
||||
const rate = APPROX_STATE_TAX_RATES[stateCode] ?? 0.05;
|
||||
return annualIncome * rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the standard monthly loan payment for principal, annualRate (%) and term (years).
|
||||
*/
|
||||
function calculateLoanPayment(principal, annualRate, years) {
|
||||
if (principal <= 0) return 0;
|
||||
|
||||
@ -123,6 +110,16 @@ function calculateLoanPayment(principal, annualRate, years) {
|
||||
|
||||
/**
|
||||
* Main projection function with bracket-based FEDERAL + optional STATE tax logic.
|
||||
*
|
||||
* milestoneImpacts: [
|
||||
* {
|
||||
* impact_type: 'ONE_TIME' | 'MONTHLY',
|
||||
* direction: 'add' | 'subtract',
|
||||
* amount: number,
|
||||
* start_date: 'YYYY-MM-DD',
|
||||
* end_date?: 'YYYY-MM-DD' | null
|
||||
* }, ...
|
||||
* ]
|
||||
*/
|
||||
export function simulateFinancialProjection(userProfile) {
|
||||
const {
|
||||
@ -168,42 +165,44 @@ export function simulateFinancialProjection(userProfile) {
|
||||
// Potential override
|
||||
programLength,
|
||||
|
||||
// NEW: user’s state code (e.g. 'CA', 'NY', 'TX', etc.)
|
||||
stateCode = 'TX', // default to TX (no state income tax)
|
||||
// State code
|
||||
stateCode = 'TX',
|
||||
|
||||
// Milestone impacts (with dates, add/subtract logic)
|
||||
milestoneImpacts = []
|
||||
} = userProfile;
|
||||
|
||||
// scenario start date
|
||||
const scenarioStart = startDate ? new Date(startDate) : new Date();
|
||||
|
||||
// 1. Monthly loan payment if not deferring
|
||||
let monthlyLoanPayment = loanDeferralUntilGraduation
|
||||
? 0
|
||||
: calculateLoanPayment(studentLoanAmount, interestRate, loanTerm);
|
||||
|
||||
// 2. Determine how many credit hours remain
|
||||
// 2. Determine credit hours
|
||||
let requiredCreditHours = 120;
|
||||
switch (programType) {
|
||||
case "Associate's Degree":
|
||||
requiredCreditHours = 60;
|
||||
break;
|
||||
case "Bachelor's Degree":
|
||||
requiredCreditHours = 120;
|
||||
break;
|
||||
case "Master's Degree":
|
||||
requiredCreditHours = 30;
|
||||
break;
|
||||
case "Doctoral Degree":
|
||||
requiredCreditHours = 60;
|
||||
break;
|
||||
default:
|
||||
requiredCreditHours = 120;
|
||||
// otherwise Bachelor's
|
||||
}
|
||||
const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted);
|
||||
const dynamicProgramLength = Math.ceil(remainingCreditHours / creditHoursPerYear);
|
||||
const finalProgramLength = programLength || dynamicProgramLength;
|
||||
|
||||
// 3. Net annual tuition after aid
|
||||
// 3. Net annual tuition
|
||||
const netAnnualTuition = Math.max(0, calculatedTuition - annualFinancialAid);
|
||||
const totalTuitionCost = netAnnualTuition * finalProgramLength;
|
||||
|
||||
// 4. Setup lumps per year
|
||||
// 4. lumps
|
||||
let lumpsPerYear, lumpsSchedule;
|
||||
switch (academicCalendar) {
|
||||
case 'semester':
|
||||
@ -228,8 +227,8 @@ export function simulateFinancialProjection(userProfile) {
|
||||
const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength);
|
||||
|
||||
// 5. Simulation loop
|
||||
const maxMonths = 240;
|
||||
let date = startDate ? new Date(startDate) : new Date();
|
||||
const maxMonths = 240; // 20 years
|
||||
let date = new Date(scenarioStart);
|
||||
|
||||
let loanBalance = Math.max(studentLoanAmount, 0);
|
||||
let loanPaidOffMonth = null;
|
||||
@ -238,55 +237,47 @@ export function simulateFinancialProjection(userProfile) {
|
||||
|
||||
let projectionData = [];
|
||||
let wasInDeferral = inCollege && loanDeferralUntilGraduation;
|
||||
const graduationDate = gradDate ? new Date(gradDate) : null;
|
||||
const graduationDateObj = gradDate ? new Date(gradDate) : null;
|
||||
|
||||
// YTD tracking for each year (federal + state)
|
||||
// e.g. taxStateByYear[2025] = { federalYtdGross, federalYtdTaxSoFar, stateYtdGross, stateYtdTaxSoFar }
|
||||
// For YTD taxes
|
||||
const taxStateByYear = {};
|
||||
|
||||
for (let month = 0; month < maxMonths; month++) {
|
||||
date.setMonth(date.getMonth() + 1);
|
||||
const currentYear = date.getFullYear();
|
||||
|
||||
// Check if loan is fully paid
|
||||
// elapsed months since scenario start
|
||||
const elapsedMonths = moment(date).diff(moment(scenarioStart), 'months');
|
||||
|
||||
// if loan paid
|
||||
if (loanBalance <= 0 && !loanPaidOffMonth) {
|
||||
loanPaidOffMonth = `${currentYear}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Are we still in college?
|
||||
// are we in college?
|
||||
let stillInCollege = false;
|
||||
if (inCollege) {
|
||||
if (graduationDate) {
|
||||
stillInCollege = date < graduationDate;
|
||||
if (graduationDateObj) {
|
||||
stillInCollege = date < graduationDateObj;
|
||||
} else {
|
||||
const simStart = startDate ? new Date(startDate) : new Date();
|
||||
const elapsedMonths =
|
||||
(date.getFullYear() - simStart.getFullYear()) * 12 +
|
||||
(date.getMonth() - simStart.getMonth());
|
||||
stillInCollege = (elapsedMonths < totalAcademicMonths);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Tuition lumps
|
||||
// 6. tuition lumps
|
||||
let tuitionCostThisMonth = 0;
|
||||
if (stillInCollege && lumpsPerYear > 0) {
|
||||
const simStart = startDate ? new Date(startDate) : new Date();
|
||||
const elapsedMonths =
|
||||
(date.getFullYear() - simStart.getFullYear()) * 12 +
|
||||
(date.getMonth() - simStart.getMonth());
|
||||
|
||||
const academicYearIndex = Math.floor(elapsedMonths / 12);
|
||||
const monthInYear = elapsedMonths % 12;
|
||||
|
||||
if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) {
|
||||
tuitionCostThisMonth = lumpAmount;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Exiting college?
|
||||
const nowExitingCollege = (wasInDeferral && !stillInCollege);
|
||||
const nowExitingCollege = wasInDeferral && !stillInCollege;
|
||||
|
||||
// 8. Deferral lumps get added to loan
|
||||
// 8. deferral lumps
|
||||
if (stillInCollege && loanDeferralUntilGraduation) {
|
||||
if (tuitionCostThisMonth > 0) {
|
||||
loanBalance += tuitionCostThisMonth;
|
||||
@ -294,15 +285,48 @@ export function simulateFinancialProjection(userProfile) {
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Gross monthly income
|
||||
// 9. Base monthly income
|
||||
let grossMonthlyIncome = 0;
|
||||
if (!inCollege || !stillInCollege) {
|
||||
if (!stillInCollege) {
|
||||
grossMonthlyIncome = (expectedSalary > 0 ? expectedSalary : currentSalary) / 12;
|
||||
} else {
|
||||
grossMonthlyIncome = (currentSalary / 12) + (partTimeIncome / 12);
|
||||
}
|
||||
|
||||
// 10. Tax calculations
|
||||
// Track extra subtracting impacts in a separate variable
|
||||
let extraImpactsThisMonth = 0;
|
||||
|
||||
// 9b. Apply milestone impacts
|
||||
milestoneImpacts.forEach((impact) => {
|
||||
const startOffset = impact.start_date
|
||||
? moment(impact.start_date).diff(moment(scenarioStart), 'months')
|
||||
: 0;
|
||||
let endOffset = Infinity;
|
||||
if (impact.end_date && impact.end_date.trim() !== '') {
|
||||
endOffset = moment(impact.end_date).diff(moment(scenarioStart), 'months');
|
||||
}
|
||||
|
||||
if (impact.impact_type === 'ONE_TIME') {
|
||||
if (elapsedMonths === startOffset) {
|
||||
if (impact.direction === 'add') {
|
||||
grossMonthlyIncome += impact.amount;
|
||||
} else {
|
||||
extraImpactsThisMonth += impact.amount;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 'MONTHLY'
|
||||
if (elapsedMonths >= startOffset && elapsedMonths <= endOffset) {
|
||||
if (impact.direction === 'add') {
|
||||
grossMonthlyIncome += impact.amount;
|
||||
} else {
|
||||
extraImpactsThisMonth += impact.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 10. Taxes
|
||||
if (!taxStateByYear[currentYear]) {
|
||||
taxStateByYear[currentYear] = {
|
||||
federalYtdGross: 0,
|
||||
@ -312,19 +336,18 @@ export function simulateFinancialProjection(userProfile) {
|
||||
};
|
||||
}
|
||||
|
||||
// Update YTD gross for federal + state
|
||||
// accumulate YTD gross
|
||||
taxStateByYear[currentYear].federalYtdGross += grossMonthlyIncome;
|
||||
taxStateByYear[currentYear].stateYtdGross += grossMonthlyIncome;
|
||||
|
||||
// Compute total fed tax for the year so far
|
||||
// fed tax
|
||||
const newFedTaxTotal = calculateAnnualFederalTaxSingle(
|
||||
taxStateByYear[currentYear].federalYtdGross
|
||||
);
|
||||
// Monthly fed tax = difference
|
||||
const monthlyFederalTax = newFedTaxTotal - taxStateByYear[currentYear].federalYtdTaxSoFar;
|
||||
taxStateByYear[currentYear].federalYtdTaxSoFar = newFedTaxTotal;
|
||||
|
||||
// Compute total state tax for the year so far
|
||||
// state tax
|
||||
const newStateTaxTotal = calculateAnnualStateTax(
|
||||
taxStateByYear[currentYear].stateYtdGross,
|
||||
stateCode
|
||||
@ -332,26 +355,20 @@ export function simulateFinancialProjection(userProfile) {
|
||||
const monthlyStateTax = newStateTaxTotal - taxStateByYear[currentYear].stateYtdTaxSoFar;
|
||||
taxStateByYear[currentYear].stateYtdTaxSoFar = newStateTaxTotal;
|
||||
|
||||
// Combined monthly tax
|
||||
const combinedTax = monthlyFederalTax + monthlyStateTax;
|
||||
|
||||
// Net monthly income after taxes
|
||||
const netMonthlyIncome = grossMonthlyIncome - combinedTax;
|
||||
|
||||
// 11. Expenses & loan
|
||||
let thisMonthLoanPayment = 0;
|
||||
let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth;
|
||||
// now include tuition lumps + any 'subtract' impacts
|
||||
let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth + extraImpactsThisMonth;
|
||||
|
||||
// Re-amortize if just exited college
|
||||
// re-amortize after deferral ends
|
||||
if (nowExitingCollege) {
|
||||
monthlyLoanPayment = calculateLoanPayment(
|
||||
loanBalance,
|
||||
interestRate,
|
||||
10
|
||||
);
|
||||
monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, 10);
|
||||
}
|
||||
|
||||
// If not deferring, we do normal payments
|
||||
// if deferring
|
||||
if (stillInCollege && loanDeferralUntilGraduation) {
|
||||
const interestForMonth = loanBalance * (interestRate / 100 / 12);
|
||||
loanBalance += interestForMonth;
|
||||
@ -370,11 +387,11 @@ export function simulateFinancialProjection(userProfile) {
|
||||
}
|
||||
}
|
||||
|
||||
// 12. leftover after mandatory expenses
|
||||
// leftover after mandatory expenses
|
||||
let leftover = netMonthlyIncome - totalMonthlyExpenses;
|
||||
if (leftover < 0) leftover = 0;
|
||||
|
||||
// Baseline contributions
|
||||
// baseline contributions
|
||||
const baselineContributions = monthlyRetirementContribution + monthlyEmergencyContribution;
|
||||
let effectiveRetirementContribution = 0;
|
||||
let effectiveEmergencyContribution = 0;
|
||||
@ -383,12 +400,9 @@ export function simulateFinancialProjection(userProfile) {
|
||||
effectiveRetirementContribution = monthlyRetirementContribution;
|
||||
effectiveEmergencyContribution = monthlyEmergencyContribution;
|
||||
leftover -= baselineContributions;
|
||||
} else {
|
||||
effectiveRetirementContribution = 0;
|
||||
effectiveEmergencyContribution = 0;
|
||||
}
|
||||
|
||||
// Check shortfall
|
||||
// shortfall check
|
||||
const totalWantedContributions = effectiveRetirementContribution + effectiveEmergencyContribution;
|
||||
const actualExpensesPaid = totalMonthlyExpenses + totalWantedContributions;
|
||||
let shortfall = actualExpensesPaid - netMonthlyIncome;
|
||||
@ -397,7 +411,7 @@ export function simulateFinancialProjection(userProfile) {
|
||||
currentEmergencySavings -= canCover;
|
||||
shortfall -= canCover;
|
||||
if (shortfall > 0) {
|
||||
// bankrupt scenario
|
||||
// bankrupt scenario, end
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -433,7 +447,7 @@ export function simulateFinancialProjection(userProfile) {
|
||||
loanPaymentThisMonth: Math.round(thisMonthLoanPayment * 100) / 100
|
||||
});
|
||||
|
||||
wasInDeferral = (stillInCollege && loanDeferralUntilGraduation);
|
||||
wasInDeferral = stillInCollege && loanDeferralUntilGraduation;
|
||||
}
|
||||
|
||||
return {
|
||||
|
BIN
upser_profile.db
Normal file
BIN
upser_profile.db
Normal file
Binary file not shown.
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user