Updated impacts data flow to incorporate generated/saved UUIDs for each impact on creation.
This commit is contained in:
parent
126a17543c
commit
c946144334
@ -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, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Premium server running on http://localhost:${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 MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView }) => {
|
||||||
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
|
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
|
||||||
|
|
||||||
|
// The "new or edit" milestone form state
|
||||||
const [newMilestone, setNewMilestone] = useState({
|
const [newMilestone, setNewMilestone] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
date: '',
|
date: '',
|
||||||
progress: '',
|
progress: 0,
|
||||||
newSalary: ''
|
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 [showForm, setShowForm] = useState(false);
|
||||||
const [editingMilestone, setEditingMilestone] = useState(null);
|
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: '' });
|
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'.
|
* Then categorize them by milestone_type: 'Career' or 'Financial'.
|
||||||
*/
|
*/
|
||||||
const fetchMilestones = useCallback(async () => {
|
const fetchMilestones = useCallback(async () => {
|
||||||
@ -35,10 +45,6 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// data.milestones = [ { id, milestone_type, date, ..., tasks: [ ... ] }, ... ]
|
|
||||||
console.log('Fetched milestones with tasks:', data.milestones);
|
|
||||||
|
|
||||||
// Categorize by milestone_type
|
|
||||||
const categorized = { Career: [], Financial: [] };
|
const categorized = { Career: [], Financial: [] };
|
||||||
data.milestones.forEach((m) => {
|
data.milestones.forEach((m) => {
|
||||||
if (categorized[m.milestone_type]) {
|
if (categorized[m.milestone_type]) {
|
||||||
@ -54,19 +60,61 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
}
|
}
|
||||||
}, [careerPathId, authFetch]);
|
}, [careerPathId, authFetch]);
|
||||||
|
|
||||||
// Run fetchMilestones on mount or when careerPathId changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMilestones();
|
fetchMilestones();
|
||||||
}, [fetchMilestones]);
|
}, [fetchMilestones]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create or update a milestone.
|
* Async function to edit an existing milestone.
|
||||||
* If editingMilestone is set, we do PUT -> /api/premium/milestones/:id
|
* Fetch its impacts, populate newMilestone, show the form.
|
||||||
* Else we do POST -> /api/premium/milestone
|
*/
|
||||||
|
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 () => {
|
const saveMilestone = async () => {
|
||||||
if (!activeView) return;
|
if (!activeView) return;
|
||||||
|
|
||||||
|
// If editing, we do PUT; otherwise POST
|
||||||
const url = editingMilestone
|
const url = editingMilestone
|
||||||
? `/api/premium/milestones/${editingMilestone.id}`
|
? `/api/premium/milestones/${editingMilestone.id}`
|
||||||
: `/api/premium/milestone`;
|
: `/api/premium/milestone`;
|
||||||
@ -80,7 +128,8 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
career_path_id: careerPathId,
|
career_path_id: careerPathId,
|
||||||
progress: newMilestone.progress,
|
progress: newMilestone.progress,
|
||||||
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
|
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
|
||||||
new_salary: activeView === 'Financial' && newMilestone.newSalary
|
new_salary:
|
||||||
|
activeView === 'Financial' && newMilestone.newSalary
|
||||||
? parseFloat(newMilestone.newSalary)
|
? parseFloat(newMilestone.newSalary)
|
||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
@ -103,6 +152,83 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
const savedMilestone = await res.json();
|
const savedMilestone = await res.json();
|
||||||
console.log('Milestone saved/updated:', savedMilestone);
|
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
|
// Update local state so we don't have to refetch everything
|
||||||
setMilestones((prev) => {
|
setMilestones((prev) => {
|
||||||
const updated = { ...prev };
|
const updated = { ...prev };
|
||||||
@ -119,18 +245,28 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
// Reset form
|
// Reset form
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingMilestone(null);
|
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({
|
setNewMilestone({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
date: '',
|
date: '',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
newSalary: ''
|
newSalary: '',
|
||||||
|
impacts: []
|
||||||
});
|
});
|
||||||
|
setImpactsToDelete([]);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving milestone:', err);
|
console.error('Error saving milestone:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new task to an existing milestone
|
||||||
|
*/
|
||||||
const addTask = async (milestoneId) => {
|
const addTask = async (milestoneId) => {
|
||||||
try {
|
try {
|
||||||
const taskPayload = {
|
const taskPayload = {
|
||||||
@ -157,15 +293,13 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
|
|
||||||
// Update the milestone's tasks in local state
|
// Update the milestone's tasks in local state
|
||||||
setMilestones((prev) => {
|
setMilestones((prev) => {
|
||||||
// We need to find which classification this milestone belongs to
|
|
||||||
const newState = { ...prev };
|
const newState = { ...prev };
|
||||||
// Could be Career or Financial
|
|
||||||
['Career', 'Financial'].forEach((category) => {
|
['Career', 'Financial'].forEach((category) => {
|
||||||
newState[category] = newState[category].map((m) => {
|
newState[category] = newState[category].map((m) => {
|
||||||
if (m.id === milestoneId) {
|
if (m.id === milestoneId) {
|
||||||
return {
|
return {
|
||||||
...m,
|
...m,
|
||||||
tasks: [...m.tasks, createdTask]
|
tasks: [...(m.tasks || []), createdTask]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return m;
|
return m;
|
||||||
@ -174,17 +308,14 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
return newState;
|
return newState;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset the addTask form
|
|
||||||
setNewTask({ title: '', description: '', due_date: '' });
|
setNewTask({ title: '', description: '', due_date: '' });
|
||||||
setShowTaskForm(null); // close the task form
|
setShowTaskForm(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error adding task:', err);
|
console.error('Error adding task:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// For timeline
|
||||||
* Figure out the timeline's "end" date by scanning all milestones.
|
|
||||||
*/
|
|
||||||
const allMilestonesCombined = [...milestones.Career, ...milestones.Financial];
|
const allMilestonesCombined = [...milestones.Career, ...milestones.Financial];
|
||||||
const lastDate = allMilestonesCombined.reduce((latest, m) => {
|
const lastDate = allMilestonesCombined.reduce((latest, m) => {
|
||||||
const d = new Date(m.date);
|
const d = new Date(m.date);
|
||||||
@ -195,12 +326,54 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
const start = today.getTime();
|
const start = today.getTime();
|
||||||
const end = lastDate.getTime();
|
const end = lastDate.getTime();
|
||||||
const dateVal = new Date(dateString).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);
|
const ratio = (dateVal - start) / (end - start);
|
||||||
return Math.min(Math.max(ratio * 100, 0), 100);
|
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]) {
|
if (!activeView || !milestones[activeView]) {
|
||||||
return (
|
return (
|
||||||
<div className="milestone-timeline">
|
<div className="milestone-timeline">
|
||||||
@ -211,7 +384,6 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="milestone-timeline">
|
<div className="milestone-timeline">
|
||||||
{/* View selector */}
|
|
||||||
<div className="view-selector">
|
<div className="view-selector">
|
||||||
{['Career', 'Financial'].map((view) => (
|
{['Career', 'Financial'].map((view) => (
|
||||||
<button
|
<button
|
||||||
@ -224,15 +396,23 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* New Milestone button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (showForm) {
|
if (showForm) {
|
||||||
// Cancel form
|
// Cancel form
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingMilestone(null);
|
setEditingMilestone(null);
|
||||||
setNewMilestone({ title: '', description: '', date: '', progress: 0, newSalary: '' });
|
setNewMilestone({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
date: '',
|
||||||
|
progress: 0,
|
||||||
|
newSalary: '',
|
||||||
|
impacts: []
|
||||||
|
});
|
||||||
|
setImpactsToDelete([]);
|
||||||
} else {
|
} else {
|
||||||
// Show form
|
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -255,15 +435,10 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })}
|
onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="date"
|
||||||
placeholder="mm/dd/yyyy"
|
placeholder="Milestone Date"
|
||||||
value={newMilestone.date}
|
value={newMilestone.date}
|
||||||
onChange={(e) =>
|
onChange={(e) => setNewMilestone((prev) => ({ ...prev, date: e.target.value }))}
|
||||||
setNewMilestone((prev) => ({
|
|
||||||
...prev,
|
|
||||||
date: e.target.value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -274,6 +449,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
setNewMilestone((prev) => ({ ...prev, progress: val }));
|
setNewMilestone((prev) => ({ ...prev, progress: val }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeView === 'Financial' && (
|
{activeView === 'Financial' && (
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
@ -283,17 +459,90 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })}
|
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<p>Enter the full new salary (not just the increase) after the milestone occurs.</p>
|
<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>
|
</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}>
|
<button onClick={saveMilestone}>
|
||||||
{editingMilestone ? 'Update' : 'Add'} Milestone
|
{editingMilestone ? 'Update' : 'Add'} Milestone
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Timeline rendering */}
|
|
||||||
<div className="milestone-timeline-container">
|
<div className="milestone-timeline-container">
|
||||||
<div className="milestone-timeline-line" />
|
<div className="milestone-timeline-line" />
|
||||||
|
|
||||||
{milestones[activeView].map((m) => {
|
{milestones[activeView].map((m) => {
|
||||||
const leftPos = calcPosition(m.date);
|
const leftPos = calcPosition(m.date);
|
||||||
return (
|
return (
|
||||||
@ -304,28 +553,17 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="milestone-timeline-dot"
|
className="milestone-timeline-dot"
|
||||||
onClick={() => {
|
onClick={() => handleEditMilestone(m)}
|
||||||
// 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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="milestone-content">
|
<div className="milestone-content">
|
||||||
<div className="title">{m.title}</div>
|
<div className="title">{m.title}</div>
|
||||||
{m.description && <p>{m.description}</p>}
|
{m.description && <p>{m.description}</p>}
|
||||||
|
|
||||||
<div className="progress-bar">
|
<div className="progress-bar">
|
||||||
<div className="progress" style={{ width: `${m.progress}%` }} />
|
<div className="progress" style={{ width: `${m.progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="date">{m.date}</div>
|
<div className="date">{m.date}</div>
|
||||||
|
|
||||||
{/* Existing tasks */}
|
|
||||||
{m.tasks && m.tasks.length > 0 && (
|
{m.tasks && m.tasks.length > 0 && (
|
||||||
<ul>
|
<ul>
|
||||||
{m.tasks.map((t) => (
|
{m.tasks.map((t) => (
|
||||||
@ -338,15 +576,15 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Button to show/hide Add Task form */}
|
<button
|
||||||
<button onClick={() => {
|
onClick={() => {
|
||||||
setShowTaskForm(showTaskForm === m.id ? null : m.id);
|
setShowTaskForm(showTaskForm === m.id ? null : m.id);
|
||||||
setNewTask({ title: '', description: '', due_date: '' });
|
setNewTask({ title: '', description: '', due_date: '' });
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
|
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Conditionally render the Add Task form for this milestone */}
|
|
||||||
{showTaskForm === m.id && (
|
{showTaskForm === m.id && (
|
||||||
<div className="task-form">
|
<div className="task-form">
|
||||||
<input
|
<input
|
||||||
|
@ -14,6 +14,7 @@ import AISuggestedMilestones from './AISuggestedMilestones.js';
|
|||||||
import './MilestoneTracker.css';
|
import './MilestoneTracker.css';
|
||||||
import './MilestoneTimeline.css';
|
import './MilestoneTimeline.css';
|
||||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||||||
|
import ScenarioEditModal from './ScenarioEditModal.js';
|
||||||
|
|
||||||
ChartJS.register(LineElement, CategoryScale, LinearScale, Filler, PointElement, Tooltip, Legend, annotationPlugin);
|
ChartJS.register(LineElement, CategoryScale, LinearScale, Filler, PointElement, Tooltip, Legend, annotationPlugin);
|
||||||
|
|
||||||
@ -35,6 +36,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
const [projectionData, setProjectionData] = useState([]);
|
const [projectionData, setProjectionData] = useState([]);
|
||||||
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
||||||
|
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
|
||||||
const apiURL = process.env.REACT_APP_API_URL;
|
const apiURL = process.env.REACT_APP_API_URL;
|
||||||
|
|
||||||
// Possibly loaded from location.state
|
// Possibly loaded from location.state
|
||||||
@ -305,6 +308,19 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
|||||||
authFetch={authFetch}
|
authFetch={authFetch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
{/* SCENARIO EDIT MODAL */}
|
||||||
|
<ScenarioEditModal
|
||||||
|
show={showEditModal}
|
||||||
|
onClose={() => setShowEditModal(false)}
|
||||||
|
financialProfile={financialProfile}
|
||||||
|
setFinancialProfile={setFinancialProfile}
|
||||||
|
collegeProfile={collegeProfile}
|
||||||
|
setCollegeProfile={setCollegeProfile}
|
||||||
|
apiURL={apiURL}
|
||||||
|
authFetch={authFetch}
|
||||||
|
/>
|
||||||
|
|
||||||
{pendingCareerForModal && (
|
{pendingCareerForModal && (
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
// handleConfirmCareerSelection logic
|
// handleConfirmCareerSelection logic
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
// src/components/ScenarioEditModal.js
|
// src/components/ScenarioEditModal.js
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import authFetch from '../utils/authFetch';
|
import authFetch from '../utils/authFetch.js';
|
||||||
|
|
||||||
const ScenarioEditModal = ({
|
const ScenarioEditModal = ({
|
||||||
show,
|
show,
|
||||||
|
@ -58,10 +58,6 @@ const APPROX_STATE_TAX_RATES = {
|
|||||||
DC: 0.05
|
DC: 0.05
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 2) Single-filer federal tax calculation (2023).
|
|
||||||
* Includes standard deduction ($13,850).
|
|
||||||
*/
|
|
||||||
function calculateAnnualFederalTaxSingle(annualIncome) {
|
function calculateAnnualFederalTaxSingle(annualIncome) {
|
||||||
const STANDARD_DEDUCTION_SINGLE = 13850;
|
const STANDARD_DEDUCTION_SINGLE = 13850;
|
||||||
const taxableIncome = Math.max(0, annualIncome - STANDARD_DEDUCTION_SINGLE);
|
const taxableIncome = Math.max(0, annualIncome - STANDARD_DEDUCTION_SINGLE);
|
||||||
@ -92,20 +88,11 @@ function calculateAnnualFederalTaxSingle(annualIncome) {
|
|||||||
return tax;
|
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) {
|
function calculateAnnualStateTax(annualIncome, stateCode) {
|
||||||
// Default to 5% if not found in dictionary
|
|
||||||
const rate = APPROX_STATE_TAX_RATES[stateCode] ?? 0.05;
|
const rate = APPROX_STATE_TAX_RATES[stateCode] ?? 0.05;
|
||||||
return annualIncome * rate;
|
return annualIncome * rate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the standard monthly loan payment for principal, annualRate (%) and term (years).
|
|
||||||
*/
|
|
||||||
function calculateLoanPayment(principal, annualRate, years) {
|
function calculateLoanPayment(principal, annualRate, years) {
|
||||||
if (principal <= 0) return 0;
|
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.
|
* 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) {
|
export function simulateFinancialProjection(userProfile) {
|
||||||
const {
|
const {
|
||||||
@ -168,42 +165,44 @@ export function simulateFinancialProjection(userProfile) {
|
|||||||
// Potential override
|
// Potential override
|
||||||
programLength,
|
programLength,
|
||||||
|
|
||||||
// NEW: user’s state code (e.g. 'CA', 'NY', 'TX', etc.)
|
// State code
|
||||||
stateCode = 'TX', // default to TX (no state income tax)
|
stateCode = 'TX',
|
||||||
|
|
||||||
|
// Milestone impacts (with dates, add/subtract logic)
|
||||||
|
milestoneImpacts = []
|
||||||
} = userProfile;
|
} = userProfile;
|
||||||
|
|
||||||
|
// scenario start date
|
||||||
|
const scenarioStart = startDate ? new Date(startDate) : new Date();
|
||||||
|
|
||||||
// 1. Monthly loan payment if not deferring
|
// 1. Monthly loan payment if not deferring
|
||||||
let monthlyLoanPayment = loanDeferralUntilGraduation
|
let monthlyLoanPayment = loanDeferralUntilGraduation
|
||||||
? 0
|
? 0
|
||||||
: calculateLoanPayment(studentLoanAmount, interestRate, loanTerm);
|
: calculateLoanPayment(studentLoanAmount, interestRate, loanTerm);
|
||||||
|
|
||||||
// 2. Determine how many credit hours remain
|
// 2. Determine credit hours
|
||||||
let requiredCreditHours = 120;
|
let requiredCreditHours = 120;
|
||||||
switch (programType) {
|
switch (programType) {
|
||||||
case "Associate's Degree":
|
case "Associate's Degree":
|
||||||
requiredCreditHours = 60;
|
requiredCreditHours = 60;
|
||||||
break;
|
break;
|
||||||
case "Bachelor's Degree":
|
|
||||||
requiredCreditHours = 120;
|
|
||||||
break;
|
|
||||||
case "Master's Degree":
|
case "Master's Degree":
|
||||||
requiredCreditHours = 30;
|
requiredCreditHours = 30;
|
||||||
break;
|
break;
|
||||||
case "Doctoral Degree":
|
case "Doctoral Degree":
|
||||||
requiredCreditHours = 60;
|
requiredCreditHours = 60;
|
||||||
break;
|
break;
|
||||||
default:
|
// otherwise Bachelor's
|
||||||
requiredCreditHours = 120;
|
|
||||||
}
|
}
|
||||||
const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted);
|
const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted);
|
||||||
const dynamicProgramLength = Math.ceil(remainingCreditHours / creditHoursPerYear);
|
const dynamicProgramLength = Math.ceil(remainingCreditHours / creditHoursPerYear);
|
||||||
const finalProgramLength = programLength || dynamicProgramLength;
|
const finalProgramLength = programLength || dynamicProgramLength;
|
||||||
|
|
||||||
// 3. Net annual tuition after aid
|
// 3. Net annual tuition
|
||||||
const netAnnualTuition = Math.max(0, calculatedTuition - annualFinancialAid);
|
const netAnnualTuition = Math.max(0, calculatedTuition - annualFinancialAid);
|
||||||
const totalTuitionCost = netAnnualTuition * finalProgramLength;
|
const totalTuitionCost = netAnnualTuition * finalProgramLength;
|
||||||
|
|
||||||
// 4. Setup lumps per year
|
// 4. lumps
|
||||||
let lumpsPerYear, lumpsSchedule;
|
let lumpsPerYear, lumpsSchedule;
|
||||||
switch (academicCalendar) {
|
switch (academicCalendar) {
|
||||||
case 'semester':
|
case 'semester':
|
||||||
@ -228,8 +227,8 @@ export function simulateFinancialProjection(userProfile) {
|
|||||||
const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength);
|
const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength);
|
||||||
|
|
||||||
// 5. Simulation loop
|
// 5. Simulation loop
|
||||||
const maxMonths = 240;
|
const maxMonths = 240; // 20 years
|
||||||
let date = startDate ? new Date(startDate) : new Date();
|
let date = new Date(scenarioStart);
|
||||||
|
|
||||||
let loanBalance = Math.max(studentLoanAmount, 0);
|
let loanBalance = Math.max(studentLoanAmount, 0);
|
||||||
let loanPaidOffMonth = null;
|
let loanPaidOffMonth = null;
|
||||||
@ -238,55 +237,47 @@ export function simulateFinancialProjection(userProfile) {
|
|||||||
|
|
||||||
let projectionData = [];
|
let projectionData = [];
|
||||||
let wasInDeferral = inCollege && loanDeferralUntilGraduation;
|
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)
|
// For YTD taxes
|
||||||
// e.g. taxStateByYear[2025] = { federalYtdGross, federalYtdTaxSoFar, stateYtdGross, stateYtdTaxSoFar }
|
|
||||||
const taxStateByYear = {};
|
const taxStateByYear = {};
|
||||||
|
|
||||||
for (let month = 0; month < maxMonths; month++) {
|
for (let month = 0; month < maxMonths; month++) {
|
||||||
date.setMonth(date.getMonth() + 1);
|
date.setMonth(date.getMonth() + 1);
|
||||||
const currentYear = date.getFullYear();
|
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) {
|
if (loanBalance <= 0 && !loanPaidOffMonth) {
|
||||||
loanPaidOffMonth = `${currentYear}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
loanPaidOffMonth = `${currentYear}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Are we still in college?
|
// are we in college?
|
||||||
let stillInCollege = false;
|
let stillInCollege = false;
|
||||||
if (inCollege) {
|
if (inCollege) {
|
||||||
if (graduationDate) {
|
if (graduationDateObj) {
|
||||||
stillInCollege = date < graduationDate;
|
stillInCollege = date < graduationDateObj;
|
||||||
} else {
|
} else {
|
||||||
const simStart = startDate ? new Date(startDate) : new Date();
|
|
||||||
const elapsedMonths =
|
|
||||||
(date.getFullYear() - simStart.getFullYear()) * 12 +
|
|
||||||
(date.getMonth() - simStart.getMonth());
|
|
||||||
stillInCollege = (elapsedMonths < totalAcademicMonths);
|
stillInCollege = (elapsedMonths < totalAcademicMonths);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Tuition lumps
|
// 6. tuition lumps
|
||||||
let tuitionCostThisMonth = 0;
|
let tuitionCostThisMonth = 0;
|
||||||
if (stillInCollege && lumpsPerYear > 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 academicYearIndex = Math.floor(elapsedMonths / 12);
|
||||||
const monthInYear = elapsedMonths % 12;
|
const monthInYear = elapsedMonths % 12;
|
||||||
|
|
||||||
if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) {
|
if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) {
|
||||||
tuitionCostThisMonth = lumpAmount;
|
tuitionCostThisMonth = lumpAmount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Exiting college?
|
// 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 (stillInCollege && loanDeferralUntilGraduation) {
|
||||||
if (tuitionCostThisMonth > 0) {
|
if (tuitionCostThisMonth > 0) {
|
||||||
loanBalance += tuitionCostThisMonth;
|
loanBalance += tuitionCostThisMonth;
|
||||||
@ -294,15 +285,48 @@ export function simulateFinancialProjection(userProfile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Gross monthly income
|
// 9. Base monthly income
|
||||||
let grossMonthlyIncome = 0;
|
let grossMonthlyIncome = 0;
|
||||||
if (!inCollege || !stillInCollege) {
|
if (!stillInCollege) {
|
||||||
grossMonthlyIncome = (expectedSalary > 0 ? expectedSalary : currentSalary) / 12;
|
grossMonthlyIncome = (expectedSalary > 0 ? expectedSalary : currentSalary) / 12;
|
||||||
} else {
|
} else {
|
||||||
grossMonthlyIncome = (currentSalary / 12) + (partTimeIncome / 12);
|
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]) {
|
if (!taxStateByYear[currentYear]) {
|
||||||
taxStateByYear[currentYear] = {
|
taxStateByYear[currentYear] = {
|
||||||
federalYtdGross: 0,
|
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].federalYtdGross += grossMonthlyIncome;
|
||||||
taxStateByYear[currentYear].stateYtdGross += grossMonthlyIncome;
|
taxStateByYear[currentYear].stateYtdGross += grossMonthlyIncome;
|
||||||
|
|
||||||
// Compute total fed tax for the year so far
|
// fed tax
|
||||||
const newFedTaxTotal = calculateAnnualFederalTaxSingle(
|
const newFedTaxTotal = calculateAnnualFederalTaxSingle(
|
||||||
taxStateByYear[currentYear].federalYtdGross
|
taxStateByYear[currentYear].federalYtdGross
|
||||||
);
|
);
|
||||||
// Monthly fed tax = difference
|
|
||||||
const monthlyFederalTax = newFedTaxTotal - taxStateByYear[currentYear].federalYtdTaxSoFar;
|
const monthlyFederalTax = newFedTaxTotal - taxStateByYear[currentYear].federalYtdTaxSoFar;
|
||||||
taxStateByYear[currentYear].federalYtdTaxSoFar = newFedTaxTotal;
|
taxStateByYear[currentYear].federalYtdTaxSoFar = newFedTaxTotal;
|
||||||
|
|
||||||
// Compute total state tax for the year so far
|
// state tax
|
||||||
const newStateTaxTotal = calculateAnnualStateTax(
|
const newStateTaxTotal = calculateAnnualStateTax(
|
||||||
taxStateByYear[currentYear].stateYtdGross,
|
taxStateByYear[currentYear].stateYtdGross,
|
||||||
stateCode
|
stateCode
|
||||||
@ -332,26 +355,20 @@ export function simulateFinancialProjection(userProfile) {
|
|||||||
const monthlyStateTax = newStateTaxTotal - taxStateByYear[currentYear].stateYtdTaxSoFar;
|
const monthlyStateTax = newStateTaxTotal - taxStateByYear[currentYear].stateYtdTaxSoFar;
|
||||||
taxStateByYear[currentYear].stateYtdTaxSoFar = newStateTaxTotal;
|
taxStateByYear[currentYear].stateYtdTaxSoFar = newStateTaxTotal;
|
||||||
|
|
||||||
// Combined monthly tax
|
|
||||||
const combinedTax = monthlyFederalTax + monthlyStateTax;
|
const combinedTax = monthlyFederalTax + monthlyStateTax;
|
||||||
|
|
||||||
// Net monthly income after taxes
|
|
||||||
const netMonthlyIncome = grossMonthlyIncome - combinedTax;
|
const netMonthlyIncome = grossMonthlyIncome - combinedTax;
|
||||||
|
|
||||||
// 11. Expenses & loan
|
// 11. Expenses & loan
|
||||||
let thisMonthLoanPayment = 0;
|
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) {
|
if (nowExitingCollege) {
|
||||||
monthlyLoanPayment = calculateLoanPayment(
|
monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, 10);
|
||||||
loanBalance,
|
|
||||||
interestRate,
|
|
||||||
10
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not deferring, we do normal payments
|
// if deferring
|
||||||
if (stillInCollege && loanDeferralUntilGraduation) {
|
if (stillInCollege && loanDeferralUntilGraduation) {
|
||||||
const interestForMonth = loanBalance * (interestRate / 100 / 12);
|
const interestForMonth = loanBalance * (interestRate / 100 / 12);
|
||||||
loanBalance += interestForMonth;
|
loanBalance += interestForMonth;
|
||||||
@ -370,11 +387,11 @@ export function simulateFinancialProjection(userProfile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 12. leftover after mandatory expenses
|
// leftover after mandatory expenses
|
||||||
let leftover = netMonthlyIncome - totalMonthlyExpenses;
|
let leftover = netMonthlyIncome - totalMonthlyExpenses;
|
||||||
if (leftover < 0) leftover = 0;
|
if (leftover < 0) leftover = 0;
|
||||||
|
|
||||||
// Baseline contributions
|
// baseline contributions
|
||||||
const baselineContributions = monthlyRetirementContribution + monthlyEmergencyContribution;
|
const baselineContributions = monthlyRetirementContribution + monthlyEmergencyContribution;
|
||||||
let effectiveRetirementContribution = 0;
|
let effectiveRetirementContribution = 0;
|
||||||
let effectiveEmergencyContribution = 0;
|
let effectiveEmergencyContribution = 0;
|
||||||
@ -383,12 +400,9 @@ export function simulateFinancialProjection(userProfile) {
|
|||||||
effectiveRetirementContribution = monthlyRetirementContribution;
|
effectiveRetirementContribution = monthlyRetirementContribution;
|
||||||
effectiveEmergencyContribution = monthlyEmergencyContribution;
|
effectiveEmergencyContribution = monthlyEmergencyContribution;
|
||||||
leftover -= baselineContributions;
|
leftover -= baselineContributions;
|
||||||
} else {
|
|
||||||
effectiveRetirementContribution = 0;
|
|
||||||
effectiveEmergencyContribution = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check shortfall
|
// shortfall check
|
||||||
const totalWantedContributions = effectiveRetirementContribution + effectiveEmergencyContribution;
|
const totalWantedContributions = effectiveRetirementContribution + effectiveEmergencyContribution;
|
||||||
const actualExpensesPaid = totalMonthlyExpenses + totalWantedContributions;
|
const actualExpensesPaid = totalMonthlyExpenses + totalWantedContributions;
|
||||||
let shortfall = actualExpensesPaid - netMonthlyIncome;
|
let shortfall = actualExpensesPaid - netMonthlyIncome;
|
||||||
@ -397,7 +411,7 @@ export function simulateFinancialProjection(userProfile) {
|
|||||||
currentEmergencySavings -= canCover;
|
currentEmergencySavings -= canCover;
|
||||||
shortfall -= canCover;
|
shortfall -= canCover;
|
||||||
if (shortfall > 0) {
|
if (shortfall > 0) {
|
||||||
// bankrupt scenario
|
// bankrupt scenario, end
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -433,7 +447,7 @@ export function simulateFinancialProjection(userProfile) {
|
|||||||
loanPaymentThisMonth: Math.round(thisMonthLoanPayment * 100) / 100
|
loanPaymentThisMonth: Math.round(thisMonthLoanPayment * 100) / 100
|
||||||
});
|
});
|
||||||
|
|
||||||
wasInDeferral = (stillInCollege && loanDeferralUntilGraduation);
|
wasInDeferral = stillInCollege && loanDeferralUntilGraduation;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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