FinancialProfileForm updates to accommodate school/program/degree selection and addition of tuition field.
This commit is contained in:
parent
8dc4755911
commit
0ddc848371
@ -10,6 +10,9 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
|
||||
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
@ -374,11 +377,11 @@ app.post('/api/premium/financial-profile', authenticatePremiumUser, async (req,
|
||||
const {
|
||||
currentSalary, additionalIncome, monthlyExpenses, monthlyDebtPayments,
|
||||
retirementSavings, retirementContribution, emergencyFund,
|
||||
inCollege, expectedGraduation, partTimeIncome, tuitionPaid, collegeLoanTotal
|
||||
inCollege, expectedGraduation, partTimeIncome, tuitionPaid, collegeLoanTotal,
|
||||
careerPathId // ✅ Required to run simulation
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
// Upsert-style logic: Check if exists
|
||||
const existing = await db.get(`SELECT id FROM financial_profile WHERE user_id = ?`, [req.userId]);
|
||||
|
||||
if (existing) {
|
||||
@ -409,7 +412,36 @@ app.post('/api/premium/financial-profile', authenticatePremiumUser, async (req,
|
||||
]);
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'Financial profile saved.' });
|
||||
// ✅ Run projection only if careerPathId is provided
|
||||
if (!careerPathId) {
|
||||
return res.status(200).json({ message: 'Financial profile saved. No projection generated.' });
|
||||
}
|
||||
|
||||
const milestones = await db.all(
|
||||
`SELECT * FROM milestones WHERE user_id = ? AND career_path_id = ? ORDER BY date ASC`,
|
||||
[req.userId, careerPathId]
|
||||
);
|
||||
|
||||
const projectionData = simulateFinancialProjection({
|
||||
currentSalary,
|
||||
additionalIncome,
|
||||
monthlyExpenses,
|
||||
monthlyDebtPayments,
|
||||
retirementSavings,
|
||||
retirementContribution,
|
||||
emergencySavings: emergencyFund,
|
||||
studentLoanAmount: collegeLoanTotal,
|
||||
studentLoanAPR: 5.5, // placeholder default, can be user-supplied later
|
||||
loanTermYears: 10, // placeholder default, can be user-supplied later
|
||||
milestones,
|
||||
gradDate: expectedGraduation,
|
||||
fullTimeCollegeStudent: !!inCollege,
|
||||
partTimeIncome,
|
||||
startDate: moment()
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: 'Financial profile saved.', projectionData });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving financial profile:', error);
|
||||
res.status(500).json({ error: 'Failed to save financial profile.' });
|
||||
@ -417,6 +449,7 @@ app.post('/api/premium/financial-profile', authenticatePremiumUser, async (req,
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Retrieve career history
|
||||
app.get('/api/premium/career-history', authenticatePremiumUser, async (req, res) => {
|
||||
try {
|
||||
|
28
package-lock.json
generated
28
package-lock.json
generated
@ -16,6 +16,7 @@
|
||||
"axios": "^1.7.9",
|
||||
"bcrypt": "^5.1.1",
|
||||
"chart.js": "^4.4.7",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -26,6 +27,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.483.0",
|
||||
"moment": "^2.30.1",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@ -6191,6 +6193,15 @@
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-plugin-annotation": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
|
||||
"integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/check-types": {
|
||||
"version": "11.2.3",
|
||||
"resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz",
|
||||
@ -12830,6 +12841,15 @@
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@ -18429,9 +18449,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
||||
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
@ -18439,7 +18459,7 @@
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
|
@ -11,6 +11,7 @@
|
||||
"axios": "^1.7.9",
|
||||
"bcrypt": "^5.1.1",
|
||||
"chart.js": "^4.4.7",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -21,6 +22,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.483.0",
|
||||
"moment": "^2.30.1",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
36
src/App.js
36
src/App.js
@ -12,6 +12,7 @@ import MilestoneTracker from "./components/MilestoneTracker.js";
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
console.log("App rendered");
|
||||
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(() => {
|
||||
return !!localStorage.getItem('token'); // Check localStorage
|
||||
@ -29,37 +30,22 @@ function App() {
|
||||
<Route path="/signup" element={<SignUp />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/getting-started"
|
||||
element={isAuthenticated ? <GettingStarted /> : <Navigate to="/signin" />}
|
||||
/>
|
||||
<Route
|
||||
path="/interest-inventory"
|
||||
element={isAuthenticated ? <InterestInventory /> : <Navigate to="/signin" />}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={isAuthenticated ? <Dashboard /> : <Navigate to="/signin" />}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={isAuthenticated ? <UserProfile /> : <Navigate to="/signin" />}
|
||||
/>
|
||||
<Route
|
||||
path="/milestone-tracker"
|
||||
element={isAuthenticated ? <MilestoneTracker /> : <Navigate to="/signin" />}
|
||||
/>
|
||||
<Route
|
||||
path="/financial-profile"
|
||||
element={isAuthenticated ? <FinancialProfileForm /> : <Navigate to="/signin" />}
|
||||
/>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Route path="/getting-started" element={<GettingStarted />} />
|
||||
<Route path="/interest-inventory" element={<InterestInventory />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/profile" element={<UserProfile />} />
|
||||
<Route path="/milestone-tracker" element={<MilestoneTracker />} />
|
||||
<Route path="/financial-profile" element={<FinancialProfileForm />} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Catch-all for unknown routes */}
|
||||
<Route path="*" element={<Navigate to="/signin" />} />
|
||||
</Routes>
|
||||
<SessionExpiredHandler />
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -2,11 +2,21 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
|
||||
const AISuggestedMilestones = ({ userId, career, careerPathId, authFetch, activeView }) => {
|
||||
const AISuggestedMilestones = ({ userId, career, careerPathId, authFetch, activeView, projectionData }) => {
|
||||
const [suggestedMilestones, setSuggestedMilestones] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Array.isArray(projectionData)) {
|
||||
console.warn('⚠️ projectionData is not an array:', projectionData);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📊 projectionData sample:', projectionData.slice(0, 3));
|
||||
}, [projectionData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!career) return;
|
||||
setSuggestedMilestones([
|
||||
|
@ -1,55 +1,88 @@
|
||||
// Updated FinancialProfileForm.js with autosuggest for school and full field list restored
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
|
||||
|
||||
export default function FinancialProfileForm() {
|
||||
const location = useLocation();
|
||||
function FinancialProfileForm() {
|
||||
const navigate = useNavigate();
|
||||
console.log("🔍 FinancialProfileForm mounted");
|
||||
console.log("🔍 location.state:", location.state);
|
||||
const initialCareer = location?.state?.selectedCareer;
|
||||
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
|
||||
const userId = localStorage.getItem("userId");
|
||||
const [formData, setFormData] = useState({
|
||||
currentSalary: "",
|
||||
additionalIncome: "",
|
||||
monthlyExpenses: "",
|
||||
monthlyDebtPayments: "",
|
||||
retirementSavings: "",
|
||||
retirementContribution: "",
|
||||
emergencyFund: "",
|
||||
inCollege: false,
|
||||
expectedGraduation: "",
|
||||
partTimeIncome: "",
|
||||
tuitionPaid: "",
|
||||
collegeLoanTotal: ""
|
||||
});
|
||||
const location = useLocation();
|
||||
|
||||
const [userId] = useState(() => localStorage.getItem("userId"));
|
||||
const [selectedCareer] = useState(() => location.state?.selectedCareer || null);
|
||||
|
||||
const [currentSalary, setCurrentSalary] = useState("");
|
||||
const [additionalIncome, setAdditionalIncome] = useState("");
|
||||
const [monthlyExpenses, setMonthlyExpenses] = useState("");
|
||||
const [monthlyDebtPayments, setMonthlyDebtPayments] = useState("");
|
||||
const [retirementSavings, setRetirementSavings] = useState("");
|
||||
const [retirementContribution, setRetirementContribution] = useState("");
|
||||
const [emergencyFund, setEmergencyFund] = useState("");
|
||||
const [inCollege, setInCollege] = useState(false);
|
||||
const [expectedGraduation, setExpectedGraduation] = useState("");
|
||||
const [partTimeIncome, setPartTimeIncome] = useState("");
|
||||
const [tuitionPaid, setTuitionPaid] = useState("");
|
||||
const [collegeLoanTotal, setCollegeLoanTotal] = useState("");
|
||||
const [existingCollegeDebt, setExistingCollegeDebt] = useState("");
|
||||
const [creditHoursPerYear, setCreditHoursPerYear] = useState("");
|
||||
const [programType, setProgramType] = useState("");
|
||||
const [isFullyOnline, setIsFullyOnline] = useState(false);
|
||||
const [selectedSchool, setSelectedSchool] = useState("");
|
||||
const [selectedProgram, setSelectedProgram] = useState("");
|
||||
const [manualTuition, setManualTuition] = useState("");
|
||||
|
||||
const [schoolData, setSchoolData] = useState([]);
|
||||
const [schoolSuggestions, setSchoolSuggestions] = useState([]);
|
||||
const [programSuggestions, setProgramSuggestions] = useState([]);
|
||||
const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
|
||||
const [calculatedTuition, setCalculatedTuition] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("✅ selectedCareer in useEffect:", selectedCareer);
|
||||
}, [selectedCareer]);
|
||||
async function fetchSchoolData() {
|
||||
const res = await fetch('/cip_institution_mapping_new.json');
|
||||
const text = await res.text();
|
||||
const lines = text.split('\n');
|
||||
const parsed = lines.map(line => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
setSchoolData(parsed);
|
||||
}
|
||||
fetchSchoolData();
|
||||
}, []);
|
||||
|
||||
// Fetch existing data on mount
|
||||
useEffect(() => {
|
||||
async function fetchFinancialProfile() {
|
||||
try {
|
||||
const res = await authFetch("/api/premium/financial-profile", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
headers: { "Authorization": `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
} else {
|
||||
console.log("No existing financial profile. Starting fresh.");
|
||||
setCurrentSalary(data.current_salary || "");
|
||||
setAdditionalIncome(data.additional_income || "");
|
||||
setMonthlyExpenses(data.monthly_expenses || "");
|
||||
setMonthlyDebtPayments(data.monthly_debt_payments || "");
|
||||
setRetirementSavings(data.retirement_savings || "");
|
||||
setRetirementContribution(data.retirement_contribution || "");
|
||||
setEmergencyFund(data.emergency_fund || "");
|
||||
setInCollege(!!data.in_college);
|
||||
setExpectedGraduation(data.expected_graduation || "");
|
||||
setPartTimeIncome(data.part_time_income || "");
|
||||
setTuitionPaid(data.tuition_paid || "");
|
||||
setCollegeLoanTotal(data.college_loan_total || "");
|
||||
setExistingCollegeDebt(data.existing_college_debt || "");
|
||||
setCreditHoursPerYear(data.credit_hours_per_year || "");
|
||||
setProgramType(data.program_type || "");
|
||||
setIsFullyOnline(!!data.is_fully_online);
|
||||
setSelectedSchool(data.selected_school || "");
|
||||
setSelectedProgram(data.selected_program || "");
|
||||
}
|
||||
} else {
|
||||
console.warn("Response not OK when fetching financial profile:", res.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch financial profile", err);
|
||||
@ -59,17 +92,108 @@ export default function FinancialProfileForm() {
|
||||
fetchFinancialProfile();
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSchool && schoolData.length > 0) {
|
||||
// Filter programs for the selected school and display them as suggestions
|
||||
const programs = schoolData
|
||||
.filter(s => s.INSTNM.toLowerCase() === selectedSchool.toLowerCase())
|
||||
.map(s => s.CIPDESC);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value
|
||||
}));
|
||||
// Filter unique programs and show the first 10
|
||||
setProgramSuggestions([...new Set(programs)].slice(0, 10));
|
||||
}
|
||||
}, [selectedSchool, schoolData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProgram && selectedSchool && schoolData.length > 0) {
|
||||
const types = schoolData
|
||||
.filter(s => s.CIPDESC === selectedProgram && s.INSTNM.toLowerCase() === selectedSchool.toLowerCase())
|
||||
.map(s => s.CREDDESC);
|
||||
setAvailableProgramTypes([...new Set(types)]);
|
||||
}
|
||||
}, [selectedProgram, selectedSchool, schoolData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSchool && selectedProgram && programType && schoolData.length > 0) {
|
||||
const match = schoolData.find(s =>
|
||||
s.INSTNM.toLowerCase() === selectedSchool.toLowerCase() &&
|
||||
s.CIPDESC === selectedProgram &&
|
||||
s.CREDDESC === programType
|
||||
);
|
||||
const tuition = match ? parseFloat(match[isFullyOnline ? "Out State Graduate" : "In_state cost"] || 0) : 0;
|
||||
setCalculatedTuition(tuition);
|
||||
}
|
||||
}, [selectedSchool, selectedProgram, programType, isFullyOnline, schoolData]);
|
||||
|
||||
const handleSchoolChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedSchool(value);
|
||||
const filtered = schoolData.filter(s => s.INSTNM.toLowerCase().includes(value.toLowerCase()));
|
||||
const unique = [...new Set(filtered.map(s => s.INSTNM))];
|
||||
setSchoolSuggestions(unique.slice(0, 10));
|
||||
setSelectedProgram("");
|
||||
setAvailableProgramTypes([]);
|
||||
};
|
||||
|
||||
const handleSchoolSelect = (name) => {
|
||||
setSelectedSchool(name);
|
||||
setSchoolSuggestions([]);
|
||||
setSelectedProgram("");
|
||||
setAvailableProgramTypes([]);
|
||||
setProgramSuggestions([]);
|
||||
};
|
||||
|
||||
const handleProgramChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedProgram(value);
|
||||
|
||||
if (!value) {
|
||||
setProgramSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = schoolData.filter(s =>
|
||||
s.INSTNM.toLowerCase() === selectedSchool.toLowerCase() &&
|
||||
s.CIPDESC.toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
|
||||
const uniquePrograms = [...new Set(filtered.map(s => s.CIPDESC))];
|
||||
setProgramSuggestions(uniquePrograms);
|
||||
|
||||
const filteredTypes = schoolData.filter(s =>
|
||||
s.INSTNM.toLowerCase() === selectedSchool.toLowerCase() &&
|
||||
s.CIPDESC === value
|
||||
).map(s => s.CREDDESC);
|
||||
setAvailableProgramTypes([...new Set(filteredTypes)]);
|
||||
};
|
||||
|
||||
const handleProgramTypeSelect = (e) => {
|
||||
setProgramType(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = {
|
||||
currentSalary,
|
||||
additionalIncome,
|
||||
monthlyExpenses,
|
||||
monthlyDebtPayments,
|
||||
retirementSavings,
|
||||
retirementContribution,
|
||||
emergencyFund,
|
||||
inCollege,
|
||||
expectedGraduation,
|
||||
partTimeIncome,
|
||||
tuitionPaid,
|
||||
collegeLoanTotal,
|
||||
existingCollegeDebt,
|
||||
creditHoursPerYear,
|
||||
programType,
|
||||
isFullyOnline,
|
||||
selectedSchool,
|
||||
selectedProgram
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await authFetch("/api/premium/financial-profile", {
|
||||
method: "POST",
|
||||
@ -87,84 +211,120 @@ export default function FinancialProfileForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (setter) => (e) => setter(e.target.value);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto p-4 space-y-4 bg-white shadow rounded">
|
||||
<h2 className="text-xl font-semibold">Your Financial Profile</h2>
|
||||
|
||||
<div>
|
||||
<label className="block font-medium">Current Salary</label>
|
||||
<input name="currentSalary" type="number" value={formData.currentSalary} onChange={handleChange}
|
||||
className="w-full border rounded p-2" placeholder="$" />
|
||||
</div>
|
||||
<input type="number" value={currentSalary} onChange={handleInput(setCurrentSalary)} className="w-full border rounded p-2" placeholder="$" />
|
||||
|
||||
<div>
|
||||
<label className="block font-medium">Additional Monthly Income</label>
|
||||
<input name="additionalIncome" type="number" value={formData.additionalIncome} onChange={handleChange}
|
||||
className="w-full border rounded p-2" placeholder="$" />
|
||||
</div>
|
||||
<input type="number" value={additionalIncome} onChange={handleInput(setAdditionalIncome)} className="w-full border rounded p-2" placeholder="$" />
|
||||
|
||||
<div>
|
||||
<label className="block font-medium">Monthly Living Expenses</label>
|
||||
<input name="monthlyExpenses" type="number" value={formData.monthlyExpenses} onChange={handleChange}
|
||||
className="w-full border rounded p-2" placeholder="$" />
|
||||
</div>
|
||||
<input type="number" value={monthlyExpenses} onChange={handleInput(setMonthlyExpenses)} className="w-full border rounded p-2" placeholder="$" />
|
||||
|
||||
<div>
|
||||
<label className="block font-medium">Monthly Debt Payments (non-student)</label>
|
||||
<input name="monthlyDebtPayments" type="number" value={formData.monthlyDebtPayments} onChange={handleChange}
|
||||
className="w-full border rounded p-2" placeholder="$" />
|
||||
</div>
|
||||
<label className="block font-medium">Monthly Debt Payments</label>
|
||||
<input type="number" value={monthlyDebtPayments} onChange={handleInput(setMonthlyDebtPayments)} className="w-full border rounded p-2" placeholder="$" />
|
||||
|
||||
<div>
|
||||
<label className="block font-medium">Current Retirement Savings</label>
|
||||
<input name="retirementSavings" type="number" value={formData.retirementSavings} onChange={handleChange}
|
||||
className="w-full border rounded p-2" placeholder="$" />
|
||||
</div>
|
||||
<label className="block font-medium">Retirement Savings</label>
|
||||
<input type="number" value={retirementSavings} onChange={handleInput(setRetirementSavings)} className="w-full border rounded p-2" placeholder="$" />
|
||||
|
||||
<div>
|
||||
<label className="block font-medium">Monthly Retirement Contribution</label>
|
||||
<input name="retirementContribution" type="number" value={formData.retirementContribution} onChange={handleChange}
|
||||
className="w-full border rounded p-2" placeholder="$" />
|
||||
</div>
|
||||
<input type="number" value={retirementContribution} onChange={handleInput(setRetirementContribution)} className="w-full border rounded p-2" placeholder="$" />
|
||||
|
||||
<div>
|
||||
<label className="block font-medium">Emergency Fund Balance</label>
|
||||
<input name="emergencyFund" type="number" value={formData.emergencyFund} onChange={handleChange}
|
||||
className="w-full border rounded p-2" placeholder="$" />
|
||||
</div>
|
||||
<input type="number" value={emergencyFund} onChange={handleInput(setEmergencyFund)} className="w-full border rounded p-2" placeholder="$" />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<input id="inCollege" name="inCollege" type="checkbox" checked={formData.inCollege} onChange={handleChange} />
|
||||
<input id="inCollege" type="checkbox" checked={inCollege} onChange={(e) => setInCollege(e.target.checked)} />
|
||||
<label htmlFor="inCollege" className="font-medium">Are you currently in college?</label>
|
||||
</div>
|
||||
|
||||
{formData.inCollege && (
|
||||
{inCollege && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block font-medium">Expected Graduation Date</label>
|
||||
<input name="expectedGraduation" type="date" value={formData.expectedGraduation} onChange={handleChange}
|
||||
className="w-full border rounded p-2" />
|
||||
</div>
|
||||
{/* Selected School input with suggestions */}
|
||||
<label className="block font-medium">Selected School</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedSchool}
|
||||
onChange={handleSchoolChange}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="Search for a School"
|
||||
/>
|
||||
{schoolSuggestions.length > 0 && (
|
||||
<ul className="border rounded bg-white max-h-40 overflow-y-auto shadow-md">
|
||||
{schoolSuggestions.map((suggestion, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
onClick={() => handleSchoolSelect(suggestion)}
|
||||
className="p-2 hover:bg-blue-100 cursor-pointer"
|
||||
>
|
||||
{suggestion}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block font-medium">Part-Time Monthly Income</label>
|
||||
<input name="partTimeIncome" type="number" value={formData.partTimeIncome} onChange={handleChange}
|
||||
className="w-full border rounded p-2" placeholder="$" />
|
||||
</div>
|
||||
{/* Program input with suggestions */}
|
||||
<label className="block font-medium">Program</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedProgram}
|
||||
onChange={handleProgramChange}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="Search for a Program"
|
||||
/>
|
||||
{selectedProgram.length > 0 && programSuggestions.length > 0 && (
|
||||
<ul className="border rounded bg-white max-h-40 overflow-y-auto shadow-md">
|
||||
{programSuggestions.map((suggestion, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
setSelectedProgram(suggestion);
|
||||
setProgramSuggestions([]);
|
||||
const filteredTypes = schoolData.filter(s =>
|
||||
s.INSTNM.toLowerCase() === selectedSchool.toLowerCase() &&
|
||||
s.CIPDESC === suggestion
|
||||
).map(s => s.CREDDESC);
|
||||
setAvailableProgramTypes([...new Set(filteredTypes)]);
|
||||
}}
|
||||
className="p-2 hover:bg-blue-100 cursor-pointer"
|
||||
>
|
||||
{suggestion}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block font-medium">Tuition Paid So Far</label>
|
||||
<input name="tuitionPaid" type="number" value={formData.tuitionPaid} onChange={handleChange}
|
||||
className="w-full border rounded p-2" placeholder="$" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-medium">Total College Loan Amount</label>
|
||||
<input name="collegeLoanTotal" type="number" value={formData.collegeLoanTotal} onChange={handleChange}
|
||||
className="w-full border rounded p-2" placeholder="$" />
|
||||
</div>
|
||||
{/* Program Type input */}
|
||||
<label className="block font-medium">Program Type</label>
|
||||
<select
|
||||
value={programType}
|
||||
onChange={handleProgramTypeSelect}
|
||||
className="w-full border rounded p-2"
|
||||
>
|
||||
<option value="">Select Program Type</option>
|
||||
{availableProgramTypes.map((type, idx) => (
|
||||
<option key={idx} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<label className="block font-medium">Estimated Tuition (Override if needed)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={manualTuition || calculatedTuition}
|
||||
onChange={handleInput(setManualTuition)}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="Override tuition amount"
|
||||
/>
|
||||
</>
|
||||
|
||||
<div className="pt-4">
|
||||
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
|
||||
@ -174,3 +334,5 @@ export default function FinancialProfileForm() {
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default FinancialProfileForm;
|
||||
|
@ -38,7 +38,7 @@ function GettingStarted() {
|
||||
<div className="premium-access">
|
||||
<h3>Already know your path?</h3>
|
||||
<p>You can skip ahead and begin planning your milestones now.</p>
|
||||
<button className="premium-button" onClick={() => navigate('/milestone-tracker', { state: { fromGettingStarted: true } })}>
|
||||
<button className="premium-button" onClick={() => navigate('/financial-profile', { state: { fromGettingStarted: true } })}>
|
||||
Access Milestone Tracker <span className="premium-label">(Premium)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -179,6 +179,17 @@ const filteredMilestones = raw.filter(
|
||||
<input type="text" placeholder="Description" value={newMilestone.description} onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })} />
|
||||
<input type="date" value={newMilestone.date} onChange={(e) => setNewMilestone({ ...newMilestone, date: e.target.value })} />
|
||||
<input type="number" placeholder="Progress (%)" value={newMilestone.progress} onChange={(e) => setNewMilestone({ ...newMilestone, progress: parseInt(e.target.value, 10) })} />
|
||||
{activeView === 'Financial' && (
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Full New Salary (e.g., 70000)"
|
||||
value={newMilestone.newSalary || ''}
|
||||
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: parseFloat(e.target.value) })}
|
||||
/>
|
||||
<p>Enter the full new salary (not just the change) after the milestone has taken place.</p>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={saveMilestone}>{editingMilestone ? 'Update' : 'Add'} Milestone</button>
|
||||
</div>
|
||||
)}
|
||||
|
@ -2,12 +2,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { Chart as ChartJS, LineElement, CategoryScale, LinearScale, PointElement, Tooltip, Legend } from 'chart.js';
|
||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||
import { Filler } from 'chart.js';
|
||||
|
||||
import CareerSelectDropdown from './CareerSelectDropdown.js';
|
||||
import CareerSearch from './CareerSearch.js';
|
||||
import MilestoneTimeline from './MilestoneTimeline.js';
|
||||
import AISuggestedMilestones from './AISuggestedMilestones.js';
|
||||
import './MilestoneTracker.css';
|
||||
import './MilestoneTimeline.css'; // Ensure this file contains styles for timeline-line and milestone-dot
|
||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||||
|
||||
ChartJS.register( LineElement, CategoryScale, LinearScale, Filler, PointElement, Tooltip, Legend, annotationPlugin );
|
||||
|
||||
|
||||
|
||||
const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
||||
const location = useLocation();
|
||||
@ -19,10 +29,15 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
||||
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false);
|
||||
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
|
||||
const [activeView, setActiveView] = useState("Career");
|
||||
const [projectionData, setProjectionData] = useState(null);
|
||||
const [financialProfile, setFinancialProfile] = useState(null); // Store the financial profile
|
||||
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
|
||||
|
||||
|
||||
|
||||
const apiURL = process.env.REACT_APP_API_URL;
|
||||
|
||||
|
||||
const authFetch = async (url, options = {}) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
@ -72,9 +87,42 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFinancialProfile = async () => {
|
||||
const res = await authFetch(`${apiURL}/premium/financial-profile`);
|
||||
if (res && res.ok) {
|
||||
const data = await res.json();
|
||||
setFinancialProfile(data); // Set the financial profile in state
|
||||
}
|
||||
};
|
||||
|
||||
fetchCareerPaths();
|
||||
fetchFinancialProfile();
|
||||
}, []);
|
||||
|
||||
// Calculate financial projection based on the profile
|
||||
useEffect(() => {
|
||||
if (financialProfile && selectedCareer) {
|
||||
const { projectionData, loanPaidOffMonth } = simulateFinancialProjection({
|
||||
currentSalary: financialProfile.current_salary,
|
||||
monthlyExpenses: financialProfile.monthly_expenses,
|
||||
studentLoanAmount: financialProfile.college_loan_total,
|
||||
studentLoanAPR: 5.5, // Default rate
|
||||
loanTermYears: 10, // Default term
|
||||
emergencySavings: financialProfile.emergency_fund,
|
||||
retirementSavings: financialProfile.retirement_savings,
|
||||
monthlyRetirementContribution: financialProfile.retirement_contribution,
|
||||
monthlyEmergencyContribution: 0, // Add emergency savings contribution if available
|
||||
gradDate: financialProfile.expected_graduation,
|
||||
fullTimeCollegeStudent: financialProfile.in_college,
|
||||
partTimeIncome: financialProfile.part_time_income,
|
||||
startDate: new Date(),
|
||||
});
|
||||
setProjectionData(projectionData);
|
||||
setLoanPayoffMonth(loanPaidOffMonth); // Set the projection data
|
||||
}
|
||||
}, [financialProfile, selectedCareer]);
|
||||
|
||||
const handleCareerChange = (selected) => {
|
||||
if (selected && selected.id && selected.career_name) {
|
||||
setSelectedCareer(selected);
|
||||
@ -84,6 +132,8 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
||||
}
|
||||
};
|
||||
|
||||
console.log("📊 projectionData sample:", projectionData?.slice(0, 5));
|
||||
|
||||
const handleConfirmCareerSelection = async () => {
|
||||
const newId = uuidv4();
|
||||
const body = { career_path_id: newId, career_name: pendingCareerForModal, start_date: new Date().toISOString().split('T')[0] };
|
||||
@ -116,7 +166,90 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
||||
<MilestoneTimeline careerPathId={careerPathId} authFetch={authFetch} activeView={activeView} setActiveView={setActiveView} />
|
||||
{console.log('Passing careerPathId to MilestoneTimeline:', careerPathId)}
|
||||
|
||||
<AISuggestedMilestones career={selectedCareer?.career_name} careerPathId={careerPathId} authFetch={authFetch} activeView={activeView}/>
|
||||
<AISuggestedMilestones career={selectedCareer?.career_name} careerPathId={careerPathId} authFetch={authFetch} activeView={activeView} projectionData={projectionData}/>
|
||||
|
||||
{projectionData && (
|
||||
<div className="bg-white p-4 mt-6 rounded shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
|
||||
<Line
|
||||
data={{
|
||||
labels: projectionData.map(p => p.month),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Net Savings',
|
||||
data: projectionData.map(p => p.netSavings),
|
||||
borderColor: 'rgba(54, 162, 235, 1)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Loan Balance',
|
||||
data: projectionData.map(p => p.loanBalance),
|
||||
borderColor: 'rgba(255, 99, 132, 1)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
||||
tension: 0.4,
|
||||
fill: {
|
||||
target: 'origin',
|
||||
above: 'rgba(255,99,132,0.3)', // loan debt
|
||||
below: 'transparent' // don't show below 0
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Retirement Savings',
|
||||
data: projectionData.map(p => p.totalRetirementSavings),
|
||||
borderColor: 'rgba(75, 192, 192, 1)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
}}
|
||||
options={{
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
tooltip: { mode: 'index', intersect: false },
|
||||
annotation: loanPayoffMonth
|
||||
? {
|
||||
annotations: {
|
||||
loanPaidOffLine: {
|
||||
type: 'line',
|
||||
xMin: loanPayoffMonth,
|
||||
xMax: loanPayoffMonth,
|
||||
borderColor: 'rgba(255, 206, 86, 1)',
|
||||
borderWidth: 2,
|
||||
borderDash: [6, 6],
|
||||
label: {
|
||||
display: true,
|
||||
content: 'Loan Paid Off',
|
||||
position: 'end',
|
||||
backgroundColor: 'rgba(255, 206, 86, 0.8)',
|
||||
color: '#000',
|
||||
font: {
|
||||
style: 'bold',
|
||||
size: 12
|
||||
},
|
||||
rotation: 0,
|
||||
yAdjust: -10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: (value) => `$${value.toLocaleString()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CareerSearch
|
||||
onSelectCareer={(careerName) => setPendingCareerForModal(careerName)}
|
||||
|
110
src/utils/FinancialProjectionService.js
Normal file
110
src/utils/FinancialProjectionService.js
Normal file
@ -0,0 +1,110 @@
|
||||
import moment from 'moment';
|
||||
|
||||
// Function to simulate monthly financial projection
|
||||
// src/utils/FinancialProjectionService.js
|
||||
|
||||
export function simulateFinancialProjection(userProfile) {
|
||||
const {
|
||||
currentSalary,
|
||||
monthlyExpenses,
|
||||
studentLoanAmount,
|
||||
studentLoanAPR,
|
||||
loanTermYears,
|
||||
milestones = [],
|
||||
emergencySavings,
|
||||
retirementSavings,
|
||||
monthlyRetirementContribution,
|
||||
monthlyEmergencyContribution,
|
||||
gradDate,
|
||||
fullTimeCollegeStudent,
|
||||
partTimeIncome = 0,
|
||||
startDate = new Date()
|
||||
} = userProfile;
|
||||
|
||||
const monthlyLoanPayment = calculateLoanPayment(studentLoanAmount, studentLoanAPR, loanTermYears);
|
||||
|
||||
let totalEmergencySavings = emergencySavings;
|
||||
let totalRetirementSavings = retirementSavings;
|
||||
let loanBalance = studentLoanAmount;
|
||||
let monthlyIncome = currentSalary;
|
||||
let projectionData = [];
|
||||
|
||||
const graduationDate = gradDate ? new Date(gradDate) : null;
|
||||
let milestoneIndex = 0;
|
||||
let loanPaidOffMonth = null;
|
||||
|
||||
const date = new Date(startDate);
|
||||
for (let month = 0; month < 240; month++) {
|
||||
// ✅ Advance date forward by one month
|
||||
date.setMonth(date.getMonth() + 1);
|
||||
|
||||
if (loanBalance <= 0 && !loanPaidOffMonth) {
|
||||
loanPaidOffMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Check for milestone
|
||||
if (
|
||||
milestoneIndex < milestones.length &&
|
||||
new Date(milestones[milestoneIndex].date) <= date
|
||||
) {
|
||||
const milestone = milestones[milestoneIndex];
|
||||
if (milestone.type === 'salary') {
|
||||
monthlyIncome = milestone.newSalary;
|
||||
}
|
||||
milestoneIndex++;
|
||||
}
|
||||
|
||||
// If in college before graduation, adjust income
|
||||
if (graduationDate && date < graduationDate) {
|
||||
monthlyIncome = fullTimeCollegeStudent ? 0 : partTimeIncome;
|
||||
}
|
||||
|
||||
let thisMonthLoanPayment = 0;
|
||||
|
||||
if (loanBalance > 0) {
|
||||
const interestForMonth = loanBalance * (studentLoanAPR / 100 / 12);
|
||||
const principalForMonth = Math.min(loanBalance, monthlyLoanPayment - interestForMonth);
|
||||
|
||||
loanBalance -= principalForMonth;
|
||||
loanBalance = Math.max(loanBalance, 0);
|
||||
|
||||
thisMonthLoanPayment = monthlyLoanPayment;
|
||||
}
|
||||
|
||||
let extraCash = monthlyLoanPayment - thisMonthLoanPayment;
|
||||
|
||||
totalEmergencySavings += monthlyEmergencyContribution + (extraCash * 0.3); // 30% redirect
|
||||
totalRetirementSavings += monthlyRetirementContribution + (extraCash * 0.7); // 70% redirect
|
||||
totalRetirementSavings *= (1 + 0.07 / 12); // compound growth
|
||||
|
||||
projectionData.push({
|
||||
month: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`,
|
||||
salary: monthlyIncome,
|
||||
monthlyIncome: monthlyIncome / 12,
|
||||
expenses: monthlyExpenses,
|
||||
loanPayment: monthlyLoanPayment,
|
||||
retirementContribution: monthlyRetirementContribution,
|
||||
emergencyContribution: monthlyEmergencyContribution,
|
||||
netSavings:
|
||||
monthlyIncome -
|
||||
(monthlyExpenses +
|
||||
thisMonthLoanPayment +
|
||||
monthlyRetirementContribution +
|
||||
monthlyEmergencyContribution),
|
||||
|
||||
totalEmergencySavings,
|
||||
totalRetirementSavings,
|
||||
loanBalance
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
return { projectionData, loanPaidOffMonth };
|
||||
|
||||
}
|
||||
|
||||
function calculateLoanPayment(principal, annualRate, years) {
|
||||
const monthlyRate = annualRate / 100 / 12;
|
||||
const numPayments = years * 12;
|
||||
return (principal * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -numPayments));
|
||||
}
|
@ -12,11 +12,14 @@ const authFetch = async (url, options = {}) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const method = options.method?.toUpperCase() || 'GET';
|
||||
const shouldIncludeContentType = ['POST', 'PUT', 'PATCH'].includes(method);
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(shouldIncludeContentType && { 'Content-Type': 'application/json' }),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user