Cross-app navigation UI fixes
This commit is contained in:
parent
2728378041
commit
0bad162d52
@ -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; // 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;
|
||||
|
||||
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>
|
||||
|
@ -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);
|
||||
|
@ -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({
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_online"
|
||||
checked={!!formData.is_in_online}
|
||||
checked={!!formData.is_online}
|
||||
onChange={handleFormChange}
|
||||
className="mr-1"
|
||||
/>
|
||||
|
@ -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;
|
||||
}
|
74
src/utils/getMissingFields.js
Normal file
74
src/utils/getMissingFields.js
Normal 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-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.
|
||||
}
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user