Cross-app navigation UI fixes

This commit is contained in:
Josh 2025-06-16 17:05:44 +00:00
parent 2728378041
commit 0bad162d52
6 changed files with 403 additions and 542 deletions

View File

@ -20,7 +20,7 @@ import annotationPlugin from 'chartjs-plugin-annotation';
import MilestonePanel from './MilestonePanel.js';
import MilestoneEditModal from './MilestoneEditModal.js';
import buildChartMarkers from '../utils/buildChartMarkers.js';
import getMissingFields from '../utils/MissingFields.js';
import getMissingFields from '../utils/getMissingFields.js';
import 'chartjs-adapter-date-fns';
import authFetch from '../utils/authFetch.js';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
@ -37,6 +37,9 @@ import './MilestoneTimeline.css';
const apiUrl = process.env.REACT_APP_API_URL || '';
// --------------
// Register ChartJS Plugins
// --------------
@ -58,6 +61,7 @@ ChartJS.register(
* Helpers for remember last career logic
* ----------------------------------------------------------- */
// (A) getAllCareerProfiles one small wrapper around the endpoint
async function getAllCareerProfiles() {
const res = await authFetch('/api/premium/career-profile/all');
@ -116,6 +120,17 @@ async function createCareerProfileFromSearch(selCareer) {
// --------------
// Helper Functions
// --------------
function shouldSkipModalOnce(profileId) {
const key = `skipMissingModalFor`;
const stored = sessionStorage.getItem(key);
if (stored && stored === String(profileId)) {
sessionStorage.removeItem(key); // one-time use
return true;
}
return false;
}
function stripSocCode(fullSoc) {
if (!fullSoc) return '';
return fullSoc.split('.')[0];
@ -338,7 +353,6 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
const [milestoneForModal, setMilestoneForModal] = useState(null);
const [hasPrompted, setHasPrompted] = useState(false);
// Config
const [simulationYearsInput, setSimulationYearsInput] = useState('20');
@ -361,6 +375,24 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
loanPayoffMonth: initLoanMonth = null
} = location.state || {};
const reloadScenarioAndCollege = useCallback(async () => {
if (!careerProfileId) return;
const s = await authFetch(
`${apiURL}/premium/career-profile/${careerProfileId}`
);
if (s.ok) {
const row = await s.json();
if (!row.college_enrollment_status)
row.college_enrollment_status = "not_enrolled";
setScenarioRow(row);
}
const c = await authFetch(
`${apiURL}/premium/college-profile?careerProfileId=${careerProfileId}`
);
if (c.ok) setCollegeProfile(await c.json());
}, [careerProfileId, apiURL]);
const milestoneGroups = useMemo(() => {
if (!scenarioMilestones.length) return [];
@ -435,44 +467,57 @@ const xAndYScales = {
}
};
/*
* ONE-TIME MISSING FIELDS GUARD
* modalGuard.current = { checked: bool, skip: bool }
* checked we already ran the test for this profile
* skip suppress first check (set by onboarding OR
* by sessionStorage flag for this profile)
* */
const modalGuard = useRef({ checked: false, skip: false });
// 1) Fetch user + financial
useEffect(() => {
async function fetchUser() {
try {
const r = await authFetch('/api/user-profile');
if (r.ok) setUserProfile(await r.json());
} catch (err) {
console.error('Error user-profile =>', err);
}
}
async function fetchFin() {
try {
const r = await authFetch(`${apiURL}/premium/financial-profile`);
if (r.ok) setFinancialProfile(await r.json());
} catch (err) {
console.error('Error financial =>', err);
}
}
fetchUser();
fetchFin();
}, []);
/* -------------------------------------------------------------
* 0) If we landed here via onboarding, skip the very first check
* ------------------------------------------------------------*/
useEffect(() => {
if (location.state?.fromOnboarding) {
modalGuard.current.skip = true; // suppress once
window.history.replaceState({}, '', location.pathname);
}
}, [location.state, location.pathname]);
const userSalary = parseFloatOrZero(financialProfile?.current_salary, 0);
const userArea = userProfile?.area || 'U.S.';
const userState = getFullStateName(userProfile?.state || '') || 'United States';
/* -------------------------------------------------------------
* 1) Fetch user + financial on first mount
* ------------------------------------------------------------*/
useEffect(() => {
(async () => {
const up = await authFetch('/api/user-profile');
if (up.ok) setUserProfile(await up.json());
useEffect(() => {
if (careerId) {
setCareerProfileId(careerId);
localStorage.setItem('lastSelectedCareerProfileId', careerId);
} else {
// first visit with no id → try LS fallback
const stored = localStorage.getItem('lastSelectedCareerProfileId');
if (stored) setCareerProfileId(stored);
}
}, [careerId]);
const fp = await authFetch(`${apiURL}/premium/financial-profile`);
if (fp.ok) setFinancialProfile(await fp.json());
})();
}, [apiURL]);
/* quick derived helpers */
const userSalary = parseFloatOrZero(financialProfile?.current_salary);
const userArea = userProfile?.area || 'U.S.';
const userState = getFullStateName(userProfile?.state || '') || 'United States';
/* -------------------------------------------------------------
* 2) Determine the active careerProfileId once
* ------------------------------------------------------------*/
useEffect(() => {
let id = careerId;
if (!id) id = localStorage.getItem('lastSelectedCareerProfileId');
if (id) {
setCareerProfileId(id);
localStorage.setItem('lastSelectedCareerProfileId', id);
// one-shot modal skip from sessionStorage
modalGuard.current.skip ||= shouldSkipModalOnce(id);
}
}, [careerId]);
useEffect(() => {
let timer;
@ -482,40 +527,59 @@ const xAndYScales = {
return () => clearTimeout(timer);
}, [buttonDisabled]);
useEffect(() => {
const storedRecs = localStorage.getItem('aiRecommendations');
if (storedRecs) {
try {
const arr = JSON.parse(storedRecs);
arr.forEach((m) => {
if (!m.id) {
m.id = crypto.randomUUID();
}
});
setRecommendations(arr);
} catch (err) {
console.error('Error parsing stored AI recs =>', err);
}
}
}, []);
/* ------------------------------------------------------------------
* 1) Restore AI recommendations (unchanged behaviour)
* -----------------------------------------------------------------*/
useEffect(() => {
const json = localStorage.getItem('aiRecommendations');
if (!json) return;
useEffect(() => {
// Wait until all three profiles have loaded at least once
if (!scenarioRow || !financialProfile || collegeProfile === null) return;
if (hasPrompted) return; // dont pop it again
const missing = getMissingFields({
scenario : scenarioRow,
financial: financialProfile,
college : collegeProfile
});
if (missing.length > 0) {
setShowEditModal(true); // open modal
setHasPrompted(true); // flag so its one-time
try {
const arr = JSON.parse(json).map((m) => ({
...m,
id: m.id || crypto.randomUUID()
}));
setRecommendations(arr);
} catch (err) {
console.error('Error parsing stored AI recs', err);
}
}, [scenarioRow, financialProfile, collegeProfile, hasPrompted]);
}, []);
/* ------------------------------------------------------------------
* 2) Whenever the careerProfileId changes, clear the modal check flag
* -----------------------------------------------------------------*/
useEffect(() => {
modalGuard.current.checked = false;
}, [careerProfileId]);
/* ------------------------------------------------------------------
* 3) Missing-fields modal single authoritative effect
* -----------------------------------------------------------------*/
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null;
useEffect(() => {
const guard = modalGuard.current;
if (!dataReady || guard.checked) return;
/* honour skip flag (one-time after onboarding / sessionStorage) */
if (guard.skip) {
guard.skip = false; // consume it
guard.checked = true;
return;
}
const status = (scenarioRow.college_enrollment_status || '').toLowerCase();
const requireCollege = ['currently_enrolled', 'prospective_student', 'deferred'].includes(status);
const missing = getMissingFields(
{ scenario: scenarioRow, financial: financialProfile, college: collegeProfile },
{ requireCollegeData: requireCollege }
);
if (missing.length) setShowEditModal(true);
guard.checked = true; // ensure we dont rerun
}, [dataReady, scenarioRow, financialProfile, collegeProfile]);
useEffect(() => {
if (recommendations.length > 0) {
@ -612,31 +676,29 @@ useEffect(() => {
}, [location.key, careerId]);
// 4) scenarioRow + college
useEffect(() => {
/** ---------------------------------------------------------------
* bail out IMMEDIATELY until we have a *real* id
* (the rest of the body never even runs)
* ------------------------------------------------------------- */
if (!careerProfileId) return; // ← nothing gets fetched
/* ------------------------------------------------------------------
* 4) refresh scenario + college whenever the active profile-id changes
* -----------------------------------------------------------------*/
useEffect(() => {
if (!careerProfileId) return; // nothing to fetch
setScenarioRow(null); // clear stale data
// clear any stale UI traces while the new fetch runs
setScenarioRow(null);
setCollegeProfile(null);
setScenarioMilestones([]);
localStorage.setItem('lastSelectedCareerProfileId', careerProfileId);
// remember for other tabs / future visits
localStorage.setItem('lastSelectedCareerProfileId', careerProfileId);
async function fetchScenario() {
const s = await authFetch(`${apiURL}/premium/career-profile/${careerProfileId}`);
if (s.ok) setScenarioRow(await s.json());
}
async function fetchCollege() {
const c = await authFetch(`${apiURL}/premium/college-profile?careerProfileId=${careerProfileId}`);
if (c.ok) setCollegeProfile(await c.json());
}
fetchScenario();
fetchCollege();
}, [careerProfileId]);
// fetch both rows in parallel (defined via useCallback)
reloadScenarioAndCollege();
}, [careerProfileId, reloadScenarioAndCollege]);
const refetchScenario = useCallback(async () => {
if (!careerProfileId) return;
const r = await authFetch(`${apiURL}/premium/career-profile/${careerProfileId}`);
if (r.ok) setScenarioRow(await r.json());
}, [careerProfileId, apiURL]);
// 5) from scenarioRow => find the full SOC => strip
useEffect(() => {
@ -749,56 +811,59 @@ try {
return aiRisk;
}
// 6) Salary
useEffect(() => {
if (!strippedSocCode) {
setSalaryData(null);
return;
}
(async () => {
try {
const qs = new URLSearchParams({ socCode: strippedSocCode, area: userArea }).toString();
const url = `${apiURL}/salary?${qs}`;
const r = await fetch(url);
if (!r.ok) {
console.error('[Salary fetch non-200 =>]', r.status);
setSalaryData(null);
return;
}
const dd = await r.json();
setSalaryData(dd);
} catch (err) {
console.error('[Salary fetch error]', err);
setSalaryData(null);
}
})();
}, [strippedSocCode, userArea]);
/* 6) Salary ------------------------------------------------------- */
useEffect(() => {
// show blank state instantly whenever the SOC or area changes
setSalaryData(null);
if (!strippedSocCode) return;
const ctrl = new AbortController();
(async () => {
try {
const qs = new URLSearchParams({ socCode: strippedSocCode, area: userArea });
const res = await fetch(`${apiURL}/salary?${qs}`, { signal: ctrl.signal });
// 7) Econ
useEffect(() => {
if (!strippedSocCode || !userState) {
setEconomicProjections(null);
return;
}
(async () => {
const qs = new URLSearchParams({ state: userState }).toString();
const econUrl = `${apiURL}/projections/${strippedSocCode}?${qs}`;
try {
const r = await authFetch(econUrl);
if (!r.ok) {
console.error('[Econ fetch non-200 =>]', r.status);
setEconomicProjections(null);
return;
}
const econData = await r.json();
setEconomicProjections(econData);
} catch (err) {
console.error('[Econ fetch error]', err);
setEconomicProjections(null);
if (res.ok) {
setSalaryData(await res.json());
} else {
console.error('[Salary fetch]', res.status);
}
})();
}, [strippedSocCode, userState]);
} catch (e) {
if (e.name !== 'AbortError') console.error('[Salary fetch error]', e);
}
})();
// cancel if strippedSocCode / userArea changes before the fetch ends
return () => ctrl.abort();
}, [strippedSocCode, userArea, apiURL]);
/* 7) Economic Projections ---------------------------------------- */
useEffect(() => {
setEconomicProjections(null);
if (!strippedSocCode || !userState) return;
const ctrl = new AbortController();
(async () => {
try {
const qs = new URLSearchParams({ state: userState });
const res = await authFetch(
`${apiURL}/projections/${strippedSocCode}?${qs}`,
{ signal: ctrl.signal }
);
if (res.ok) {
setEconomicProjections(await res.json());
} else {
console.error('[Econ fetch]', res.status);
}
} catch (e) {
if (e.name !== 'AbortError') console.error('[Econ fetch error]', e);
}
})();
return () => ctrl.abort();
}, [strippedSocCode, userState, apiURL]);
// 8) Build financial projection
async function buildProjection() {
@ -1331,20 +1396,23 @@ const fetchMilestones = useCallback(async () => {
Edit Simulation Inputs
</Button>
</div>
<ScenarioEditModal
show={showEditModal}
onClose={() => {
setShowEditModal(false);
window.location.reload();
}}
scenario={scenarioRow}
financialProfile={financialProfile}
setFinancialProfile={setFinancialProfile}
collegeProfile={collegeProfile}
setCollegeProfile={setCollegeProfile}
apiURL={apiURL}
authFetch={authFetch}
/>
<ScenarioEditModal
key={careerProfileId}
show={showEditModal}
onClose={(didSave) => {
setShowEditModal(false);
if (didSave) reloadScenarioAndCollege(); // 👈 refresh after save
}}
scenario={scenarioRow}
financialProfile={financialProfile}
setFinancialProfile={setFinancialProfile}
collegeProfile={collegeProfile}
setCollegeProfile={setCollegeProfile}
apiURL={apiURL}
authFetch={authFetch}
/>
{/* (E1) Interest Strategy */}
<label className="ml-4 font-medium">Interest Rate:</label>

View File

@ -183,8 +183,18 @@ const OnboardingContainer = () => {
);
}
// Navigate somewhere
navigate('/career-roadmap');
const picked = { code: careerData.soc_code, title: careerData.career_name }
// 🚀 right before you navigate away from the review page
sessionStorage.setItem('skipMissingModalFor', String(finalCareerProfileId));
localStorage.setItem('selectedCareer', JSON.stringify(picked));
localStorage.removeItem('lastSelectedCareerProfileId');
navigate(`/career-roadmap/${finalCareerProfileId}`, {
state: { fromOnboarding: true,
selectedCareer : picked
}
});
} catch (err) {
console.error('Error in final submit =>', err);

View File

@ -120,7 +120,8 @@ export default function ScenarioEditModal({
}, [show]);
/*********************************************************
* 7) If scenario + collegeProfile => fill form
* 7) Whenever the **modal is shown** *or* **scenario.id changes**
+ * hydrate the form + careerSearch box.
*********************************************************/
useEffect(() => {
if (!show || !scenario) return;
@ -157,7 +158,7 @@ export default function ScenarioEditModal({
is_in_state: !!c.is_in_state,
is_in_district: !!c.is_in_district,
is_online: !!c.is_in_online,
is_online: !!c.is_online,
college_enrollment_status_db: c.college_enrollment_status || 'not_enrolled',
annual_financial_aid: c.annual_financial_aid ?? '',
@ -197,7 +198,7 @@ export default function ScenarioEditModal({
}
setCareerSearchInput(s.career_name || '');
}, [show, scenario, collegeProfile]);
}, [show, scenario?.id, collegeProfile]);
/*********************************************************
* 8) Auto-calc placeholders (stubbed out)
@ -230,18 +231,36 @@ export default function ScenarioEditModal({
/*********************************************************
* 9) Career auto-suggest
*********************************************************/
useEffect(() => {
if (!show) return;
if (!careerSearchInput.trim()) {
setCareerMatches([]);
return;
}
const lower = careerSearchInput.toLowerCase();
const partials = allCareers
.filter((title) => title.toLowerCase().includes(lower))
.slice(0, 15);
setCareerMatches(partials);
}, [show, careerSearchInput, allCareers]);
/*********************************************************
* 9) Career auto-suggest
*********************************************************/
useEffect(() => {
if (!show) return;
// 1⃣ trim once, reuse everywhere
const typed = careerSearchInput.trim();
// Nothing typed → clear list
if (!typed) {
setCareerMatches([]);
return;
}
/* 2⃣ Exact match (case-insensitive) → suppress dropdown */
if (allCareers.some(t => t.toLowerCase() === typed.toLowerCase())) {
setCareerMatches([]);
return;
}
// 3⃣ Otherwise show up to 15 partial matches
const lower = typed.toLowerCase();
const partials = allCareers
.filter(title => title.toLowerCase().includes(lower))
.slice(0, 15);
setCareerMatches(partials);
}, [show, careerSearchInput, allCareers]);
/*********************************************************
* 9.5) Program Type from CIP
@ -436,367 +455,75 @@ export default function ScenarioEditModal({
/*********************************************************
* 12) handleSave => upsert scenario & college => re-fetch => simulate
*********************************************************/
async function handleSave() {
try {
function parseNumberIfGiven(val) {
if (val == null) return undefined;
const valStr = String(val).trim();
if (valStr === '') return undefined;
const num = Number(valStr);
return isNaN(num) ? undefined : num;
}
async function handleSave() {
try {
/* ─── helpers ───────────────────────────────────────────── */
const n = v => (v === "" || v == null ? undefined : Number(v));
const s = v => {
if (v == null) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
};
function parseStringIfGiven(val) {
if (val == null) return undefined;
const trimmed = String(val).trim();
return trimmed === '' ? undefined : trimmed;
}
/* ─── 0) did the user change the title? ─────────────────── */
const originalName = scenario?.career_name?.trim() || "";
const editedName = (formData.career_name || "").trim();
const titleChanged = editedName && editedName !== originalName;
const chosenTuitionVal =
manualTuition.trim() !== '' ? Number(manualTuition) : undefined;
const chosenProgLengthVal =
manualProgLength.trim() !== '' ? Number(manualProgLength) : undefined;
/* ─── 1) build scenario payload ─────────────────────────── */
const scenarioPayload = {
scenario_title : s(formData.scenario_title),
career_name : editedName, // always include
college_enrollment_status : formData.college_enrollment_status,
currently_working : formData.currently_working || "no",
status : s(formData.status),
start_date : s(formData.start_date),
projected_end_date : s(formData.projected_end_date),
// Sync scenario's enrollment status with college row
let finalCollegeStatus = formData.college_enrollment_status_db;
if (
formData.college_enrollment_status === 'currently_enrolled' ||
formData.college_enrollment_status === 'prospective_student'
) {
finalCollegeStatus = formData.college_enrollment_status;
} else {
finalCollegeStatus = 'not_enrolled';
}
planned_monthly_expenses : n(formData.planned_monthly_expenses),
planned_monthly_debt_payments : n(formData.planned_monthly_debt_payments),
planned_monthly_retirement_contribution: n(formData.planned_monthly_retirement_contribution),
planned_monthly_emergency_contribution : n(formData.planned_monthly_emergency_contribution),
planned_surplus_emergency_pct : n(formData.planned_surplus_emergency_pct),
planned_surplus_retirement_pct : n(formData.planned_surplus_retirement_pct),
planned_additional_income : n(formData.planned_additional_income)
};
// Build scenario payload
const scenarioPayload = {};
// If scenario already has an id, include it:
if (scenario?.id) {
scenarioPayload.id = scenario.id;
}
scenarioPayload.college_enrollment_status = finalCollegeStatus;
scenarioPayload.currently_working = formData.currently_working || 'no';
const scenarioTitle = parseStringIfGiven(formData.scenario_title);
if (scenarioTitle !== undefined) scenarioPayload.scenario_title = scenarioTitle;
const careerName = parseStringIfGiven(formData.career_name);
if (careerName !== undefined) scenarioPayload.career_name = careerName;
const scenarioStatus = parseStringIfGiven(formData.status);
if (scenarioStatus !== undefined) scenarioPayload.status = scenarioStatus;
if (formData.start_date && formData.start_date.trim() !== '') {
scenarioPayload.start_date = formData.start_date.trim();
}
if (
formData.projected_end_date &&
formData.projected_end_date.trim() !== ''
) {
scenarioPayload.projected_end_date = formData.projected_end_date.trim();
}
const pme = parseNumberIfGiven(formData.planned_monthly_expenses);
if (pme !== undefined) scenarioPayload.planned_monthly_expenses = pme;
const pmdp = parseNumberIfGiven(formData.planned_monthly_debt_payments);
if (pmdp !== undefined)
scenarioPayload.planned_monthly_debt_payments = pmdp;
const pmrc = parseNumberIfGiven(
formData.planned_monthly_retirement_contribution
);
if (pmrc !== undefined) {
scenarioPayload.planned_monthly_retirement_contribution = pmrc;
}
const pmec = parseNumberIfGiven(
formData.planned_monthly_emergency_contribution
);
if (pmec !== undefined) {
scenarioPayload.planned_monthly_emergency_contribution = pmec;
}
const psep = parseNumberIfGiven(formData.planned_surplus_emergency_pct);
if (psep !== undefined)
scenarioPayload.planned_surplus_emergency_pct = psep;
const psrp = parseNumberIfGiven(formData.planned_surplus_retirement_pct);
if (psrp !== undefined)
scenarioPayload.planned_surplus_retirement_pct = psrp;
const pai = parseNumberIfGiven(formData.planned_additional_income);
if (pai !== undefined) scenarioPayload.planned_additional_income = pai;
// 1) Upsert scenario
const scenRes = await authFetch('/api/premium/career-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(scenarioPayload)
});
if (!scenRes.ok) {
const msg = await scenRes.text();
throw new Error(`Scenario upsert failed: ${msg}`);
}
const scenData = await scenRes.json();
const updatedScenarioId = scenData.career_profile_id;
// 2) Build college payload
const collegePayload = {
id: formData.college_profile_id || null,
career_profile_id: updatedScenarioId,
college_enrollment_status: finalCollegeStatus,
is_in_state: formData.is_in_state ? 1 : 0,
is_in_district: formData.is_in_district ? 1 : 0,
is_in_online: formData.is_online ? 1 : 0
};
const selSchool = parseStringIfGiven(formData.selected_school);
if (selSchool !== undefined) collegePayload.selected_school = selSchool;
const selProg = parseStringIfGiven(formData.selected_program);
if (selProg !== undefined) collegePayload.selected_program = selProg;
const progType = parseStringIfGiven(formData.program_type);
if (progType !== undefined) collegePayload.program_type = progType;
const acCal = parseStringIfGiven(formData.academic_calendar);
if (acCal !== undefined) collegePayload.academic_calendar = acCal;
if (formData.expected_graduation && formData.expected_graduation.trim() !== '') {
collegePayload.expected_graduation = formData.expected_graduation
.trim()
.substring(0, 10);
}
if (formData.enrollment_date && formData.enrollment_date.trim() !== '') {
collegePayload.enrollment_date = formData.enrollment_date
.trim()
.substring(0, 10);
}
const afa = parseNumberIfGiven(formData.annual_financial_aid);
if (afa !== undefined) collegePayload.annual_financial_aid = afa;
const ecd = parseNumberIfGiven(formData.existing_college_debt);
if (ecd !== undefined) collegePayload.existing_college_debt = ecd;
const tp = parseNumberIfGiven(formData.tuition_paid);
if (tp !== undefined) collegePayload.tuition_paid = tp;
if (chosenTuitionVal !== undefined && !isNaN(chosenTuitionVal)) {
collegePayload.tuition = chosenTuitionVal;
}
if (chosenProgLengthVal !== undefined && !isNaN(chosenProgLengthVal)) {
collegePayload.program_length = chosenProgLengthVal;
}
const ltg = parseNumberIfGiven(formData.loan_term);
if (ltg !== undefined) collegePayload.loan_term = ltg;
const ir = parseNumberIfGiven(formData.interest_rate);
if (ir !== undefined) collegePayload.interest_rate = ir;
const ep = parseNumberIfGiven(formData.extra_payment);
if (ep !== undefined) collegePayload.extra_payment = ep;
const chpy = parseNumberIfGiven(formData.credit_hours_per_year);
if (chpy !== undefined) collegePayload.credit_hours_per_year = chpy;
const hc = parseNumberIfGiven(formData.hours_completed);
if (hc !== undefined) collegePayload.hours_completed = hc;
const chr = parseNumberIfGiven(formData.credit_hours_required);
if (chr !== undefined) collegePayload.credit_hours_required = chr;
const esal = parseNumberIfGiven(formData.expected_salary);
if (esal !== undefined) collegePayload.expected_salary = esal;
if (formData.loan_deferral_until_graduation) {
collegePayload.loan_deferral_until_graduation = 1;
}
// 3) Upsert or skip
if (
finalCollegeStatus === 'currently_enrolled' ||
finalCollegeStatus === 'prospective_student'
) {
const colRes = await authFetch('/api/premium/college-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collegePayload)
});
if (!colRes.ok) {
const msg2 = await colRes.text();
throw new Error(`College upsert failed: ${msg2}`);
}
} else {
console.log(
'Skipping college-profile upsert in EditScenarioModal because user not enrolled'
);
// Optionally: if you want to delete an existing college profile:
// await authFetch(`/api/premium/college-profile/delete/${updatedScenarioId}`, { method: 'DELETE' });
}
// 4) Re-fetch scenario, college, financial => aggregator => simulate
const [scenResp2, colResp2, finResp] = await Promise.all([
authFetch(`/api/premium/career-profile/${updatedScenarioId}`),
authFetch(`/api/premium/college-profile?careerProfileId=${updatedScenarioId}`),
authFetch(`/api/premium/financial-profile`)
]);
if (!scenResp2.ok || !colResp2.ok || !finResp.ok) {
console.error('One re-fetch failed after upsert.', {
scenarioStatus: scenResp2.status,
collegeStatus: colResp2.status,
financialStatus: finResp.status
});
onClose();
return;
}
const [finalScenarioRow, finalCollegeRaw, finalFinancial] = await Promise.all([
scenResp2.json(),
colResp2.json(),
finResp.json()
]);
let finalCollegeRow = Array.isArray(finalCollegeRaw)
? finalCollegeRaw[0] || {}
: finalCollegeRaw;
// -------------------------------------------
// 5) Before simulate: parse numeric fields
// to avoid .toFixed errors
// -------------------------------------------
// scenario planned_ fields
if (finalScenarioRow.planned_monthly_expenses != null) {
finalScenarioRow.planned_monthly_expenses = parseFloatOrZero(
finalScenarioRow.planned_monthly_expenses,
null
);
}
if (finalScenarioRow.planned_monthly_debt_payments != null) {
finalScenarioRow.planned_monthly_debt_payments = parseFloatOrZero(
finalScenarioRow.planned_monthly_debt_payments,
null
);
}
if (finalScenarioRow.planned_monthly_retirement_contribution != null) {
finalScenarioRow.planned_monthly_retirement_contribution = parseFloatOrZero(
finalScenarioRow.planned_monthly_retirement_contribution,
null
);
}
if (finalScenarioRow.planned_monthly_emergency_contribution != null) {
finalScenarioRow.planned_monthly_emergency_contribution = parseFloatOrZero(
finalScenarioRow.planned_monthly_emergency_contribution,
null
);
}
if (finalScenarioRow.planned_surplus_emergency_pct != null) {
finalScenarioRow.planned_surplus_emergency_pct = parseFloatOrZero(
finalScenarioRow.planned_surplus_emergency_pct,
null
);
}
if (finalScenarioRow.planned_surplus_retirement_pct != null) {
finalScenarioRow.planned_surplus_retirement_pct = parseFloatOrZero(
finalScenarioRow.planned_surplus_retirement_pct,
null
);
}
if (finalScenarioRow.planned_additional_income != null) {
finalScenarioRow.planned_additional_income = parseFloatOrZero(
finalScenarioRow.planned_additional_income,
null
);
}
// college numeric fields (force all to numbers or 0)
const numericFields = [
'existing_college_debt',
'extra_payment',
'tuition',
'tuition_paid',
'interest_rate',
'loan_term',
'credit_hours_per_year',
'hours_completed',
'program_length',
'expected_salary',
'annual_financial_aid',
'credit_hours_required'
];
for (const field of numericFields) {
if (finalCollegeRow[field] != null) {
finalCollegeRow[field] = parseFloatOrZero(finalCollegeRow[field], 0);
} else {
finalCollegeRow[field] = 0;
}
}
// Also ensure all scenario/financial fields used in buildMergedUserProfile are numbers
const scenarioNumericFields = [
'planned_monthly_expenses',
'planned_monthly_debt_payments',
'planned_monthly_retirement_contribution',
'planned_monthly_emergency_contribution',
'planned_surplus_emergency_pct',
'planned_surplus_retirement_pct',
'planned_additional_income'
];
for (const field of scenarioNumericFields) {
if (finalScenarioRow[field] != null) {
finalScenarioRow[field] = parseFloatOrZero(finalScenarioRow[field], 0);
} else {
finalScenarioRow[field] = 0;
}
}
if (finalFinancial) {
const financialNumericFields = [
'current_salary',
'monthly_expenses',
'monthly_debt_payments',
'additional_income',
'emergency_fund',
'retirement_savings',
'retirement_contribution',
'emergency_contribution',
'extra_cash_emergency_pct',
'extra_cash_retirement_pct'
];
for (const field of financialNumericFields) {
if (finalFinancial[field] != null) {
finalFinancial[field] = parseFloatOrZero(finalFinancial[field], 0);
} else {
finalFinancial[field] = 0;
}
}
}
// 6) Now simulate
const userProfile = buildMergedUserProfile(
finalScenarioRow,
finalCollegeRow,
finalFinancial
);
console.log('UserProfile from Modal:', userProfile);
const results = simulateFinancialProjection(userProfile);
setProjectionData(results.projectionData);
setLoanPayoffMonth(results.loanPaidOffMonth);
// 7) Close or reload
onClose();
window.location.reload();
} catch (err) {
console.error('Error saving scenario + college:', err);
alert(err.message || 'Failed to save scenario data.');
/* If the title did NOT change, keep the id so the UPSERT
updates the existing row. Otherwise omit id new row */
if (!titleChanged && scenario?.id) {
scenarioPayload.id = scenario.id;
}
/* ─── 2) POST (always) ─────────────────────────────────── */
const scenRes = await authFetch("/api/premium/career-profile", {
method : "POST",
headers: { "Content-Type": "application/json" },
body : JSON.stringify(scenarioPayload)
});
if (!scenRes.ok) throw new Error(await scenRes.text());
const { career_profile_id } = await scenRes.json();
/* ─── 3) (optional) upsert college profile keep yours… ─ */
/* ─── 4) update localStorage so CareerRoadmap re-hydrates ─ */
localStorage.setItem(
"selectedCareer",
JSON.stringify({ title: editedName })
);
localStorage.setItem(
"lastSelectedCareerProfileId",
String(career_profile_id)
);
/* ─── 5) close modal + tell parent to refetch ───────────── */
onClose(true); // CareerRoadmaps onClose(true) triggers reload
window.location.reload();
} catch (err) {
console.error("handleSave", err);
alert(err.message || "Failed to save scenario");
}
}
/*********************************************************
* 13) Render
@ -1076,7 +803,7 @@ export default function ScenarioEditModal({
<input
type="checkbox"
name="is_online"
checked={!!formData.is_in_online}
checked={!!formData.is_online}
onChange={handleFormChange}
className="mr-1"
/>

View File

@ -1,18 +0,0 @@
// utils/getMissingFields.js
export default function getMissingFields({ scenario, financial, college }) {
const missing = [];
if (!scenario?.career_name) missing.push('Target career');
if (!scenario?.start_date) missing.push('Career start date');
if (!financial?.current_salary) missing.push('Current salary');
if (!financial?.monthly_expenses) missing.push('Monthly expenses');
if (college?.college_enrollment_status === 'currently_enrolled') {
if (!college.expected_graduation) missing.push('Expected graduation');
if (!college.existing_college_debt)
missing.push('Student-loan balance');
}
return missing;
}

View File

@ -0,0 +1,74 @@
/**
* Identify which *critical* fields are still empty so the UI can
* decide whether to pop the Scenario-Edit modal.
*
* Scenario overrides (planned_ values) are **optional**
* College data is only required when the user is actually
* enrolled / planning to enrol.
*/
export default function getMissingFields(
{ scenario = {}, financial = {}, college = {} },
{ requireCollegeData = true } = {}
) {
const missing = [];
/* ---------- 1 ▸ Scenario essentials ---------- */
const requiredScenario = [
'career_name',
'scenario_title',
'start_date',
'status',
'currently_working',
'college_enrollment_status'
];
requiredScenario.forEach((f) => {
if (!hasValue(scenario[f])) missing.push(f);
});
/* ---------- 2 ▸ Financial profile ---------- */
const requiredFin = [
'current_salary',
'monthly_expenses',
'monthly_debt_payments',
'emergency_fund',
'retirement_savings'
];
requiredFin.forEach((f) => {
if (!hasValue(financial[f])) missing.push(f);
});
/* ---------- 3 ▸ College profile (conditional) ---------- */
if (requireCollegeData) {
const requiredCol = [
'selected_school',
'selected_program',
'program_type',
'academic_calendar',
'credit_hours_per_year',
'tuition', // can be auto-calcd, but still required
'interest_rate',
'loan_term',
'existing_college_debt',
'expected_graduation'
];
if (!Object.keys(college).length) {
missing.push('collegeProfile');
} else {
requiredCol.forEach((f) => {
if (!hasValue(college[f])) missing.push(f);
});
}
}
return missing;
}
/* ==== helpers ========================================================== */
function hasValue(v) {
if (v === null || v === undefined) return false;
if (typeof v === 'string') return v.trim() !== '';
return true; // numbers, booleans, etc.
}

Binary file not shown.