diff --git a/src/components/CareerRoadmap.js b/src/components/CareerRoadmap.js index 0b08432..75a6307 100644 --- a/src/components/CareerRoadmap.js +++ b/src/components/CareerRoadmap.js @@ -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,8 +353,7 @@ 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'); const simulationYears = parseInt(simulationYearsInput, 10) || 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; // don’t pop it again - - const missing = getMissingFields({ - scenario : scenarioRow, - financial: financialProfile, - college : collegeProfile - }); - - if (missing.length > 0) { - setShowEditModal(true); // open modal - setHasPrompted(true); // flag so it’s 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 don’t 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; - - // 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); + const ctrl = new AbortController(); + (async () => { + try { + const qs = new URLSearchParams({ socCode: strippedSocCode, area: userArea }); + const res = await fetch(`${apiURL}/salary?${qs}`, { signal: ctrl.signal }); + + 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 - { - setShowEditModal(false); - window.location.reload(); - }} - scenario={scenarioRow} - financialProfile={financialProfile} - setFinancialProfile={setFinancialProfile} - collegeProfile={collegeProfile} - setCollegeProfile={setCollegeProfile} - apiURL={apiURL} - authFetch={authFetch} - /> + + { + 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 */} diff --git a/src/components/PremiumOnboarding/OnboardingContainer.js b/src/components/PremiumOnboarding/OnboardingContainer.js index 72b5ec0..aeac7b8 100644 --- a/src/components/PremiumOnboarding/OnboardingContainer.js +++ b/src/components/PremiumOnboarding/OnboardingContainer.js @@ -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); diff --git a/src/components/ScenarioEditModal.js b/src/components/ScenarioEditModal.js index ced0d01..73e8269 100644 --- a/src/components/ScenarioEditModal.js +++ b/src/components/ScenarioEditModal.js @@ -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); // CareerRoadmap’s 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({ diff --git a/src/utils/MissingFields.js b/src/utils/MissingFields.js deleted file mode 100644 index c15566a..0000000 --- a/src/utils/MissingFields.js +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/utils/getMissingFields.js b/src/utils/getMissingFields.js new file mode 100644 index 0000000..db8e1bc --- /dev/null +++ b/src/utils/getMissingFields.js @@ -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-calc’d, 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. +} diff --git a/user_profile.db b/user_profile.db index 3be8f9c..2078778 100644 Binary files a/user_profile.db and b/user_profile.db differ