Cleanup, all technical fixes prior to prod creation
This commit is contained in:
parent
7a425a955b
commit
ee098148a4
2
.env
2
.env
@ -2,4 +2,4 @@ CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://
|
||||
SERVER1_PORT=5000
|
||||
SERVER2_PORT=5001
|
||||
SERVER3_PORT=5002
|
||||
IMG_TAG=202508011207
|
||||
IMG_TAG=66721ee-202508031720
|
@ -75,6 +75,7 @@ function internalFetch(req, urlPath, opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
const auth = (req, urlPath, opts = {}) => internalFetch(req, urlPath, opts);
|
||||
|
||||
app.post('/api/premium/stripe/webhook',
|
||||
express.raw({ type: 'application/json' }),
|
||||
|
@ -10,78 +10,3 @@ const BASE = (
|
||||
process.env.REACT_APP_API_URL ||
|
||||
''
|
||||
).replace(/\/+$/, ''); // trim *all* trailing “/”
|
||||
|
||||
export const api = (path = '') =>
|
||||
`${BASE}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Fetch areas-by-state (static JSON in public/ or served by nginx)
|
||||
------------------------------------------------------------------*/
|
||||
export const fetchAreasByState = async (state) => {
|
||||
try {
|
||||
// NOTE: if Institution_data.json is in /public, nginx serves it
|
||||
const res = await fetch(api('/Institution_data.json'));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
// Adjust this part if your JSON structure is different
|
||||
const json = await res.json();
|
||||
return json[state]?.areas || [];
|
||||
} catch (err) {
|
||||
console.error('Error fetching areas:', err.message);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Client-side Google Maps geocode
|
||||
------------------------------------------------------------------*/
|
||||
export async function clientGeocodeZip(zip) {
|
||||
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY ??
|
||||
process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
|
||||
const url = `https://maps.googleapis.com/maps/api/geocode/json` +
|
||||
`?address=${encodeURIComponent(zip)}&key=${apiKey}`;
|
||||
|
||||
const resp = await axios.get(url);
|
||||
const { status, results } = resp.data;
|
||||
if (status === 'OK' && results.length) {
|
||||
return results[0].geometry.location; // { lat, lng }
|
||||
}
|
||||
throw new Error('Geocoding failed.');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Haversine distance helper (miles)
|
||||
------------------------------------------------------------------*/
|
||||
export function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
const R = 3959; // earth radius in miles
|
||||
const toRad = (v) => (v * Math.PI) / 180;
|
||||
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) *
|
||||
Math.cos(toRad(lat2)) *
|
||||
Math.sin(dLon / 2) ** 2;
|
||||
|
||||
return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Fetch schools for one or many CIP prefixes
|
||||
------------------------------------------------------------------*/
|
||||
export async function fetchSchools(cipCodes) {
|
||||
try {
|
||||
// 1) Ensure array-ness, then join with commas
|
||||
const codes = Array.isArray(cipCodes) ? cipCodes : [cipCodes];
|
||||
const cipParam = codes.join(',');
|
||||
|
||||
// 2) Hit backend
|
||||
const res = await axios.get(api('/api/schools'), {
|
||||
params: { cipCodes: cipParam },
|
||||
});
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
console.error('Error fetching schools:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
@ -184,12 +184,20 @@ export default function chatFreeEndpoint(
|
||||
authenticateUser,
|
||||
async (req, res) => {
|
||||
try {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection : "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
});
|
||||
const headers = {
|
||||
// streaming MIME type – browsers still treat it as text, but
|
||||
// it signals “keep pushing” semantics more clearly
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no" // disables Nginx/ALB buffering
|
||||
};
|
||||
|
||||
// “Connection” is allowed **only** on HTTP/1.x
|
||||
if (req.httpVersionMajor < 2) {
|
||||
headers.Connection = "keep-alive";
|
||||
}
|
||||
|
||||
res.writeHead(200, headers);
|
||||
res.flushHeaders?.();
|
||||
|
||||
const sendChunk = (txt = "") => { res.write(txt); res.flush?.(); };
|
||||
|
@ -1,170 +0,0 @@
|
||||
/**
|
||||
* applyOps – execute a fenced ```ops``` block returned by Jess.
|
||||
* Supports milestones, tasks, impacts, scenario utilities, and college profile.
|
||||
*
|
||||
* @param {object} opsObj – parsed JSON inside ```ops```
|
||||
* @param {object} req – Express request (for auth header)
|
||||
* @param {string} scenarioId – current career_profile_id (optional but lets us
|
||||
* auto-fill when the bot forgets)
|
||||
* @return {string[]} – human-readable confirmations
|
||||
*/
|
||||
export async function applyOps(opsObj = {}, req, scenarioId = null) {
|
||||
if (!Array.isArray(opsObj?.milestones) && !Array.isArray(opsObj?.tasks)
|
||||
&& !Array.isArray(opsObj?.impacts) && !Array.isArray(opsObj?.scenarios)
|
||||
&& !opsObj.collegeProfile) return [];
|
||||
|
||||
const apiBase = process.env.APTIVA_INTERNAL_API || 'http://localhost:5002/api';
|
||||
const auth = (p, o = {}) =>
|
||||
internalFetch(req, `${apiBase}${p}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...(o.headers || {}) },
|
||||
...o
|
||||
});
|
||||
|
||||
const confirmations = [];
|
||||
|
||||
/* ────────────────────────────────────────────────────
|
||||
1. MILESTONE-LEVEL OPS (unchanged behaviour)
|
||||
──────────────────────────────────────────────────── */
|
||||
for (const m of opsObj.milestones || []) {
|
||||
const op = (m?.op || '').toUpperCase();
|
||||
|
||||
if (op === 'DELETE' && m.id) { // single-scenario delete
|
||||
const r = await auth(`/premium/milestones/${m.id.trim()}`, { method: 'DELETE' });
|
||||
if (r.ok) confirmations.push(`Deleted milestone ${m.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'UPDATE' && m.id && m.patch) {
|
||||
const r = await auth(`/premium/milestones/${m.id}`, {
|
||||
method: 'PUT', body: JSON.stringify(m.patch)
|
||||
});
|
||||
if (r.ok) confirmations.push(`Updated milestone ${m.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'CREATE' && m.data) {
|
||||
m.data.career_profile_id = m.data.career_profile_id || scenarioId;
|
||||
const r = await auth('/premium/milestone', { method: 'POST', body: JSON.stringify(m.data) });
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
const newId = Array.isArray(j) ? j[0]?.id : j.id;
|
||||
confirmations.push(`Created milestone ${newId || '(new)'}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'DELETEALL' && m.id) { // delete across every scenario
|
||||
const r = await auth(`/premium/milestones/${m.id}/all`, { method: 'DELETE' });
|
||||
if (r.ok) confirmations.push(`Deleted milestone ${m.id} from all scenarios`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'COPY' && m.id && Array.isArray(m.targetScenarioIds)) {
|
||||
const r = await auth('/premium/milestone/copy', {
|
||||
method: 'POST',
|
||||
body : JSON.stringify({ milestoneId: m.id, scenarioIds: m.targetScenarioIds })
|
||||
});
|
||||
if (r.ok) confirmations.push(`Copied milestone ${m.id} → ${m.targetScenarioIds.length} scenario(s)`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────
|
||||
2. TASK-LEVEL OPS
|
||||
──────────────────────────────────────────────────── */
|
||||
for (const t of opsObj.tasks || []) {
|
||||
const op = (t?.op || '').toUpperCase();
|
||||
|
||||
if (op === 'CREATE' && t.data && t.data.milestone_id) {
|
||||
await auth('/premium/tasks', { method: 'POST', body: JSON.stringify(t.data) });
|
||||
confirmations.push(`Added task to milestone ${t.data.milestone_id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'UPDATE' && t.taskId && t.patch) {
|
||||
await auth(`/premium/tasks/${t.taskId}`, { method: 'PUT', body: JSON.stringify(t.patch) });
|
||||
confirmations.push(`Updated task ${t.taskId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'DELETE' && t.taskId) {
|
||||
await auth(`/premium/tasks/${t.taskId}`, { method: 'DELETE' });
|
||||
confirmations.push(`Deleted task ${t.taskId}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────
|
||||
3. IMPACT-LEVEL OPS
|
||||
──────────────────────────────────────────────────── */
|
||||
for (const imp of opsObj.impacts || []) {
|
||||
const op = (imp?.op || '').toUpperCase();
|
||||
|
||||
if (op === 'CREATE' && imp.data && imp.data.milestone_id) {
|
||||
await auth('/premium/milestone-impacts', { method: 'POST', body: JSON.stringify(imp.data) });
|
||||
confirmations.push(`Added impact to milestone ${imp.data.milestone_id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'UPDATE' && imp.impactId && imp.patch) {
|
||||
await auth(`/premium/milestone-impacts/${imp.impactId}`, { method: 'PUT', body: JSON.stringify(imp.patch) });
|
||||
confirmations.push(`Updated impact ${imp.impactId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'DELETE' && imp.impactId) {
|
||||
await auth(`/premium/milestone-impacts/${imp.impactId}`, { method: 'DELETE' });
|
||||
confirmations.push(`Deleted impact ${imp.impactId}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────
|
||||
4. SCENARIO (career_profile) OPS
|
||||
──────────────────────────────────────────────────── */
|
||||
for (const s of opsObj.scenarios || []) {
|
||||
const op = (s?.op || '').toUpperCase();
|
||||
|
||||
if (op === 'CREATE' && s.data?.career_name) {
|
||||
await auth('/premium/career-profile', { method: 'POST', body: JSON.stringify(s.data) });
|
||||
confirmations.push(`Created scenario “${s.data.career_name}”`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'UPDATE' && s.scenarioId && s.patch) {
|
||||
/* if only goals are patched, hit the goals route; otherwise create a PUT route */
|
||||
const hasOnlyGoals = Object.keys(s.patch).length === 1 && s.patch.career_goals !== undefined;
|
||||
const url = hasOnlyGoals
|
||||
? `/premium/career-profile/${s.scenarioId}/goals`
|
||||
: `/premium/career-profile`; // <-- add generic PATCH if you implemented one
|
||||
await auth(url.replace(/\/$/, `/${s.scenarioId}`), { method: 'PUT', body: JSON.stringify(s.patch) });
|
||||
confirmations.push(`Updated scenario ${s.scenarioId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'DELETE' && s.scenarioId) {
|
||||
await auth(`/premium/career-profile/${s.scenarioId}`, { method: 'DELETE' });
|
||||
confirmations.push(`Deleted scenario ${s.scenarioId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 'CLONE' && s.sourceId) {
|
||||
await auth('/premium/career-profile/clone', { method: 'POST', body: JSON.stringify({
|
||||
sourceId : s.sourceId,
|
||||
overrides : s.overrides || {}
|
||||
})});
|
||||
confirmations.push(`Cloned scenario ${s.sourceId}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────
|
||||
5. COLLEGE PROFILE (single op per block)
|
||||
──────────────────────────────────────────────────── */
|
||||
if (opsObj.collegeProfile?.op?.toUpperCase() === 'UPSERT' && opsObj.collegeProfile.data) {
|
||||
await auth('/premium/college-profile', { method: 'POST', body: JSON.stringify(opsObj.collegeProfile.data) });
|
||||
confirmations.push('Saved college profile');
|
||||
}
|
||||
|
||||
return confirmations;
|
||||
}
|
30
eslint.config.js
Normal file
30
eslint.config.js
Normal file
@ -0,0 +1,30 @@
|
||||
// eslint.config.js
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactPlugin from "eslint-plugin-react";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: { sourceType: "module" }
|
||||
},
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
import: importPlugin
|
||||
},
|
||||
rules: {
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
"import/no-unused-modules": ["warn", { unusedExports: true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["**/*.cjs"],
|
||||
languageOptions: { sourceType: "commonjs" }
|
||||
}
|
||||
];
|
445
package-lock.json
generated
445
package-lock.json
generated
@ -63,12 +63,21 @@
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"glob": "^11.0.3",
|
||||
"globals": "^16.3.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17"
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript-eslint": "^8.38.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@ -150,9 +159,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/eslint-parser": {
|
||||
"version": "7.27.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.27.5.tgz",
|
||||
"integrity": "sha512-HLkYQfRICudzcOtjGwkPvGc5nF1b4ljLZh1IRDj50lRZ718NAKVgQpIAUX8bfg6u/yuSKY3L7E0YzIV+OxrB8Q==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.0.tgz",
|
||||
"integrity": "sha512-N4ntErOlKvcbTt01rr5wj3y55xnIdx1ymrfIr8C2WnM1Y9glFgWaGDEULJIazOX3XM9NRzhfJ6zZnQ1sBNWU+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
|
||||
@ -1069,6 +1078,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-classes/node_modules/globals": {
|
||||
"version": "11.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
||||
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-computed-properties": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz",
|
||||
@ -2051,6 +2069,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse/node_modules/globals": {
|
||||
"version": "11.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
||||
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
|
||||
@ -2452,12 +2479,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
||||
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
|
||||
"version": "9.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz",
|
||||
"integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://eslint.org/donate"
|
||||
}
|
||||
},
|
||||
"node_modules/@gar/promisify": {
|
||||
@ -4582,6 +4613,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz",
|
||||
"integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.38.0",
|
||||
"@typescript-eslint/types": "^8.38.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz",
|
||||
"integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
|
||||
@ -4599,6 +4666,23 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz",
|
||||
"integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz",
|
||||
@ -8495,6 +8579,48 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-airbnb": {
|
||||
"version": "19.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-airbnb/-/eslint-config-airbnb-19.0.4.tgz",
|
||||
"integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"object.assign": "^4.1.2",
|
||||
"object.entries": "^1.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.12.0 || ^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^7.32.0 || ^8.2.0",
|
||||
"eslint-plugin-import": "^2.25.3",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-airbnb-base": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz",
|
||||
"integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confusing-browser-globals": "^1.0.10",
|
||||
"object.assign": "^4.1.2",
|
||||
"object.entries": "^1.1.5",
|
||||
"semver": "^6.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^7.32.0 || ^8.2.0",
|
||||
"eslint-plugin-import": "^2.25.2"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-react-app": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz",
|
||||
@ -8864,6 +8990,15 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/@eslint/js": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
||||
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@ -10191,12 +10326,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "11.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
||||
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz",
|
||||
"integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globalthis": {
|
||||
@ -19470,6 +19609,19 @@
|
||||
"integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-interface-checker": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
@ -19724,6 +19876,275 @@
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz",
|
||||
"integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.38.0",
|
||||
"@typescript-eslint/parser": "8.38.0",
|
||||
"@typescript-eslint/typescript-estree": "8.38.0",
|
||||
"@typescript-eslint/utils": "8.38.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz",
|
||||
"integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.38.0",
|
||||
"@typescript-eslint/type-utils": "8.38.0",
|
||||
"@typescript-eslint/utils": "8.38.0",
|
||||
"@typescript-eslint/visitor-keys": "8.38.0",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.38.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz",
|
||||
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.38.0",
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
"@typescript-eslint/typescript-estree": "8.38.0",
|
||||
"@typescript-eslint/visitor-keys": "8.38.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz",
|
||||
"integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
"@typescript-eslint/visitor-keys": "8.38.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz",
|
||||
"integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
"@typescript-eslint/typescript-estree": "8.38.0",
|
||||
"@typescript-eslint/utils": "8.38.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz",
|
||||
"integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz",
|
||||
"integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.38.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.38.0",
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
"@typescript-eslint/visitor-keys": "8.38.0",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"minimatch": "^9.0.4",
|
||||
"semver": "^7.6.0",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz",
|
||||
"integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.38.0",
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
"@typescript-eslint/typescript-estree": "8.38.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz",
|
||||
"integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
|
13
package.json
13
package.json
@ -56,6 +56,8 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"start": "react-scripts start --host 0.0.0.0",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
@ -87,11 +89,20 @@
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"glob": "^11.0.3",
|
||||
"globals": "^16.3.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17"
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript-eslint": "^8.38.0"
|
||||
}
|
||||
}
|
||||
|
42
parseLine.js
42
parseLine.js
@ -1,42 +0,0 @@
|
||||
// parseLine.js
|
||||
function parseLine(line) {
|
||||
// Split on tabs
|
||||
const cols = line.split(/\t/).map((c) => c.trim());
|
||||
|
||||
// We expect 15 columns, but we won't skip lines if some are missing or extra.
|
||||
// We'll fill them with "" if not present, to preserve all data
|
||||
const col0 = cols[0] || ""; // O*NET-SOC Code
|
||||
const col1 = cols[1] || ""; // Title
|
||||
const col2 = cols[2] || ""; // Element ID
|
||||
const col3 = cols[3] || ""; // Element Name
|
||||
const col4 = cols[4] || ""; // Scale ID
|
||||
const col5 = cols[5] || ""; // Scale Name
|
||||
const col6 = cols[6] || ""; // Data Value
|
||||
const col7 = cols[7] || ""; // N
|
||||
const col8 = cols[8] || ""; // Standard Error
|
||||
const col9 = cols[9] || ""; // Lower CI Bound
|
||||
const col10 = cols[10] || ""; // Upper CI Bound
|
||||
const col11 = cols[11] || ""; // Recommend Suppress
|
||||
const col12 = cols[12] || ""; // Not Relevant
|
||||
const col13 = cols[13] || ""; // Date
|
||||
const col14 = cols[14] || ""; // Domain Source
|
||||
|
||||
// Return an object with keys matching your definitions
|
||||
return {
|
||||
onetSocCode: cols[0], // e.g. "11-1011.00"
|
||||
elementID: cols[1], // e.g. "2.C.1.a"
|
||||
elementName: cols[2], // e.g. "Administration and Management"
|
||||
scaleID: cols[3], // e.g. "IM" or "LV"
|
||||
dataValue: cols[4], // e.g. "4.78"
|
||||
n: cols[5], // e.g. "28"
|
||||
standardError: cols[6], // e.g. "0.1102"
|
||||
lowerCI: cols[7],
|
||||
upperCI: cols[8],
|
||||
recommendSuppress: cols[9],
|
||||
notRelevant: cols[10],
|
||||
date: cols[11],
|
||||
domainSource: cols[12]
|
||||
};
|
||||
}
|
||||
|
||||
export default parseLine;
|
29
src/App.js
29
src/App.js
@ -22,7 +22,6 @@ import EducationalProgramsPage from './components/EducationalProgramsPage.js';
|
||||
import EnhancingLanding from './components/EnhancingLanding.js';
|
||||
import RetirementLanding from './components/RetirementLanding.js';
|
||||
import InterestInventory from './components/InterestInventory.js';
|
||||
import Dashboard from './components/Dashboard.js';
|
||||
import UserProfile from './components/UserProfile.js';
|
||||
import FinancialProfileForm from './components/FinancialProfileForm.js';
|
||||
import CareerProfileList from './components/CareerProfileList.js';
|
||||
@ -78,6 +77,10 @@ const uiToolHandlers = useMemo(() => {
|
||||
return {}; // every other page exposes no UI tools
|
||||
}, [pageContext]);
|
||||
|
||||
// Retirement bot is only relevant on these pages
|
||||
const canShowRetireBot =
|
||||
pageContext === 'RetirementPlanner' ||
|
||||
pageContext === 'RetirementLanding';
|
||||
|
||||
// Auth states
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
@ -228,18 +231,16 @@ const uiToolHandlers = useMemo(() => {
|
||||
<ChatCtx.Provider value={{ setChatSnapshot,
|
||||
openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); },
|
||||
openRetire : (props) => {
|
||||
setRetireProps(props);
|
||||
setDrawerPane('retire');
|
||||
setDrawerOpen(true);
|
||||
|
||||
if (pageContext === 'RetirementPlanner' || pageContext === 'RetirementLanding') {
|
||||
setRetireProps(props);
|
||||
setDrawerPane('retire');
|
||||
setDrawerOpen(true);
|
||||
} else {
|
||||
console.warn('Retirement bot disabled on this page');
|
||||
}
|
||||
}}}>
|
||||
if (!canShowRetireBot) {
|
||||
console.warn('Retirement bot disabled on this page');
|
||||
return;
|
||||
}
|
||||
|
||||
setRetireProps(props);
|
||||
setDrawerPane('retire');
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
}}>
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between border-b bg-white px-6 py-4 shadow-sm relative">
|
||||
@ -517,7 +518,6 @@ const uiToolHandlers = useMemo(() => {
|
||||
<>
|
||||
<Route path="/signin-landing" element={<SignInLanding user={user} />}/>
|
||||
<Route path="/interest-inventory" element={<InterestInventory />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/profile" element={<UserProfile />} />
|
||||
<Route path="/planning" element={<PlanningLanding />} />
|
||||
<Route path="/career-explorer" element={<CareerExplorer />} />
|
||||
@ -608,6 +608,7 @@ const uiToolHandlers = useMemo(() => {
|
||||
pageContext={pageContext}
|
||||
snapshot={chatSnapshot}
|
||||
uiToolHandlers={uiToolHandlers}
|
||||
canShowRetireBot={canShowRetireBot}
|
||||
/>
|
||||
|
||||
{/* Session Handler (Optional) */}
|
||||
|
@ -1,183 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button } from './ui/button.js';
|
||||
|
||||
const AISuggestedMilestones = ({ id, career, careerProfileId, authFetch, activeView, projectionData }) => {
|
||||
const [suggestedMilestones, setSuggestedMilestones] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [aiLoading, setAiLoading] = useState(true); // Start loading state true initially
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAISuggestions = async () => {
|
||||
if (!career || !careerProfileId || !Array.isArray(projectionData) || projectionData.length === 0) {
|
||||
console.warn('Holding fetch, required data not yet available.');
|
||||
setAiLoading(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setAiLoading(true);
|
||||
try {
|
||||
const milestonesRes = await authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`);
|
||||
const { milestones } = await milestonesRes.json();
|
||||
|
||||
const response = await authFetch('/api/premium/milestone/ai-suggestions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ career, careerProfileId, projectionData, existingMilestones: milestones }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch AI suggestions');
|
||||
const data = await response.json();
|
||||
|
||||
setSuggestedMilestones(data.suggestedMilestones.map((m) => ({
|
||||
title: m.title,
|
||||
date: m.date,
|
||||
description: m.description,
|
||||
progress: 0,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Error fetching AI suggestions:', error);
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAISuggestions();
|
||||
}, [career, careerProfileId, projectionData, authFetch]);
|
||||
|
||||
const regenerateSuggestions = async () => {
|
||||
setAiLoading(true);
|
||||
try {
|
||||
const milestonesRes = await authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`);
|
||||
const { milestones } = await milestonesRes.json();
|
||||
|
||||
const previouslySuggestedMilestones = suggestedMilestones;
|
||||
|
||||
// Explicitly reduce projection data size by sampling every 6 months
|
||||
const sampledProjectionData = projectionData.filter((_, i) => i % 6 === 0);
|
||||
|
||||
// Fetch career goals explicitly if defined (you'll implement this later; for now send empty or placeholder)
|
||||
// const careerGoals = selectedCareer?.careerGoals || '';
|
||||
|
||||
const response = await authFetch('/api/premium/milestone/ai-suggestions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
career,
|
||||
careerProfileId,
|
||||
projectionData: sampledProjectionData,
|
||||
existingMilestones: milestones,
|
||||
previouslySuggestedMilestones,
|
||||
regenerate: true,
|
||||
//careerGoals, // explicitly included
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch AI suggestions');
|
||||
const data = await response.json();
|
||||
|
||||
setSuggestedMilestones(
|
||||
data.suggestedMilestones.map((m) => ({
|
||||
title: m.title,
|
||||
date: m.date,
|
||||
description: m.description,
|
||||
progress: 0,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error regenerating AI suggestions:', error);
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const toggleSelect = (index) => {
|
||||
setSelected((prev) =>
|
||||
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]
|
||||
);
|
||||
};
|
||||
|
||||
const confirmSelectedMilestones = async () => {
|
||||
const milestonesToSend = selected.map((index) => {
|
||||
const m = suggestedMilestones[index];
|
||||
return {
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
date: m.date,
|
||||
progress: m.progress,
|
||||
milestone_type: activeView || 'Career',
|
||||
career_profile_id: careerProfileId
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await authFetch(`/api/premium/milestone`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ milestones: milestonesToSend }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save selected milestones');
|
||||
|
||||
setSelected([]);
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error saving selected milestones:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Explicit spinner shown whenever aiLoading is true
|
||||
if (aiLoading) {
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-500"></div>
|
||||
<span className="ml-2 text-gray-600">Generating AI-suggested milestones...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!suggestedMilestones.length) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 p-4 border rounded bg-gray-50 shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-lg font-semibold mb-2">AI-Suggested Milestones</h4>
|
||||
<Button
|
||||
className="mb-2"
|
||||
onClick={() => regenerateSuggestions()}
|
||||
disabled={aiLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{aiLoading ? 'Regenerating...' : 'Regenerate Suggestions'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{suggestedMilestones.map((m, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-indigo-600 shadow-sm"
|
||||
checked={selected.includes(i)}
|
||||
onChange={() => toggleSelect(i)}
|
||||
/>
|
||||
<span className="text-sm">{m.title} – {m.date}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
className="mt-3"
|
||||
onClick={confirmSelectedMilestones}
|
||||
disabled={loading || selected.length === 0}
|
||||
>
|
||||
{loading ? 'Saving...' : 'Confirm Selected'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default AISuggestedMilestones;
|
@ -40,11 +40,11 @@ export default function BillingResult() {
|
||||
</p>
|
||||
|
||||
<Button asChild className="w-full">
|
||||
<Link to="/premium-onboarding">Set up Premium Features</Link>
|
||||
<Link to="/premium-onboarding" className="block w-full">Set up Premium Features</Link>
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" asChild className="w-full">
|
||||
<Link to="/profile">Go to my account</Link>
|
||||
<Link to="/profile" className="block w-full">Go to my account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@ -59,7 +59,7 @@ export default function BillingResult() {
|
||||
<p className="text-gray-600">No changes were made to your account.</p>
|
||||
|
||||
<Button asChild className="w-full">
|
||||
<Link to="/paywall">Back to pricing</Link>
|
||||
<Link to="/paywall" className="block w-full">Back to pricing</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback, useContext } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, useLocation, createSearchParams } from 'react-router-dom';
|
||||
import ChatCtx from '../contexts/ChatCtx.js';
|
||||
|
||||
import CareerSuggestions from './CareerSuggestions.js';
|
||||
|
@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import isAllOther from '../utils/isAllOther.js';
|
||||
|
||||
|
||||
|
||||
@ -55,6 +57,22 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
|
||||
<div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center overflow-auto z-50">
|
||||
<div className="bg-white rounded-lg shadow-lg w-full max-w-5xl p-6 m-4 max-h-[90vh] overflow-y-auto">
|
||||
|
||||
|
||||
{isAllOther(career) && (
|
||||
<div className="mb-4 flex items-start rounded-md border-l-4 border-yellow-500 bg-yellow-50 p-3">
|
||||
<AlertTriangle className="mt-[2px] mr-2 h-5 w-5 text-yellow-600" />
|
||||
<p className="text-sm text-yellow-800">
|
||||
You've selected an "umbrella" field that covers a wide range of careers—many
|
||||
people begin a career journey with a broad interest area and we don't want to discourage
|
||||
anyone from taking this approach. It's just difficult to display detailed career data
|
||||
and day‑to‑day tasks for this “all‑other” occupatio.. Use it as a starting point,
|
||||
keep exploring specializations, and we can show you richer insights as soon as you are able
|
||||
to narrow it down to a more specific role. If you know this is the field for you, go ahead to
|
||||
add it to your comparison list or move straight into Preparing & Upskilling for Your Career!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title row */}
|
||||
<div className="flex justify-between items-center mb-4 pb-2 border-b">
|
||||
<div>
|
||||
|
@ -56,8 +56,8 @@ export default function CareerProfileForm() {
|
||||
career_name : d.career_name ?? '',
|
||||
soc_code : d.soc_code ?? '',
|
||||
status : d.status ?? 'current',
|
||||
start_date : d.start_date ?? '',
|
||||
retirement_start_date : d.retirement_start_date ?? '',
|
||||
start_date : (d.start_date || '').slice(0, 10), // ← trim
|
||||
retirement_start_date : (d.retirement_start_date || '').slice(0, 10),
|
||||
college_enrollment_status : d.college_enrollment_status ?? '',
|
||||
career_goals : d.career_goals ?? '',
|
||||
desired_retirement_income_monthly :
|
||||
@ -68,16 +68,18 @@ export default function CareerProfileForm() {
|
||||
|
||||
/* ---------- 4. save ---------- */
|
||||
async function save() {
|
||||
if (!form.soc_code) {
|
||||
alert('Please pick a valid career from the list first.');
|
||||
return;
|
||||
}
|
||||
if (!careerLocked && !form.soc_code) {
|
||||
alert('Please pick a valid career from the list first.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await authFetch('/api/premium/career-profile', {
|
||||
method : 'POST',
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({
|
||||
...form,
|
||||
start_date : form.start_date?.slice(0, 10) || null,
|
||||
retirement_start_date : form.retirement_start_date?.slice(0, 10) || null,
|
||||
id: id === 'new' ? undefined : id // upsert
|
||||
})
|
||||
});
|
||||
|
@ -693,7 +693,7 @@ useEffect(() => {
|
||||
|
||||
(async function init () {
|
||||
/* 1 ▸ get every row the user owns */
|
||||
const r = await authFetch('api/premium/career-profile/all');
|
||||
const r = await authFetch('/api/premium/career-profile/all');
|
||||
if (!r?.ok || cancelled) return;
|
||||
const { careerProfiles=[] } = await r.json();
|
||||
setExistingCareerProfiles(careerProfiles);
|
||||
@ -777,7 +777,7 @@ useEffect(() => {
|
||||
|
||||
const refetchScenario = useCallback(async () => {
|
||||
if (!careerProfileId) return;
|
||||
const r = await authFetch('api/premium/career-profile/${careerProfileId}');
|
||||
const r = await authFetch('/api/premium/career-profile/${careerProfileId}');
|
||||
if (r.ok) setScenarioRow(await r.json());
|
||||
}, [careerProfileId]);
|
||||
|
||||
@ -835,7 +835,7 @@ try {
|
||||
if (err.response && err.response.status === 404) {
|
||||
try {
|
||||
// Call GPT via server3
|
||||
const aiRes = await axios.post('api/public/ai-risk-analysis', {
|
||||
const aiRes = await axios.post('/api/public/ai-risk-analysis', {
|
||||
socCode,
|
||||
careerName,
|
||||
jobDescription: description,
|
||||
@ -869,7 +869,7 @@ try {
|
||||
}
|
||||
|
||||
// 3) Store in server2
|
||||
await axios.post('api/ai-risk', storePayload);
|
||||
await axios.post('/api/ai-risk', storePayload);
|
||||
|
||||
// Construct final object for usage here
|
||||
aiRisk = {
|
||||
|
@ -1,23 +1,63 @@
|
||||
// src/components/CareerSuggestions.js
|
||||
import React from 'react';
|
||||
import { Button } from './ui/button.js';
|
||||
|
||||
export function CareerSuggestions({
|
||||
/**
|
||||
* Grid of career buttons.
|
||||
*
|
||||
* @param {Object[]} careerSuggestions – array of career objects
|
||||
* @param {(career) => void} onCareerClick – callback when a button is clicked
|
||||
*/
|
||||
export default function CareerSuggestions({
|
||||
careerSuggestions = [],
|
||||
onCareerClick,
|
||||
}) {
|
||||
return (
|
||||
<div className="career-suggestions-grid">
|
||||
{careerSuggestions.map((career) => (
|
||||
<button
|
||||
key={career.code}
|
||||
className={`career-button ${career.limitedData ? 'limited-data' : ''}`}
|
||||
onClick={() => onCareerClick(career)}
|
||||
>
|
||||
{career.title}
|
||||
{career.limitedData && <span className="warning-icon"> ⚠️</span>}
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
/* similar to: grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap:10px; padding:10px */
|
||||
className="
|
||||
grid w-full
|
||||
gap-[10px] p-[10px]
|
||||
[grid-template-columns:repeat(auto-fit,minmax(12rem,1fr))]
|
||||
"
|
||||
>
|
||||
{careerSuggestions.map((career) => {
|
||||
const isLimited = career.limitedData;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={career.code}
|
||||
onClick={() => onCareerClick(career)}
|
||||
/* Tailwind recreation of your old CSS ------------------------ */
|
||||
variant="outline"
|
||||
className={`
|
||||
flex h-full w-full items-center justify-center text-center
|
||||
whitespace-normal break-words
|
||||
font-bold text-[14px] leading-snug
|
||||
px-[10px] py-[8px] rounded-[3px]
|
||||
transition-colors
|
||||
|
||||
/* default style = #007bff -> darker on hover */
|
||||
bg-[#007bff] hover:bg-[#0056b3] text-white
|
||||
|
||||
/* limited-data override */
|
||||
${isLimited && `
|
||||
!bg-blue-300 !hover:bg-blue-200 /* light blue fill */
|
||||
!text-white
|
||||
border-2 !border-amber-500 /* orange border */
|
||||
`}
|
||||
`}
|
||||
>
|
||||
<span>{career.title}</span>
|
||||
|
||||
{isLimited && (
|
||||
<span className="ml-[6px] text-[14px] font-bold text-yellow-300">
|
||||
⚠️
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CareerSuggestions;
|
||||
|
@ -22,6 +22,7 @@ export default function ChatDrawer({
|
||||
pane: controlledPane = 'support',
|
||||
setPane: setControlledPane,
|
||||
retireProps = null, // { scenario, financialProfile, … }
|
||||
canShowRetireBot
|
||||
}) {
|
||||
/* ─────────────────────────── internal / fallback state ───────── */
|
||||
const [openLocal, setOpenLocal] = useState(false);
|
||||
@ -59,6 +60,13 @@ export default function ChatDrawer({
|
||||
return [...prev, { role: 'assistant', content: chunk }];
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!canShowRetireBot && pane === 'retire') {
|
||||
setPane('support');
|
||||
}
|
||||
}, [canShowRetireBot, pane, setPane]);
|
||||
|
||||
/* ───────────────────────── send support-bot prompt ───────────── */
|
||||
async function sendPrompt() {
|
||||
const text = prompt.trim();
|
||||
@ -125,49 +133,63 @@ export default function ChatDrawer({
|
||||
}
|
||||
};
|
||||
|
||||
/* ──────────────────────────── UI ─────────────────────────────── */
|
||||
/* ---------- render ---------- */
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
{/* floating FAB */}
|
||||
<SheetTrigger asChild>
|
||||
{/* floating action button */}
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open chat"
|
||||
className="fixed bottom-6 right-6 z-50 rounded-full bg-blue-600 p-3 text-white shadow-lg hover:bg-blue-700"
|
||||
onClick={() => onOpenChange(!open)}
|
||||
className="fixed bottom-6 right-6 z-40 flex h-14 w-14
|
||||
items-center justify-center rounded-full
|
||||
bg-blue-600 text-white shadow-lg
|
||||
hover:bg-blue-700 focus:outline-none"
|
||||
/* -------- explicitly open Support pane -------- */
|
||||
onClick={() => {
|
||||
setPane('support');
|
||||
onOpenChange(true); // <‑ force the controlled state
|
||||
}}
|
||||
>
|
||||
<MessageCircle size={24} />
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
|
||||
{/* side-drawer */}
|
||||
{/* side drawer */}
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex max-h-screen w-[380px] flex-col px-0 md:w-[420px]"
|
||||
className="flex h-full w-[370px] flex-col p-0 md:w-[420px]"
|
||||
>
|
||||
{/* header – tab switch */}
|
||||
<div className="flex border-b text-sm font-semibold">
|
||||
{[
|
||||
{ id: 'support', label: 'Aptiva Support' },
|
||||
{ id: 'retire', label: 'Retirement Helper' },
|
||||
].map((tab) => (
|
||||
{/* header (tabs only if retirement bot is allowed) */}
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
className={
|
||||
pane === 'support'
|
||||
? 'flex-1 px-4 py-3 text-sm font-semibold border-b-2 border-blue-600'
|
||||
: 'flex-1 px-4 py-3 text-sm text-gray-500 hover:bg-gray-50'
|
||||
}
|
||||
onClick={() => setPane('support')}
|
||||
>
|
||||
Aptiva Support
|
||||
</button>
|
||||
|
||||
{canShowRetireBot && (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setPane(tab.id)}
|
||||
className={cn(
|
||||
'flex-1 py-2',
|
||||
pane === tab.id
|
||||
? 'border-b-2 border-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
className={
|
||||
pane === 'retire'
|
||||
? 'flex-1 px-4 py-3 text-sm font-semibold border-b-2 border-blue-600'
|
||||
: 'flex-1 px-4 py-3 text-sm text-gray-500 hover:bg-gray-50'
|
||||
}
|
||||
onClick={() => setPane('retire')}
|
||||
>
|
||||
{tab.label}
|
||||
Retirement Helper
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* body – conditional panes */}
|
||||
{/* body */}
|
||||
{pane === 'support' ? (
|
||||
/* ─────────── Support bot ─────────── */
|
||||
/* ── Support bot pane ── */
|
||||
<>
|
||||
<div
|
||||
ref={listRef}
|
||||
@ -175,13 +197,9 @@ export default function ChatDrawer({
|
||||
>
|
||||
{messages.map((m, i) => (
|
||||
<div
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
key={i}
|
||||
/* eslint-enable react/no-array-index-key */
|
||||
className={
|
||||
m.role === 'user'
|
||||
? 'text-right'
|
||||
: 'text-left text-gray-800'
|
||||
m.role === 'user' ? 'text-right' : 'text-left text-gray-800'
|
||||
}
|
||||
>
|
||||
{m.content}
|
||||
@ -200,8 +218,13 @@ export default function ChatDrawer({
|
||||
<Input
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask me anything…"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendPrompt();
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" disabled={!prompt.trim()}>
|
||||
@ -211,9 +234,10 @@ export default function ChatDrawer({
|
||||
</div>
|
||||
</>
|
||||
) : retireProps ? (
|
||||
/* ───────── Retirement helper ─────── */
|
||||
/* ── Retirement helper pane ── */
|
||||
<RetirementChatBar {...retireProps} />
|
||||
) : (
|
||||
/* failsafe (retire tab opened before selecting a scenario) */
|
||||
<div className="m-auto px-6 text-center text-sm text-gray-400">
|
||||
Select a scenario in
|
||||
<strong>Retirement Planner</strong>
|
||||
@ -222,4 +246,4 @@ export default function ChatDrawer({
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
import moment from 'moment/moment.js';
|
||||
|
||||
|
||||
/** -----------------------------------------------------------
|
||||
@ -52,6 +53,9 @@ export default function CollegeProfileForm() {
|
||||
const [ipeds, setIpeds] = useState([]);
|
||||
const [schoolValid, setSchoolValid] = useState(true);
|
||||
const [programValid, setProgramValid] = useState(true);
|
||||
const [autoGradDate, setAutoGradDate] = useState('');
|
||||
const [graduationTouched, setGraduationTouched] = useState(false);
|
||||
const [programLengthTouched, setProgramLengthTouched] = useState(false);
|
||||
|
||||
const schoolData = cipRows;
|
||||
|
||||
@ -78,7 +82,7 @@ const handleFieldChange = (e) => {
|
||||
setForm((prev) => {
|
||||
const draft = { ...prev };
|
||||
if (type === 'checkbox') {
|
||||
draft[name] = checked;
|
||||
draft[name] = checked;
|
||||
} else if (
|
||||
[
|
||||
'interest_rate','loan_term','extra_payment','expected_salary',
|
||||
@ -88,6 +92,7 @@ const handleFieldChange = (e) => {
|
||||
].includes(name)
|
||||
) {
|
||||
draft[name] = value === '' ? '' : parseFloat(value);
|
||||
if (name === 'program_length') setProgramLengthTouched(true);
|
||||
} else {
|
||||
draft[name] = value;
|
||||
}
|
||||
@ -178,7 +183,6 @@ useEffect(()=>{
|
||||
setTypes([...new Set(t)]);
|
||||
},[form.selected_school, form.selected_program, cipRows]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!ipeds.length) return;
|
||||
if (!form.selected_school ||
|
||||
@ -235,6 +239,73 @@ const chosenTuition = manualTuition.trim() === ''
|
||||
? autoTuition
|
||||
: parseFloat(manualTuition);
|
||||
|
||||
/* ────────────────────────────────────────────────────────────
|
||||
Auto‑calculate PROGRAM LENGTH when the user hasn’t typed in
|
||||
the field themselves. Triggers when hours / CHPY change.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
if (programLengthTouched) return; // user override
|
||||
|
||||
const chpy = parseFloat(form.credit_hours_per_year);
|
||||
if (!chpy || chpy <= 0) return;
|
||||
|
||||
/* 1 – figure out how many credits remain.
|
||||
If the plan doesn’t have credit_hours_required saved
|
||||
we fall back to a common default for the degree type. */
|
||||
let creditsNeeded = parseFloat(form.credit_hours_required);
|
||||
|
||||
if (!creditsNeeded) {
|
||||
switch (form.program_type) {
|
||||
case "Associate's Degree": creditsNeeded = 60; break;
|
||||
case "Master's Degree": creditsNeeded = 30; break;
|
||||
case "Doctoral Degree": creditsNeeded = 60; break;
|
||||
default: /* Bachelor et al. */ creditsNeeded = 120;
|
||||
}
|
||||
}
|
||||
creditsNeeded -= parseFloat(form.hours_completed || 0);
|
||||
if (creditsNeeded <= 0) return;
|
||||
|
||||
/* 2 – years = credits / CHPY → one decimal place */
|
||||
const years = Math.ceil((creditsNeeded / chpy) * 10) / 10;
|
||||
|
||||
if (years !== form.program_length) {
|
||||
setForm(prev => ({ ...prev, program_length: years }));
|
||||
}
|
||||
}, [
|
||||
form.credit_hours_required,
|
||||
form.credit_hours_per_year,
|
||||
form.hours_completed,
|
||||
form.program_type,
|
||||
programLengthTouched
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (graduationTouched) return;
|
||||
|
||||
const years = parseFloat(form.program_length);
|
||||
if (!years || years <= 0) return;
|
||||
|
||||
const start = form.enrollment_date
|
||||
? moment(form.enrollment_date)
|
||||
: moment();
|
||||
|
||||
const iso = start.add(years, 'years')
|
||||
.startOf('month')
|
||||
.format('YYYY-MM-DD');
|
||||
|
||||
setAutoGradDate(iso);
|
||||
setForm(prev => ({ ...prev, expected_graduation: iso }));
|
||||
}, [
|
||||
form.program_length,
|
||||
form.credit_hours_required,
|
||||
form.credit_hours_per_year,
|
||||
form.hours_completed,
|
||||
form.credit_hours_per_year,
|
||||
form.enrollment_date,
|
||||
graduationTouched
|
||||
]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6 space-y-4">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
@ -378,6 +449,36 @@ return (
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* ──────────────── Dates ──────────────── */}
|
||||
{form.college_enrollment_status === 'prospective_student' && (
|
||||
<div className="space-y-1">
|
||||
<label className="block font-medium">Anticipated Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
name="enrollment_date"
|
||||
value={form.enrollment_date || ''}
|
||||
onChange={handleFieldChange}
|
||||
className="w-full border rounded p-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="block font-medium">Expected Graduation Date</label>
|
||||
<input
|
||||
type="date"
|
||||
name="expected_graduation"
|
||||
value={form.expected_graduation || autoGradDate}
|
||||
onChange={e => {
|
||||
handleFieldChange(e);
|
||||
setGraduationTouched(true);
|
||||
}}
|
||||
className="w-full border rounded p-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 7 │ Tuition & aid */}
|
||||
<div className="space-y-1">
|
||||
<label className="block font-medium">Yearly Tuition</label>
|
||||
|
@ -1,788 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
|
||||
import { CareerSuggestions } from './CareerSuggestions.js';
|
||||
import PopoutPanel from './PopoutPanel.js';
|
||||
import CareerSearch from './CareerSearch.js'; // <--- Import your new search
|
||||
import Chatbot from './Chatbot.js';
|
||||
|
||||
import "../styles/legacy/Dashboard.legacy.css";
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import { fetchSchools } from '../utils/apiUtils.js';
|
||||
|
||||
const STATES = [
|
||||
{ name: 'Alabama', code: 'AL' },
|
||||
{ name: 'Alaska', code: 'AK' },
|
||||
{ name: 'Arizona', code: 'AZ' },
|
||||
{ name: 'Arkansas', code: 'AR' },
|
||||
{ name: 'California', code: 'CA' },
|
||||
{ name: 'Colorado', code: 'CO' },
|
||||
{ name: 'Connecticut', code: 'CT' },
|
||||
{ name: 'Delaware', code: 'DE' },
|
||||
{ name: 'District of Columbia', code: 'DC' },
|
||||
{ name: 'Florida', code: 'FL' },
|
||||
{ name: 'Georgia', code: 'GA' },
|
||||
{ name: 'Hawaii', code: 'HI' },
|
||||
{ name: 'Idaho', code: 'ID' },
|
||||
{ name: 'Illinois', code: 'IL' },
|
||||
{ name: 'Indiana', code: 'IN' },
|
||||
{ name: 'Iowa', code: 'IA' },
|
||||
{ name: 'Kansas', code: 'KS' },
|
||||
{ name: 'Kentucky', code: 'KY' },
|
||||
{ name: 'Louisiana', code: 'LA' },
|
||||
{ name: 'Maine', code: 'ME' },
|
||||
{ name: 'Maryland', code: 'MD' },
|
||||
{ name: 'Massachusetts', code: 'MA' },
|
||||
{ name: 'Michigan', code: 'MI' },
|
||||
{ name: 'Minnesota', code: 'MN' },
|
||||
{ name: 'Mississippi', code: 'MS' },
|
||||
{ name: 'Missouri', code: 'MO' },
|
||||
{ name: 'Montana', code: 'MT' },
|
||||
{ name: 'Nebraska', code: 'NE' },
|
||||
{ name: 'Nevada', code: 'NV' },
|
||||
{ name: 'New Hampshire', code: 'NH' },
|
||||
{ name: 'New Jersey', code: 'NJ' },
|
||||
{ name: 'New Mexico', code: 'NM' },
|
||||
{ name: 'New York', code: 'NY' },
|
||||
{ name: 'North Carolina', code: 'NC' },
|
||||
{ name: 'North Dakota', code: 'ND' },
|
||||
{ name: 'Ohio', code: 'OH' },
|
||||
{ name: 'Oklahoma', code: 'OK' },
|
||||
{ name: 'Oregon', code: 'OR' },
|
||||
{ name: 'Pennsylvania', code: 'PA' },
|
||||
{ name: 'Rhode Island', code: 'RI' },
|
||||
{ name: 'South Carolina', code: 'SC' },
|
||||
{ name: 'South Dakota', code: 'SD' },
|
||||
{ name: 'Tennessee', code: 'TN' },
|
||||
{ name: 'Texas', code: 'TX' },
|
||||
{ name: 'Utah', code: 'UT' },
|
||||
{ name: 'Vermont', code: 'VT' },
|
||||
{ name: 'Virginia', code: 'VA' },
|
||||
{ name: 'Washington', code: 'WA' },
|
||||
{ name: 'West Virginia', code: 'WV' },
|
||||
{ name: 'Wisconsin', code: 'WI' },
|
||||
{ name: 'Wyoming', code: 'WY' },
|
||||
];
|
||||
|
||||
// 2) Helper to convert state code => full name
|
||||
function getFullStateName(code) {
|
||||
const found = STATES.find((s) => s.code === code?.toUpperCase());
|
||||
return found ? found.name : '';
|
||||
}
|
||||
|
||||
// Haversine formula helper
|
||||
function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
// approximate radius of earth in miles
|
||||
const R = 3959;
|
||||
const toRad = (val) => (val * Math.PI) / 180;
|
||||
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) *
|
||||
Math.cos(toRad(lat2)) *
|
||||
Math.sin(dLon / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c; // in miles
|
||||
}
|
||||
|
||||
// **Added**: A small helper to geocode the user's ZIP on the client side.
|
||||
async function clientGeocodeZip(zip) {
|
||||
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
|
||||
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(
|
||||
zip
|
||||
)}&key=${apiKey}`;
|
||||
|
||||
const resp = await axios.get(url);
|
||||
if (
|
||||
resp.data.status === 'OK' &&
|
||||
resp.data.results &&
|
||||
resp.data.results.length > 0
|
||||
) {
|
||||
return resp.data.results[0].geometry.location; // { lat, lng }
|
||||
}
|
||||
throw new Error('Geocoding failed.');
|
||||
}
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
||||
|
||||
function Dashboard() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ============= Existing States =============
|
||||
const [careerSuggestions, setCareerSuggestions] = useState([]);
|
||||
const [careerDetails, setCareerDetails] = useState(null);
|
||||
const [riaSecScores, setRiaSecScores] = useState([]);
|
||||
const [selectedCareer, setSelectedCareer] = useState(null);
|
||||
const [schools, setSchools] = useState([]);
|
||||
const [salaryData, setSalaryData] = useState([]);
|
||||
const [economicProjections, setEconomicProjections] = useState(null);
|
||||
const [tuitionData, setTuitionData] = useState(null);
|
||||
|
||||
// Overall Dashboard loading
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const [error, setError] = useState(null);
|
||||
const [userState, setUserState] = useState(null);
|
||||
const [areaTitle, setAreaTitle] = useState(null);
|
||||
const [userZipcode, setUserZipcode] = useState(null);
|
||||
const [riaSecDescriptions, setRiaSecDescriptions] = useState([]);
|
||||
const [selectedJobZone, setSelectedJobZone] = useState('');
|
||||
const [careersWithJobZone, setCareersWithJobZone] = useState([]);
|
||||
const [selectedFit, setSelectedFit] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [chatbotContext, setChatbotContext] = useState({});
|
||||
|
||||
// Show session expired modal
|
||||
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false);
|
||||
|
||||
|
||||
// ============= NEW State =============
|
||||
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
|
||||
|
||||
// We'll treat "loading" as "loadingSuggestions"
|
||||
const loadingSuggestions = loading;
|
||||
const popoutVisible = !!selectedCareer;
|
||||
|
||||
// ============= Auth & URL Setup =============
|
||||
|
||||
// AUTH fetch
|
||||
const authFetch = async (url, options = {}, onUnauthorized) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
console.log('Token is missing, triggering session expired modal.');
|
||||
if (typeof onUnauthorized === 'function') onUnauthorized();
|
||||
return null;
|
||||
}
|
||||
const finalOptions = {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const res = await fetch(url, finalOptions);
|
||||
console.log('Response Status:', res.status);
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
console.log('Session expired, triggering session expired modal.');
|
||||
if (typeof onUnauthorized === 'function') onUnauthorized();
|
||||
return null;
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
console.error('Fetch error:', err);
|
||||
if (typeof onUnauthorized === 'function') onUnauthorized();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ============= Fetch user profile =============
|
||||
const fetchUserProfile = async () => {
|
||||
const res = await authFetch('api/user-profile');
|
||||
if (!res) return;
|
||||
|
||||
if (res.ok) {
|
||||
const profileData = await res.json();
|
||||
setUserState(profileData.state);
|
||||
setAreaTitle(profileData.area.trim() || '');
|
||||
setUserZipcode(profileData.zipcode);
|
||||
// Store entire userProfile if needed
|
||||
setUserProfile(profileData);
|
||||
} else {
|
||||
console.error('Failed to fetch user profile');
|
||||
}
|
||||
};
|
||||
|
||||
// We'll store the userProfile for reference
|
||||
const [userProfile, setUserProfile] = useState(null);
|
||||
|
||||
// ============= Lifecycle: Load Profile =============
|
||||
useEffect(() => {
|
||||
fetchUserProfile();
|
||||
}, []); // load once
|
||||
|
||||
// ============= jobZone & Fit Setup =============
|
||||
const jobZoneLabels = {
|
||||
'1': 'Little or No Preparation',
|
||||
'2': 'Some Preparation Needed',
|
||||
'3': 'Medium Preparation Needed',
|
||||
'4': 'Considerable Preparation Needed',
|
||||
'5': 'Extensive Preparation Needed',
|
||||
};
|
||||
|
||||
const fitLabels = {
|
||||
Best: 'Best - Very Strong Match',
|
||||
Great: 'Great - Strong Match',
|
||||
Good: 'Good - Less Strong Match',
|
||||
};
|
||||
|
||||
// ============= "Mimic" InterestInventory submission if user has 60 answers =============
|
||||
const mimicInterestInventorySubmission = async (answers) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setProgress(0);
|
||||
const response = await authFetch('api/onet/submit_answers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers }),
|
||||
});
|
||||
if (!response || !response.ok) {
|
||||
throw new Error('Failed to submit stored answers');
|
||||
}
|
||||
const data = await response.json();
|
||||
const { careers, riaSecScores } = data;
|
||||
|
||||
// This sets location.state, so the next effect sees it as if we came from InterestInventory
|
||||
navigate('/dashboard', {
|
||||
state: { careerSuggestions: careers, riaSecScores },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error mimicking submission:', err);
|
||||
alert('We could not load your saved answers. Please retake the Interest Inventory.');
|
||||
navigate('/interest-inventory');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ============= On Page Load: get careerSuggestions from location.state, or mimic =============
|
||||
useEffect(() => {
|
||||
// If we have location.state from InterestInventory, proceed as normal
|
||||
if (location.state) {
|
||||
let descriptions = [];
|
||||
const { careerSuggestions: suggestions, riaSecScores: scores } = location.state || {};
|
||||
descriptions = (scores || []).map((score) => score.description || 'No description available.');
|
||||
setCareerSuggestions(suggestions || []);
|
||||
setRiaSecScores(scores || []);
|
||||
setRiaSecDescriptions(descriptions);
|
||||
} else {
|
||||
// We came here directly; wait for userProfile, then check answers
|
||||
if (!userProfile) return; // wait until userProfile is loaded
|
||||
|
||||
const storedAnswers = userProfile.interest_inventory_answers;
|
||||
if (storedAnswers && storedAnswers.length === 60) {
|
||||
// Mimic the submission so we get suggestions
|
||||
mimicInterestInventorySubmission(storedAnswers);
|
||||
} else {
|
||||
alert(
|
||||
'We need your Interest Inventory answers to generate career suggestions. Redirecting...'
|
||||
);
|
||||
navigate('/interest-inventory');
|
||||
}
|
||||
}
|
||||
}, [location.state, navigate, userProfile]);
|
||||
|
||||
// ============= jobZone fetch =============
|
||||
useEffect(() => {
|
||||
const fetchJobZones = async () => {
|
||||
if (careerSuggestions.length === 0) return;
|
||||
const socCodes = careerSuggestions.map((career) => career.code);
|
||||
try {
|
||||
const response = await axios.post('api/job-zones', { socCodes });
|
||||
const jobZoneData = response.data;
|
||||
const updatedCareers = careerSuggestions.map((career) => ({
|
||||
...career,
|
||||
job_zone: jobZoneData[career.code.slice(0, -3)]?.job_zone || null,
|
||||
}));
|
||||
setCareersWithJobZone(updatedCareers);
|
||||
} catch (error) {
|
||||
console.error('Error fetching job zone information:', error);
|
||||
}
|
||||
};
|
||||
fetchJobZones();
|
||||
}, [careerSuggestions]);
|
||||
|
||||
// ============= Filter by job zone, fit =============
|
||||
const filteredCareers = useMemo(() => {
|
||||
return careersWithJobZone.filter((career) => {
|
||||
const jobZoneMatches = selectedJobZone
|
||||
? career.job_zone !== null &&
|
||||
career.job_zone !== undefined &&
|
||||
typeof career.job_zone === 'number' &&
|
||||
Number(career.job_zone) === Number(selectedJobZone)
|
||||
: true;
|
||||
|
||||
const fitMatches = selectedFit ? career.fit === selectedFit : true;
|
||||
return jobZoneMatches && fitMatches;
|
||||
});
|
||||
}, [careersWithJobZone, selectedJobZone, selectedFit]);
|
||||
|
||||
// ============= Merge data into chatbot context =============
|
||||
const updateChatbotContext = (updatedData) => {
|
||||
setChatbotContext((prevContext) => {
|
||||
const mergedContext = {
|
||||
...prevContext,
|
||||
...Object.keys(updatedData).reduce((acc, key) => {
|
||||
if (updatedData[key] !== undefined && updatedData[key] !== null) {
|
||||
acc[key] = updatedData[key];
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
return mergedContext;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
careerSuggestions.length > 0 &&
|
||||
riaSecScores.length > 0 &&
|
||||
userState !== null &&
|
||||
areaTitle !== null &&
|
||||
userZipcode !== null
|
||||
) {
|
||||
const newChatbotContext = {
|
||||
careerSuggestions: [...careersWithJobZone],
|
||||
riaSecScores: [...riaSecScores],
|
||||
userState: userState || '',
|
||||
areaTitle: areaTitle || '',
|
||||
userZipcode: userZipcode || '',
|
||||
};
|
||||
setChatbotContext(newChatbotContext);
|
||||
}
|
||||
}, [careerSuggestions, riaSecScores, userState, areaTitle, userZipcode, careersWithJobZone]);
|
||||
|
||||
|
||||
// ============= handleCareerClick =============
|
||||
const handleCareerClick = useCallback(
|
||||
async (career) => {
|
||||
console.log('[handleCareerClick] career =>', career);
|
||||
const socCode = career.code;
|
||||
console.log('[handleCareerClick] career.code =>', socCode);
|
||||
setSelectedCareer(career);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCareerDetails({});
|
||||
setSchools([]);
|
||||
setSalaryData([]);
|
||||
setEconomicProjections({});
|
||||
setTuitionData([]);
|
||||
|
||||
if (!socCode) {
|
||||
console.error('SOC Code is missing');
|
||||
setError('SOC Code is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// CIP fetch
|
||||
const cipResponse = await fetch(`api/cip/${socCode}`);
|
||||
if (!cipResponse.ok) throw new Error('Failed to fetch CIP Code');
|
||||
const { cipCode } = await cipResponse.json();
|
||||
const cleanedCipCode = cipCode.replace('.', '').slice(0, 4);
|
||||
|
||||
// Job details
|
||||
const jobDetailsResponse = await fetch(`api/onet/career-description/${socCode}`);
|
||||
if (!jobDetailsResponse.ok) throw new Error('Failed to fetch job description');
|
||||
const { description, tasks } = await jobDetailsResponse.json();
|
||||
|
||||
// Salary
|
||||
let salaryResponse;
|
||||
try {
|
||||
salaryResponse = await axios.get('api/salary', {
|
||||
params: { socCode: socCode.split('.')[0], area: areaTitle },
|
||||
});
|
||||
} catch (error) {
|
||||
salaryResponse = { data: {} };
|
||||
}
|
||||
|
||||
const fullName = getFullStateName(userState);
|
||||
|
||||
// Economic
|
||||
let economicResponse;
|
||||
try {
|
||||
economicResponse = await axios.get(`api/projections/${socCode.split('.')[0]}`, {
|
||||
params: { state: fullName }, // e.g. "Kentucky"
|
||||
});
|
||||
} catch (error) {
|
||||
economicResponse = { data: {} };
|
||||
}
|
||||
|
||||
// Tuition
|
||||
let tuitionResponse;
|
||||
try {
|
||||
tuitionResponse = await axios.get('api/tuition', {
|
||||
params: { cipCode: cleanedCipCode, state: userState },
|
||||
});
|
||||
} catch (error) {
|
||||
tuitionResponse = { data: {} };
|
||||
}
|
||||
|
||||
// ** FETCH SCHOOLS NORMALLY **
|
||||
const filteredSchools = await fetchSchools(cleanedCipCode, userState);
|
||||
|
||||
// ** 1) Geocode user zip once on the client **
|
||||
let userLat = null;
|
||||
let userLng = null;
|
||||
if (userZipcode) {
|
||||
try {
|
||||
const geocodeResult = await clientGeocodeZip(userZipcode);
|
||||
userLat = geocodeResult.lat;
|
||||
userLng = geocodeResult.lng;
|
||||
} catch (err) {
|
||||
console.warn('Unable to geocode user ZIP, distances will be N/A.');
|
||||
}
|
||||
}
|
||||
|
||||
// ** 2) Compute Haversine distance locally for each school **
|
||||
const schoolsWithDistance = filteredSchools.map((sch) => {
|
||||
// only if we have lat/lon for both user + school
|
||||
const lat2 = sch.LATITUDE ? parseFloat(sch.LATITUDE) : null;
|
||||
const lon2 = sch.LONGITUD ? parseFloat(sch.LONGITUD) : null;
|
||||
|
||||
if (userLat && userLng && lat2 && lon2) {
|
||||
const distMiles = haversineDistance(userLat, userLng, lat2, lon2);
|
||||
return {
|
||||
...sch,
|
||||
distance: distMiles.toFixed(1) + ' mi',
|
||||
duration: 'N/A',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...sch,
|
||||
distance: 'N/A',
|
||||
duration: 'N/A',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Build salary array
|
||||
const sData = salaryResponse.data || {};
|
||||
const salaryDataPoints =
|
||||
sData && Object.keys(sData).length > 0
|
||||
? [
|
||||
{
|
||||
percentile: '10th Percentile',
|
||||
regionalSalary: parseInt(sData.regional?.regional_PCT10, 10) || 0,
|
||||
nationalSalary: parseInt(sData.national?.national_PCT10, 10) || 0,
|
||||
},
|
||||
{
|
||||
percentile: '25th Percentile',
|
||||
regionalSalary: parseInt(sData.regional?.regional_PCT25, 10) || 0,
|
||||
nationalSalary: parseInt(sData.national?.national_PCT25, 10) || 0,
|
||||
},
|
||||
{
|
||||
percentile: 'Median',
|
||||
regionalSalary: parseInt(sData.regional?.regional_MEDIAN, 10) || 0,
|
||||
nationalSalary: parseInt(sData.national?.national_MEDIAN, 10) || 0,
|
||||
},
|
||||
{
|
||||
percentile: '75th Percentile',
|
||||
regionalSalary: parseInt(sData.regional?.regional_PCT75, 10) || 0,
|
||||
nationalSalary: parseInt(sData.national?.national_PCT75, 10) || 0,
|
||||
},
|
||||
{
|
||||
percentile: '90th Percentile',
|
||||
regionalSalary: parseInt(sData.regional?.regional_PCT90, 10) || 0,
|
||||
nationalSalary: parseInt(sData.national?.national_PCT90, 10) || 0,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
// Build final details
|
||||
const updatedCareerDetails = {
|
||||
...career,
|
||||
jobDescription: description,
|
||||
tasks: tasks,
|
||||
economicProjections: economicResponse.data || {},
|
||||
salaryData: salaryDataPoints,
|
||||
schools: schoolsWithDistance,
|
||||
tuitionData: tuitionResponse.data || [],
|
||||
};
|
||||
|
||||
setCareerDetails(updatedCareerDetails);
|
||||
updateChatbotContext({ careerDetails: updatedCareerDetails });
|
||||
} catch (error) {
|
||||
console.error('Error processing career click:', error.message);
|
||||
setError('Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[userState, areaTitle, userZipcode, updateChatbotContext]
|
||||
);
|
||||
|
||||
// ============= Let typed careers open PopoutPanel =============
|
||||
const handleCareerFromSearch = useCallback(
|
||||
(obj) => {
|
||||
const adapted = {
|
||||
code: obj.soc_code,
|
||||
title: obj.title,
|
||||
cipCode: obj.cip_code,
|
||||
};
|
||||
console.log('[Dashboard -> handleCareerFromSearch] adapted =>', adapted);
|
||||
handleCareerClick(adapted);
|
||||
},
|
||||
[handleCareerClick]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingCareerForModal) {
|
||||
console.log('[useEffect] pendingCareerForModal =>', pendingCareerForModal);
|
||||
handleCareerFromSearch(pendingCareerForModal);
|
||||
setPendingCareerForModal(null);
|
||||
}
|
||||
}, [pendingCareerForModal, handleCareerFromSearch]);
|
||||
|
||||
// ============= RIASEC Chart Data =============
|
||||
const chartData = {
|
||||
labels: riaSecScores.map((score) => score.area),
|
||||
datasets: [
|
||||
{
|
||||
label: 'RIASEC Scores',
|
||||
data: riaSecScores.map((score) => score.score),
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||
borderColor: 'rgba(75, 192, 192, 1)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ============= Hide the spinner if popout is open =============
|
||||
const renderLoadingOverlay = () => {
|
||||
if (!loadingSuggestions || popoutVisible) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-50">
|
||||
<div className="rounded bg-white p-6 shadow-lg">
|
||||
<div className="mb-2 w-full max-w-md rounded bg-gray-200">
|
||||
<div
|
||||
className="h-2 rounded bg-blue-500 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-center text-sm text-gray-600">
|
||||
{progress}% — Loading Career Suggestions...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============= Popout Panel Setup =============
|
||||
const memoizedPopoutPanel = useMemo(() => {
|
||||
return (
|
||||
<PopoutPanel
|
||||
isVisible={!!selectedCareer}
|
||||
data={careerDetails}
|
||||
schools={schools}
|
||||
salaryData={salaryData}
|
||||
economicProjections={economicProjections}
|
||||
tuitionData={tuitionData}
|
||||
closePanel={() => setSelectedCareer(null)}
|
||||
loading={loading}
|
||||
error={error}
|
||||
userState={userState}
|
||||
results={results}
|
||||
updateChatbotContext={updateChatbotContext}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
selectedCareer,
|
||||
careerDetails,
|
||||
schools,
|
||||
salaryData,
|
||||
economicProjections,
|
||||
tuitionData,
|
||||
loading,
|
||||
error,
|
||||
userState,
|
||||
results,
|
||||
updateChatbotContext,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
{showSessionExpiredModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<h3>Session Expired</h3>
|
||||
<p>Your session has expired or is invalid.</p>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="confirm-btn"
|
||||
onClick={() => setShowSessionExpiredModal(false)}
|
||||
>
|
||||
Stay Signed In
|
||||
</button>
|
||||
<button
|
||||
className="confirm-btn"
|
||||
onClick={() => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('id');
|
||||
setShowSessionExpiredModal(false);
|
||||
navigate('/signin');
|
||||
}}
|
||||
>
|
||||
Sign In Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderLoadingOverlay()}
|
||||
|
||||
<div className="dashboard-content">
|
||||
{/* ====== 1) The new CareerSearch bar ====== */}
|
||||
|
||||
{/* Existing filters + suggestions */}
|
||||
<div className="career-suggestions-container">
|
||||
<div
|
||||
className="career-suggestions-header"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: '15px',
|
||||
justifyContent: 'center',
|
||||
gap: '15px',
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Preparation Level:
|
||||
<select
|
||||
value={selectedJobZone}
|
||||
onChange={(e) => setSelectedJobZone(Number(e.target.value))}
|
||||
style={{ marginLeft: '5px', padding: '2px', width: '200px' }}
|
||||
>
|
||||
<option value="">All Preparation Levels</option>
|
||||
{Object.entries(jobZoneLabels).map(([zone, label]) => (
|
||||
<option key={zone} value={zone}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Fit:
|
||||
<select
|
||||
value={selectedFit}
|
||||
onChange={(e) => setSelectedFit(e.target.value)}
|
||||
style={{ marginLeft: '5px', padding: '2px', width: '150px' }}
|
||||
>
|
||||
<option value="">All Fit Levels</option>
|
||||
{Object.entries(fitLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<CareerSearch
|
||||
onCareerSelected={(careerObj) => {
|
||||
console.log('[Dashboard] onCareerSelected =>', careerObj);
|
||||
// Set the "pendingCareerForModal" so our useEffect fires
|
||||
setPendingCareerForModal(careerObj);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CareerSuggestions
|
||||
careerSuggestions={filteredCareers}
|
||||
onCareerClick={handleCareerClick}
|
||||
setLoading={setLoading}
|
||||
setProgress={setProgress}
|
||||
userState={userState}
|
||||
areaTitle={areaTitle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* RIASEC Container */}
|
||||
<div className="riasec-container">
|
||||
<div className="riasec-scores">
|
||||
<h2>RIASEC Scores</h2>
|
||||
<Bar data={chartData} />
|
||||
</div>
|
||||
<div className="riasec-descriptions">
|
||||
<h3>RIASEC Personality Descriptions</h3>
|
||||
{riaSecDescriptions.length > 0 ? (
|
||||
<ul>
|
||||
{riaSecDescriptions.map((desc, index) => (
|
||||
<li key={index}>
|
||||
<strong>{riaSecScores[index]?.area}:</strong> {desc}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>Loading descriptions...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The PopoutPanel */}
|
||||
{memoizedPopoutPanel}
|
||||
|
||||
{/* Chatbot */}
|
||||
<div className="chatbot-widget">
|
||||
{careerSuggestions.length > 0 ? (
|
||||
<Chatbot context={chatbotContext} />
|
||||
) : (
|
||||
<p>Loading Chatbot...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="data-source-acknowledgment"
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
padding: '10px',
|
||||
borderTop: '1px solid #ccc',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Career results and RIASEC scores are provided by
|
||||
<a
|
||||
href="https://www.onetcenter.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{' '}
|
||||
O*Net
|
||||
</a>
|
||||
, in conjunction with the
|
||||
<a
|
||||
href="https://www.bls.gov"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{' '}
|
||||
Bureau of Labor Statistics
|
||||
</a>
|
||||
, and the
|
||||
<a
|
||||
href="https://nces.ed.gov"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{' '}
|
||||
National Center for Education Statistics (NCES)
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
@ -1,57 +0,0 @@
|
||||
// src/components/EditableCareerGoals.js
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button.js';
|
||||
import { Pencil, Save } from 'lucide-react';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
|
||||
export default function EditableCareerGoals({ initialGoals='', careerProfileId, onSaved }) {
|
||||
const [editing , setEditing ] = useState(false);
|
||||
const [draftText, setDraftText] = useState(initialGoals);
|
||||
const [saving , setSaving ] = useState(false);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
const res = await authFetch(`/api/premium/career-profile/${careerProfileId}/goals`, {
|
||||
method : 'PUT',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body : JSON.stringify({ career_goals: draftText })
|
||||
});
|
||||
if (res.ok) {
|
||||
onSaved(draftText);
|
||||
setEditing(false);
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-lg font-semibold">Your Career Goals</h3>
|
||||
{!editing && (
|
||||
<Button size="icon" variant="ghost" onClick={() => setEditing(true)}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
<textarea
|
||||
value={draftText}
|
||||
onChange={e => setDraftText(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full border rounded p-2 mt-2"
|
||||
placeholder="Describe your short- and long-term goals…"
|
||||
/>
|
||||
<Button onClick={save} disabled={saving} className="mt-2">
|
||||
{saving ? 'Saving…' : <><Save className="w-4 h-4 mr-1" /> Save</>}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 whitespace-pre-wrap text-gray-700">
|
||||
{initialGoals || <span className="italic text-gray-400">No goals entered yet.</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { fetchSchools } from '../utils/apiUtils.js';
|
||||
|
||||
function EducationalPrograms({ cipCode, userState }) {
|
||||
const [schools, setSchools] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSchools = async () => {
|
||||
if (!cipCode || !userState) {
|
||||
setError('CIP Code or user state is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filteredSchools = await fetchSchools(cipCode, userState);
|
||||
setSchools(filteredSchools);
|
||||
} catch (error) {
|
||||
console.error('Error fetching schools:', error);
|
||||
setError('Failed to load schools data');
|
||||
}
|
||||
};
|
||||
|
||||
loadSchools();
|
||||
}, [cipCode, userState]);
|
||||
|
||||
if (error) {
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
if (schools.length === 0) {
|
||||
return <div>No schools found for the selected CIP Code.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="educational-programs">
|
||||
<h3>Educational Programs</h3>
|
||||
<ul>
|
||||
{schools.map((school, index) => (
|
||||
<li key={index}>
|
||||
<strong>{school['Institution Name']}</strong><br />
|
||||
Degree Type: {school['Degree Type']}<br />
|
||||
CIP Code: {school['CIP Code']}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EducationalPrograms;
|
@ -1,87 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function GettingStarted() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleStartInventory = () => {
|
||||
navigate('/interest-inventory');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||
{/* Page Title */}
|
||||
<h1 className="mb-2 text-center text-3xl font-semibold">
|
||||
Welcome to AptivaAI
|
||||
</h1>
|
||||
<p className="mx-auto mb-8 max-w-xl text-center text-gray-700">
|
||||
Let’s start by getting to know you better. Completing the steps below
|
||||
will help us tailor career recommendations based on your interests.
|
||||
</p>
|
||||
|
||||
{/* Steps Container */}
|
||||
<div className="space-y-6">
|
||||
{/* Step 1 */}
|
||||
<div className="flex items-center space-x-4 rounded-lg bg-blue-50 p-6 shadow-sm">
|
||||
<span className="text-3xl">📄</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">
|
||||
Step 1: Set Up Your Profile
|
||||
</h2>
|
||||
<p className="mb-3 text-sm text-gray-700">
|
||||
Add details like your skills, education, and experience to further
|
||||
personalize your recommendations.
|
||||
</p>
|
||||
<button
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
onClick={() => navigate('/profile')}
|
||||
>
|
||||
Go to Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="flex items-center space-x-4 rounded-lg bg-blue-50 p-6 shadow-sm">
|
||||
<span className="text-3xl">🎯</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">
|
||||
Step 2: Complete the O*Net Interest Inventory
|
||||
</h2>
|
||||
<p className="mb-3 text-sm text-gray-700">
|
||||
Discover your career interests by taking the O*Net inventory.
|
||||
This will help us suggest personalized career paths for you.
|
||||
</p>
|
||||
<button
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
onClick={handleStartInventory}
|
||||
>
|
||||
Start Inventory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Premium Access */}
|
||||
<div className="mt-8 rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm">
|
||||
<h3 className="mb-2 text-lg font-medium">Already know your path?</h3>
|
||||
<p className="mb-4 text-sm text-gray-700">
|
||||
You can skip ahead and begin planning your journey now.
|
||||
</p>
|
||||
<button
|
||||
className="rounded bg-blue-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||
onClick={() =>
|
||||
navigate('/premium-onboarding', { state: { fromGettingStarted: true } })
|
||||
}
|
||||
>
|
||||
Access Premium Content{' '}
|
||||
<span className="ml-1 text-xs font-normal text-gray-100">
|
||||
(Premium)
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GettingStarted;
|
@ -155,7 +155,7 @@ const InterestInventory = () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
const response = await authFetch(`${process.env.REACT_APP_API_URL}/onet/submit_answers`, {
|
||||
const response = await authFetch('/api/onet/submit_answers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers }),
|
||||
|
@ -1,258 +0,0 @@
|
||||
// src/components/MilestoneAddModal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
CONSTANTS
|
||||
───────────────────────────────────────────────────────── */
|
||||
const IMPACT_TYPES = ['salary', 'cost', 'tuition', 'note'];
|
||||
const FREQ_OPTIONS = ['ONE_TIME', 'MONTHLY'];
|
||||
|
||||
export default function MilestoneAddModal({
|
||||
show,
|
||||
onClose,
|
||||
scenarioId, // active scenario UUID
|
||||
editMilestone = null // pass full row when editing
|
||||
}) {
|
||||
/* ────────────── state ────────────── */
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [impacts, setImpacts] = useState([]);
|
||||
|
||||
/* ────────────── init / reset ────────────── */
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
|
||||
if (editMilestone) {
|
||||
setTitle(editMilestone.title || '');
|
||||
setDescription(editMilestone.description || '');
|
||||
setImpacts(editMilestone.impacts || []);
|
||||
} else {
|
||||
setTitle(''); setDescription(''); setImpacts([]);
|
||||
}
|
||||
}, [show, editMilestone]);
|
||||
|
||||
/* ────────────── helpers ────────────── */
|
||||
const addImpactRow = () =>
|
||||
setImpacts(prev => [
|
||||
...prev,
|
||||
{
|
||||
impact_type : 'cost',
|
||||
frequency : 'ONE_TIME',
|
||||
direction : 'subtract',
|
||||
amount : 0,
|
||||
start_date : '', // ISO yyyy‑mm‑dd
|
||||
end_date : '' // blank ⇒ indefinite
|
||||
}
|
||||
]);
|
||||
|
||||
const updateImpact = (idx, field, value) =>
|
||||
setImpacts(prev => {
|
||||
const copy = [...prev];
|
||||
copy[idx] = { ...copy[idx], [field]: value };
|
||||
return copy;
|
||||
});
|
||||
|
||||
const removeImpact = idx =>
|
||||
setImpacts(prev => prev.filter((_, i) => i !== idx));
|
||||
|
||||
/* ────────────── save ────────────── */
|
||||
async function handleSave() {
|
||||
try {
|
||||
/* 1️⃣ create OR update the milestone row */
|
||||
let milestoneId = editMilestone?.id;
|
||||
if (milestoneId) {
|
||||
await authFetch(`api/premium/milestones/${milestoneId}`, {
|
||||
method : 'PUT',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body : JSON.stringify({ title, description })
|
||||
});
|
||||
} else {
|
||||
const res = await authFetch('api/premium/milestones', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body : JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
career_profile_id: scenarioId
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error('Milestone create failed');
|
||||
const json = await res.json();
|
||||
milestoneId = json.id ?? json[0]?.id; // array OR obj
|
||||
}
|
||||
|
||||
/* 2️⃣ upsert each impact (one call per row) */
|
||||
for (const imp of impacts) {
|
||||
const body = {
|
||||
milestone_id : milestoneId,
|
||||
impact_type : imp.impact_type,
|
||||
frequency : imp.frequency, // ONE_TIME / MONTHLY
|
||||
direction : imp.direction,
|
||||
amount : parseFloat(imp.amount) || 0,
|
||||
start_date : imp.start_date || null,
|
||||
end_date : imp.frequency === 'MONTHLY' && imp.end_date
|
||||
? imp.end_date
|
||||
: null
|
||||
};
|
||||
await authFetch('api/premium/milestone-impacts', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body : JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
onClose(true); // ← parent will refetch
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
alert('Sorry, something went wrong – please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────── UI ────────────── */
|
||||
if (!show) return null;
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal-container w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold mb-2">
|
||||
{editMilestone ? 'Edit Milestone' : 'Add Milestone'}
|
||||
</h2>
|
||||
|
||||
{/* basic fields */}
|
||||
<label className="block font-semibold mt-2">Title</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="border w-full px-2 py-1"
|
||||
/>
|
||||
|
||||
<label className="block font-semibold mt-4">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="border w-full px-2 py-1"
|
||||
/>
|
||||
|
||||
{/* impacts */}
|
||||
<h3 className="text-lg font-semibold mt-6">Financial Impacts</h3>
|
||||
|
||||
{impacts.map((imp, i) => (
|
||||
<div key={i} className="border rounded p-3 mt-4 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">Impact #{i + 1}</span>
|
||||
<button
|
||||
className="text-red-600 text-sm"
|
||||
onClick={() => removeImpact(i)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* type */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Type</label>
|
||||
<select
|
||||
value={imp.impact_type}
|
||||
onChange={e => updateImpact(i, 'impact_type', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
>
|
||||
{IMPACT_TYPES.map(t => (
|
||||
<option key={t} value={t}>
|
||||
{t === 'salary' ? 'Salary change'
|
||||
: t === 'cost' ? 'Cost / expense'
|
||||
: t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* frequency */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Frequency</label>
|
||||
<select
|
||||
value={imp.frequency}
|
||||
onChange={e => updateImpact(i, 'frequency', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
>
|
||||
<option value="ONE_TIME">One‑time</option>
|
||||
<option value="MONTHLY">Monthly (recurring)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* direction */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Direction</label>
|
||||
<select
|
||||
value={imp.direction}
|
||||
onChange={e => updateImpact(i, 'direction', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
>
|
||||
<option value="add">Add (income)</option>
|
||||
<option value="subtract">Subtract (expense)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* amount */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Amount ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={imp.amount}
|
||||
onChange={e => updateImpact(i, 'amount', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* dates */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Start date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={imp.start_date}
|
||||
onChange={e => updateImpact(i, 'start_date', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{imp.frequency === 'MONTHLY' && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">
|
||||
End date (optional)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={imp.end_date || ''}
|
||||
onChange={e => updateImpact(i, 'end_date', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={addImpactRow}
|
||||
className="bg-gray-200 px-4 py-1 rounded mt-4"
|
||||
>
|
||||
+ Add impact
|
||||
</button>
|
||||
|
||||
{/* actions */}
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button onClick={() => onClose(false)} className="px-4 py-2">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-blue-600 text-white px-5 py-2 rounded"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -511,10 +511,4 @@ export default function MilestoneEditModal({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------- tiny utility styles (or swap for Tailwind) ---- */
|
||||
const inputBase = 'border rounded-md w-full px-2 py-1 text-sm';
|
||||
const labelBase = 'block text-xs font-medium text-gray-600';
|
||||
export const input = inputBase; // export so you can reuse
|
||||
export const label = labelBase;
|
||||
}
|
@ -1,120 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from './ui/button.js';
|
||||
|
||||
export default function MilestoneModal({
|
||||
show,
|
||||
onClose,
|
||||
milestones,
|
||||
editingMilestone,
|
||||
showForm,
|
||||
handleNewMilestone,
|
||||
handleEditMilestone,
|
||||
handleDeleteMilestone,
|
||||
handleAddTask,
|
||||
showTaskForm,
|
||||
editingTask,
|
||||
handleEditTask,
|
||||
deleteTask,
|
||||
saveTask,
|
||||
saveMilestone,
|
||||
copyWizardMilestone,
|
||||
setCopyWizardMilestone
|
||||
}) {
|
||||
if (!show) return null; // if we don't want to render at all when hidden
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-start justify-center overflow-auto">
|
||||
<div className="bg-white p-4 m-4 max-w-4xl w-full relative">
|
||||
<h3 className="text-xl font-bold mb-4">Edit Milestones</h3>
|
||||
|
||||
<Button onClick={handleNewMilestone}>+ New Milestone</Button>
|
||||
|
||||
{/*
|
||||
1) Render existing milestones
|
||||
*/}
|
||||
{milestones.map((m) => {
|
||||
const tasks = m.tasks || [];
|
||||
return (
|
||||
<div key={m.id} className="border p-2 my-2">
|
||||
<h5>{m.title}</h5>
|
||||
{m.description && <p>{m.description}</p>}
|
||||
<p>
|
||||
<strong>Date:</strong> {m.date} —
|
||||
<strong>Progress:</strong> {m.progress}%
|
||||
</p>
|
||||
|
||||
{/* tasks list */}
|
||||
{tasks.length > 0 && (
|
||||
<ul>
|
||||
{tasks.map((t) => (
|
||||
<li key={t.id}>
|
||||
<strong>{t.title}</strong>
|
||||
{t.description ? ` - ${t.description}` : ''}
|
||||
{t.due_date ? ` (Due: ${t.due_date})` : ''}{' '}
|
||||
<Button onClick={() => handleEditTask(m.id, t)}>Edit</Button>
|
||||
<Button style={{ color: 'red' }} onClick={() => deleteTask(t.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Button onClick={() => handleAddTask(m.id)}>+ Task</Button>
|
||||
<Button onClick={() => handleEditMilestone(m)}>Edit</Button>
|
||||
<Button
|
||||
onClick={() => setCopyWizardMilestone(m)}
|
||||
style={{ marginLeft: '0.5rem' }}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
|
||||
onClick={() => handleDeleteMilestone(m)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
{/* The "Add/Edit Task" form if showTaskForm === m.id */}
|
||||
{showTaskForm === m.id && (
|
||||
<div style={{ border: '1px solid #aaa', padding: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<h5>{editingTask.id ? 'Edit Task' : 'New Task'}</h5>
|
||||
{/* same form logic... */}
|
||||
<Button onClick={() => saveTask(m.id)}>
|
||||
{editingTask.id ? 'Update' : 'Add'} Task
|
||||
</Button>
|
||||
<Button /* ... */>Cancel</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/*
|
||||
2) The big milestone form if showForm is true
|
||||
*/}
|
||||
{showForm && (
|
||||
<div className="form border p-2 my-2">
|
||||
<h4>{editingMilestone ? 'Edit Milestone' : 'New Milestone'}</h4>
|
||||
{/* ... your milestone form code (title, date, impacts, etc.) */}
|
||||
<Button onClick={saveMilestone}>
|
||||
{editingMilestone ? 'Update' : 'Add'} Milestone
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copy wizard if copyWizardMilestone */}
|
||||
{copyWizardMilestone && (
|
||||
<div>
|
||||
{/* your copy wizard UI */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-right">
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,756 +0,0 @@
|
||||
// src/components/MilestoneTimeline.js
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Button } from './ui/button.js';
|
||||
|
||||
/**
|
||||
* Renders a simple vertical list of milestones for the given careerProfileId.
|
||||
* Also includes Task CRUD (create/edit/delete) for each milestone,
|
||||
* plus a small "copy milestone" wizard, "financial impacts" form, etc.
|
||||
*/
|
||||
export default function MilestoneTimeline({
|
||||
careerProfileId,
|
||||
authFetch,
|
||||
activeView, // 'Career' or 'Financial'
|
||||
setActiveView, // optional, if you need to switch between views
|
||||
onMilestoneUpdated // callback after saving/deleting a milestone
|
||||
}) {
|
||||
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
|
||||
|
||||
// For CREATE/EDIT milestone
|
||||
const [newMilestone, setNewMilestone] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
progress: 0,
|
||||
newSalary: '',
|
||||
impacts: [],
|
||||
isUniversal: 0
|
||||
});
|
||||
const [impactsToDelete, setImpactsToDelete] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingMilestone, setEditingMilestone] = useState(null);
|
||||
|
||||
// For CREATE/EDIT tasks
|
||||
const [showTaskForm, setShowTaskForm] = useState(null); // which milestone ID is showing the form
|
||||
const [newTask, setNewTask] = useState({
|
||||
id: null,
|
||||
title: '',
|
||||
description: '',
|
||||
due_date: ''
|
||||
});
|
||||
|
||||
// For the "Copy to other scenarios" wizard
|
||||
const [scenarios, setScenarios] = useState([]);
|
||||
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 1) Financial Impacts sub-form helpers
|
||||
// ------------------------------------------------------------------
|
||||
function addNewImpact() {
|
||||
setNewMilestone((prev) => ({
|
||||
...prev,
|
||||
impacts: [
|
||||
...prev.impacts,
|
||||
{ impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' }
|
||||
]
|
||||
}));
|
||||
}
|
||||
|
||||
function removeImpact(idx) {
|
||||
setNewMilestone((prev) => {
|
||||
const newImpacts = [...prev.impacts];
|
||||
const removed = newImpacts[idx];
|
||||
if (removed && removed.id) {
|
||||
setImpactsToDelete((old) => [...old, removed.id]);
|
||||
}
|
||||
newImpacts.splice(idx, 1);
|
||||
return { ...prev, impacts: newImpacts };
|
||||
});
|
||||
}
|
||||
|
||||
function updateImpact(idx, field, value) {
|
||||
setNewMilestone((prev) => {
|
||||
const newImpacts = [...prev.impacts];
|
||||
newImpacts[idx] = { ...newImpacts[idx], [field]: value };
|
||||
return { ...prev, impacts: newImpacts };
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2) Fetch milestones => store in "milestones[Career]" / "milestones[Financial]"
|
||||
// ------------------------------------------------------------------
|
||||
const fetchMilestones = useCallback(async () => {
|
||||
if (!careerProfileId) return;
|
||||
try {
|
||||
const res = await authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`);
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch milestones. Status:', res.status);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.milestones) {
|
||||
console.warn('No milestones in response:', data);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch milestones:', err);
|
||||
}
|
||||
}, [careerProfileId, authFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMilestones();
|
||||
}, [fetchMilestones]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3) Load all scenarios for the copy wizard
|
||||
// ------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
async function loadScenarios() {
|
||||
try {
|
||||
const res = await authFetch('/api/premium/career-profile/all');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setScenarios(data.careerProfiles || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading scenarios for copy wizard:', err);
|
||||
}
|
||||
}
|
||||
loadScenarios();
|
||||
}, [authFetch]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4) Edit Milestone => fetch impacts
|
||||
// ------------------------------------------------------------------
|
||||
async function handleEditMilestone(m) {
|
||||
try {
|
||||
setImpactsToDelete([]);
|
||||
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch milestone impacts. Status:', res.status);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const fetchedImpacts = data.impacts || [];
|
||||
|
||||
setNewMilestone({
|
||||
title: m.title || '',
|
||||
description: m.description || '',
|
||||
date: m.date || '',
|
||||
progress: m.progress || 0,
|
||||
impacts: fetchedImpacts.map((imp) => ({
|
||||
id: imp.id,
|
||||
impact_type: imp.impact_type || 'ONE_TIME',
|
||||
direction: imp.direction || 'subtract',
|
||||
amount: imp.amount || 0,
|
||||
start_date: imp.start_date || '',
|
||||
end_date: imp.end_date || ''
|
||||
})),
|
||||
isUniversal: m.is_universal ? 1 : 0
|
||||
});
|
||||
|
||||
setEditingMilestone(m);
|
||||
setShowForm(true);
|
||||
} catch (err) {
|
||||
console.error('Error in handleEditMilestone:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5) Save (create/update) => handle impacts
|
||||
// ------------------------------------------------------------------
|
||||
async function saveMilestone() {
|
||||
if (!activeView) return;
|
||||
|
||||
const url = editingMilestone
|
||||
? `/api/premium/milestones/${editingMilestone.id}`
|
||||
: `/api/premium/milestone`;
|
||||
const method = editingMilestone ? 'PUT' : 'POST';
|
||||
|
||||
const payload = {
|
||||
milestone_type: activeView, // 'Career' or 'Financial'
|
||||
title: newMilestone.title,
|
||||
description: newMilestone.description,
|
||||
date: newMilestone.date,
|
||||
career_profile_id: careerProfileId,
|
||||
progress: newMilestone.progress,
|
||||
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
|
||||
is_universal: newMilestone.isUniversal || 0
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await authFetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
console.error('Failed to save milestone:', errData);
|
||||
alert(errData.error || 'Error saving milestone');
|
||||
return;
|
||||
}
|
||||
|
||||
const savedMilestone = await res.json();
|
||||
console.log('Milestone saved/updated:', savedMilestone);
|
||||
|
||||
// If it's a "Financial" milestone => handle impacts
|
||||
if (activeView === 'Financial') {
|
||||
// 1) Delete old impacts
|
||||
for (const impactId of impactsToDelete) {
|
||||
if (impactId) {
|
||||
const delRes = await authFetch(`/api/premium/milestone-impacts/${impactId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!delRes.ok) {
|
||||
console.error('Failed deleting old impact', impactId, await delRes.text());
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) Insert/Update new impacts
|
||||
for (let i = 0; i < newMilestone.impacts.length; i++) {
|
||||
const imp = newMilestone.impacts[i];
|
||||
if (imp.id) {
|
||||
// existing => PUT
|
||||
const putPayload = {
|
||||
milestone_id: savedMilestone.id,
|
||||
impact_type: imp.impact_type,
|
||||
direction: imp.direction,
|
||||
amount: parseFloat(imp.amount) || 0,
|
||||
start_date: imp.start_date || null,
|
||||
end_date: imp.end_date || null
|
||||
};
|
||||
const impRes = await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(putPayload)
|
||||
});
|
||||
if (!impRes.ok) {
|
||||
const errImp = await impRes.json();
|
||||
console.error('Failed updating impact:', errImp);
|
||||
}
|
||||
} else {
|
||||
// new => POST
|
||||
const postPayload = {
|
||||
milestone_id: savedMilestone.id,
|
||||
impact_type: imp.impact_type,
|
||||
direction: imp.direction,
|
||||
amount: parseFloat(imp.amount) || 0,
|
||||
start_date: imp.start_date || null,
|
||||
end_date: imp.end_date || null
|
||||
};
|
||||
const impRes = await authFetch('/api/premium/milestone-impacts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(postPayload)
|
||||
});
|
||||
if (!impRes.ok) {
|
||||
console.error('Failed creating new impact:', await impRes.text());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch milestones
|
||||
await fetchMilestones();
|
||||
|
||||
// reset form
|
||||
setShowForm(false);
|
||||
setEditingMilestone(null);
|
||||
setNewMilestone({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
progress: 0,
|
||||
newSalary: '',
|
||||
impacts: [],
|
||||
isUniversal: 0
|
||||
});
|
||||
setImpactsToDelete([]);
|
||||
|
||||
if (onMilestoneUpdated) {
|
||||
onMilestoneUpdated();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving milestone:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 6) TASK CRUD
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// A) “Add Task” button => sets newTask for a new item
|
||||
function handleAddTask(milestoneId) {
|
||||
setShowTaskForm(milestoneId);
|
||||
setNewTask({ id: null, title: '', description: '', due_date: '' });
|
||||
}
|
||||
|
||||
// B) “Edit Task” => fill newTask with the existing fields
|
||||
function handleEditTask(milestoneId, task) {
|
||||
setShowTaskForm(milestoneId);
|
||||
setNewTask({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
due_date: task.due_date || ''
|
||||
});
|
||||
}
|
||||
|
||||
// C) Save (create or update) task
|
||||
async function saveTask(milestoneId) {
|
||||
if (!newTask.title.trim()) {
|
||||
alert('Task needs a title');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
milestone_id: milestoneId,
|
||||
title: newTask.title,
|
||||
description: newTask.description,
|
||||
due_date: newTask.due_date
|
||||
};
|
||||
|
||||
let url = '/api/premium/tasks';
|
||||
let method = 'POST';
|
||||
|
||||
if (newTask.id) {
|
||||
// existing => PUT
|
||||
url = `/api/premium/tasks/${newTask.id}`;
|
||||
method = 'PUT';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await authFetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
console.error('Failed to save task:', errData);
|
||||
alert(errData.error || 'Error saving task');
|
||||
return;
|
||||
}
|
||||
|
||||
// re-fetch
|
||||
await fetchMilestones();
|
||||
|
||||
// reset
|
||||
setShowTaskForm(null);
|
||||
setNewTask({ id: null, title: '', description: '', due_date: '' });
|
||||
} catch (err) {
|
||||
console.error('Error saving task:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// D) Delete an existing task
|
||||
async function deleteTask(taskId) {
|
||||
if (!taskId) return;
|
||||
try {
|
||||
const res = await authFetch(`/api/premium/tasks/${taskId}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
console.error('Failed to delete task:', errData);
|
||||
alert(errData.error || 'Error deleting task');
|
||||
return;
|
||||
}
|
||||
await fetchMilestones();
|
||||
} catch (err) {
|
||||
console.error('Error deleting task:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 7) Copy Wizard for universal/cross-scenario
|
||||
// ------------------------------------------------------------------
|
||||
function CopyMilestoneWizard({ milestone, scenarios, onClose, authFetch }) {
|
||||
const [selectedScenarios, setSelectedScenarios] = useState([]);
|
||||
|
||||
if (!milestone) return null;
|
||||
|
||||
function toggleScenario(scenarioId) {
|
||||
setSelectedScenarios((prev) =>
|
||||
prev.includes(scenarioId) ? prev.filter((id) => id !== scenarioId) : [...prev, scenarioId]
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
const res = await authFetch('/api/premium/milestone/copy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
milestoneId: milestone.id,
|
||||
scenarioIds: selectedScenarios
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to copy milestone');
|
||||
|
||||
window.location.reload();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Error copying milestone:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal-container">
|
||||
<h3>Copy Milestone to Other Scenarios</h3>
|
||||
<p>
|
||||
Milestone: <strong>{milestone.title}</strong>
|
||||
</p>
|
||||
{scenarios.map((s) => (
|
||||
<div key={s.id}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedScenarios.includes(s.id)}
|
||||
onChange={() => toggleScenario(s.id)}
|
||||
/>
|
||||
{s.career_name || s.scenario_title || '(untitled)'}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<Button onClick={onClose} style={{ marginRight: '0.5rem' }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCopy}>Copy</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 8) Delete Milestone
|
||||
// ------------------------------------------------------------------
|
||||
async function handleDeleteMilestone(m) {
|
||||
if (m.is_universal === 1) {
|
||||
const userChoice = window.confirm(
|
||||
'This milestone is universal. OK => remove from ALL scenarios, Cancel => only remove from this scenario.'
|
||||
);
|
||||
if (userChoice) {
|
||||
// delete from all
|
||||
try {
|
||||
const delAll = await authFetch(`/api/premium/milestones/${m.id}/all`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!delAll.ok) {
|
||||
console.error('Failed removing universal from all. Status:', delAll.status);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting universal milestone from all:', err);
|
||||
}
|
||||
} else {
|
||||
// remove from single scenario
|
||||
await deleteSingleMilestone(m);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// normal => single scenario
|
||||
await deleteSingleMilestone(m);
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function deleteSingleMilestone(m) {
|
||||
try {
|
||||
const delRes = await authFetch(`/api/premium/milestones/${m.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!delRes.ok) {
|
||||
console.error('Failed to delete milestone:', delRes.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error removing milestone from scenario:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 9) Render
|
||||
// ------------------------------------------------------------------
|
||||
// Combine "Career" + "Financial" if you want them in a single list:
|
||||
|
||||
return (
|
||||
<div className="milestone-timeline" style={{ padding: '1rem' }}>
|
||||
{/* “+ New Milestone” toggles the same form as before */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (showForm) {
|
||||
// Cancel form
|
||||
setShowForm(false);
|
||||
setEditingMilestone(null);
|
||||
setNewMilestone({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
progress: 0,
|
||||
newSalary: '',
|
||||
impacts: [],
|
||||
isUniversal: 0
|
||||
});
|
||||
setImpactsToDelete([]);
|
||||
} else {
|
||||
setShowForm(true);
|
||||
}
|
||||
}}
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
>
|
||||
{showForm ? 'Cancel' : '+ New Milestone'}
|
||||
</Button>
|
||||
|
||||
{/* If showForm => the create/edit milestone sub-form */}
|
||||
{showForm && (
|
||||
<div className="border p-2 my-2">
|
||||
<h4>{editingMilestone ? 'Edit Milestone' : 'New Milestone'}</h4>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={newMilestone.title}
|
||||
onChange={(e) => setNewMilestone({ ...newMilestone, title: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Description"
|
||||
value={newMilestone.description}
|
||||
onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={newMilestone.date}
|
||||
onChange={(e) => setNewMilestone({ ...newMilestone, date: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Progress (%)"
|
||||
value={newMilestone.progress === 0 ? '' : newMilestone.progress}
|
||||
onChange={(e) =>
|
||||
setNewMilestone((prev) => ({
|
||||
...prev,
|
||||
progress: parseInt(e.target.value || '0', 10)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* If “Financial” => show impacts */}
|
||||
{activeView === 'Financial' && (
|
||||
<div style={{ border: '1px solid #ccc', padding: '1rem', marginTop: '1rem' }}>
|
||||
<h5>Financial Impacts</h5>
|
||||
{newMilestone.impacts.map((imp, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{ border: '1px solid #bbb', margin: '0.5rem', padding: '0.5rem' }}
|
||||
>
|
||||
{imp.id && <p style={{ fontSize: '0.8rem' }}>ID: {imp.id}</p>}
|
||||
<div>
|
||||
<label>Type: </label>
|
||||
<select
|
||||
value={imp.impact_type}
|
||||
onChange={(e) => updateImpact(idx, 'impact_type', e.target.value)}
|
||||
>
|
||||
<option value="ONE_TIME">One-Time</option>
|
||||
<option value="MONTHLY">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Direction: </label>
|
||||
<select
|
||||
value={imp.direction}
|
||||
onChange={(e) => updateImpact(idx, 'direction', e.target.value)}
|
||||
>
|
||||
<option value="add">Add (Income)</option>
|
||||
<option value="subtract">Subtract (Expense)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Amount: </label>
|
||||
<input
|
||||
type="number"
|
||||
value={imp.amount}
|
||||
onChange={(e) => updateImpact(idx, 'amount', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Start Date: </label>
|
||||
<input
|
||||
type="date"
|
||||
value={imp.start_date || ''}
|
||||
onChange={(e) => updateImpact(idx, 'start_date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{imp.impact_type === 'MONTHLY' && (
|
||||
<div>
|
||||
<label>End Date: </label>
|
||||
<input
|
||||
type="date"
|
||||
value={imp.end_date || ''}
|
||||
onChange={(e) => updateImpact(idx, 'end_date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
style={{ marginLeft: '0.5rem', color: 'red' }}
|
||||
onClick={() => removeImpact(idx)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button onClick={addNewImpact}>+ Add Impact</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!newMilestone.isUniversal}
|
||||
onChange={(e) =>
|
||||
setNewMilestone((prev) => ({
|
||||
...prev,
|
||||
isUniversal: e.target.checked ? 1 : 0
|
||||
}))
|
||||
}
|
||||
/>{' '}
|
||||
Apply this milestone to all scenarios
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<Button onClick={saveMilestone}>
|
||||
{editingMilestone ? 'Update' : 'Add'} Milestone
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render the (Career + Financial) milestones in a simple vertical list */}
|
||||
{Object.keys(milestones).map((typeKey) =>
|
||||
milestones[typeKey].map((m) => {
|
||||
const tasks = m.tasks || [];
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
style={{ border: '1px solid #ccc', marginTop: '1rem', padding: '0.5rem' }}
|
||||
>
|
||||
<h5>{m.title}</h5>
|
||||
{m.description && <p>{m.description}</p>}
|
||||
<p>
|
||||
<strong>Date:</strong> {m.date} — <strong>Progress:</strong> {m.progress}%
|
||||
</p>
|
||||
|
||||
{/* tasks list */}
|
||||
{tasks.length > 0 && (
|
||||
<ul>
|
||||
{tasks.map((t) => (
|
||||
<li key={t.id}>
|
||||
<strong>{t.title}</strong>
|
||||
{t.description ? ` - ${t.description}` : ''}
|
||||
{t.due_date ? ` (Due: ${t.due_date})` : ''}{' '}
|
||||
{/* EDIT & DELETE Task buttons */}
|
||||
<Button
|
||||
onClick={() => handleEditTask(m.id, t)}
|
||||
style={{ marginLeft: '0.5rem' }}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => deleteTask(t.id)}
|
||||
style={{ marginLeft: '0.5rem', color: 'red' }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Add or edit a task */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
// if we are already showing the form for this milestone => Cancel
|
||||
if (showTaskForm === m.id) {
|
||||
setShowTaskForm(null);
|
||||
setNewTask({ id: null, title: '', description: '', due_date: '' });
|
||||
} else {
|
||||
handleAddTask(m.id);
|
||||
}
|
||||
}}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
>
|
||||
{showTaskForm === m.id ? 'Cancel Task' : '+ Task'}
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => handleEditMilestone(m)}>Edit</Button>
|
||||
<Button
|
||||
style={{ marginLeft: '0.5rem' }}
|
||||
onClick={() => setCopyWizardMilestone(m)}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
|
||||
onClick={() => handleDeleteMilestone(m)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
{/* If this is the milestone whose tasks we're editing => show the form */}
|
||||
{showTaskForm === m.id && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
border: '1px solid #aaa',
|
||||
padding: '0.5rem'
|
||||
}}
|
||||
>
|
||||
<h5>{newTask.id ? 'Edit Task' : 'New Task'}</h5>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Task Title"
|
||||
value={newTask.title}
|
||||
onChange={(e) =>
|
||||
setNewTask((prev) => ({ ...prev, title: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Task Description"
|
||||
value={newTask.description}
|
||||
onChange={(e) =>
|
||||
setNewTask((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={newTask.due_date || ''}
|
||||
onChange={(e) =>
|
||||
setNewTask((prev) => ({ ...prev, due_date: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Button onClick={() => saveTask(m.id)}>
|
||||
{newTask.id ? 'Update' : 'Add'} Task
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Copy wizard if open */}
|
||||
{copyWizardMilestone && (
|
||||
<CopyMilestoneWizard
|
||||
milestone={copyWizardMilestone}
|
||||
scenarios={scenarios}
|
||||
onClose={() => setCopyWizardMilestone(null)}
|
||||
authFetch={authFetch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,502 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ClipLoader } from "react-spinners";
|
||||
import LoanRepayment from "./LoanRepayment.js";
|
||||
|
||||
function PopoutPanel({
|
||||
isVisible,
|
||||
data = {},
|
||||
userState = "N/A",
|
||||
loading = false,
|
||||
error = null,
|
||||
closePanel,
|
||||
updateChatbotContext,
|
||||
}) {
|
||||
// Original local states
|
||||
const [isCalculated, setIsCalculated] = useState(false);
|
||||
const [results, setResults] = useState([]);
|
||||
const [loadingCalculation, setLoadingCalculation] = useState(false);
|
||||
const [persistedROI, setPersistedROI] = useState({});
|
||||
const [programLengths, setProgramLengths] = useState([]);
|
||||
const [sortBy, setSortBy] = useState("tuition");
|
||||
const [maxTuition, setMaxTuition] = useState(50000);
|
||||
const [maxDistance, setMaxDistance] = useState(100);
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Destructure your data
|
||||
const {
|
||||
jobDescription = null,
|
||||
tasks = null,
|
||||
title = "Career Details",
|
||||
economicProjections = {},
|
||||
salaryData = [],
|
||||
schools = [],
|
||||
} = data || {};
|
||||
|
||||
// Clear results if sorting or filters change
|
||||
useEffect(() => {
|
||||
setResults([]);
|
||||
setIsCalculated(false);
|
||||
}, [sortBy, maxTuition, maxDistance]);
|
||||
|
||||
// Derive program lengths from school CREDDESC
|
||||
useEffect(() => {
|
||||
setProgramLengths(
|
||||
schools.map((school) => getProgramLength(school["CREDDESC"]))
|
||||
);
|
||||
}, [schools]);
|
||||
|
||||
// Update chatbot context if data is present
|
||||
useEffect(() => {
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
updateChatbotContext({
|
||||
careerDetails: data,
|
||||
schools,
|
||||
salaryData,
|
||||
economicProjections,
|
||||
results,
|
||||
persistedROI,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
data,
|
||||
schools,
|
||||
salaryData,
|
||||
economicProjections,
|
||||
results,
|
||||
persistedROI,
|
||||
updateChatbotContext,
|
||||
]);
|
||||
|
||||
// If panel isn't visible, don't render
|
||||
if (!isVisible) return null;
|
||||
|
||||
// If the panel or the loan calc is loading, show a spinner
|
||||
if (loading || loadingCalculation) {
|
||||
return (
|
||||
<div className="popout-panel fixed top-0 right-0 z-50 h-full w-full max-w-xl overflow-y-auto bg-white shadow-xl">
|
||||
<div className="p-4">
|
||||
<button
|
||||
className="mb-4 rounded bg-red-100 px-2 py-1 text-sm text-red-600 hover:bg-red-200"
|
||||
onClick={closePanel}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
<h2 className="mb-2 text-xl font-semibold">Loading Career Details...</h2>
|
||||
<ClipLoader size={35} color="#4A90E2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Original helper
|
||||
function getProgramLength(degreeType) {
|
||||
if (degreeType?.includes("Associate")) return 2;
|
||||
if (degreeType?.includes("Bachelor")) return 4;
|
||||
if (degreeType?.includes("Master")) return 6;
|
||||
if (degreeType?.includes("Doctoral") || degreeType?.includes("First Professional"))
|
||||
return 8;
|
||||
if (degreeType?.includes("Certificate")) return 1;
|
||||
return 4;
|
||||
}
|
||||
|
||||
// Original close logic
|
||||
function handleClosePanel() {
|
||||
setResults([]);
|
||||
setIsCalculated(false);
|
||||
closePanel();
|
||||
}
|
||||
|
||||
async function handlePlanMyPath() {
|
||||
if (!token) {
|
||||
alert("You need to be logged in to create a career path.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1) Fetch existing career profiles (a.k.a. "careerProfiles")
|
||||
const allPathsResponse = await fetch(
|
||||
`${process.env.REACT_APP_API_URL}/premium/career-profile/all`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!allPathsResponse.ok) {
|
||||
throw new Error(`HTTP error ${allPathsResponse.status}`);
|
||||
}
|
||||
|
||||
// The server returns { careerProfiles: [...] }
|
||||
const { careerProfiles } = await allPathsResponse.json();
|
||||
|
||||
// 2) Check if there's already a career path with the same name
|
||||
const match = careerProfiles.find((cp) => cp.career_name === data.title);
|
||||
|
||||
if (match) {
|
||||
// If a path already exists for this career, confirm with the user
|
||||
const decision = window.confirm(
|
||||
`A career path (scenario) for "${data.title}" already exists.\n\n` +
|
||||
`Click OK to RELOAD the existing path.\nClick Cancel to CREATE a new one.`
|
||||
);
|
||||
if (decision) {
|
||||
// Reload existing path → go to Paywall
|
||||
navigate("/paywall", {
|
||||
state: {
|
||||
selectedCareer: {
|
||||
career_profile_id: match.id, // 'id' is the primary key from the DB
|
||||
career_name: data.title,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Otherwise, create a new career profile using POST /premium/career-profile
|
||||
const newResponse = await fetch(
|
||||
`${process.env.REACT_APP_API_URL}/premium/career-profile`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// The server expects at least career_name
|
||||
career_name: data.title,
|
||||
// Optionally pass scenario_title, start_date, etc.
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!newResponse.ok) {
|
||||
throw new Error("Failed to create new career path.");
|
||||
}
|
||||
|
||||
// The server returns something like { message: 'Career profile upserted.', career_profile_id: 'xxx-xxx' }
|
||||
const result = await newResponse.json();
|
||||
const newlyCreatedId = result?.career_profile_id;
|
||||
|
||||
// 4) Navigate to /paywall, passing the newly created career_profile_id
|
||||
navigate("/paywall", {
|
||||
state: {
|
||||
selectedCareer: {
|
||||
career_profile_id: newlyCreatedId,
|
||||
career_name: data.title,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in Plan My Path:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter & sort schools
|
||||
const filteredAndSortedSchools = [...schools]
|
||||
.filter((school) => {
|
||||
const inStateCost = parseFloat(school["In_state cost"]) || 0;
|
||||
const distance = parseFloat((school["distance"] || "0").replace(" mi", ""));
|
||||
return inStateCost <= maxTuition && distance <= maxDistance;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === "tuition") return a["In_state cost"] - b["In_state cost"];
|
||||
if (sortBy === "distance") {
|
||||
const distA = parseFloat((a["distance"] || "0").replace(" mi", ""));
|
||||
const distB = parseFloat((b["distance"] || "0").replace(" mi", ""));
|
||||
return distA - distB;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="popout-panel fixed top-0 right-0 z-50 flex h-full w-full max-w-xl flex-col bg-white shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<button
|
||||
className="rounded bg-red-100 px-2 py-1 text-sm text-red-600 hover:bg-red-200"
|
||||
onClick={handleClosePanel}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
onClick={handlePlanMyPath}
|
||||
>
|
||||
Plan My Path
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded bg-red-50 p-2 text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job Description */}
|
||||
<div className="rounded bg-gray-50 p-4">
|
||||
<h3 className="mb-2 text-base font-medium">Job Description</h3>
|
||||
<p className="text-sm text-gray-700">
|
||||
{jobDescription || "No description available"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="rounded bg-gray-50 p-4">
|
||||
<h3 className="mb-2 text-base font-medium">Expected Tasks</h3>
|
||||
{tasks && tasks.length > 0 ? (
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-gray-700">
|
||||
{tasks.map((task, idx) => (
|
||||
<li key={idx}>{task}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No tasks available.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Economic Projections */}
|
||||
<div className="rounded bg-gray-50 p-4">
|
||||
{(() => {
|
||||
if (!economicProjections.state && !economicProjections.national) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
No economic projections available.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, proceed
|
||||
const st = economicProjections.state || {};
|
||||
const nat = economicProjections.national || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="mb-2 text-base font-medium">
|
||||
Economic Projections for {userState} —{' '}
|
||||
{st.occupationName ? st.occupationName : 'N/A'}
|
||||
</h3>
|
||||
|
||||
<table className="w-full text-sm text-gray-700">
|
||||
<thead>
|
||||
<tr className="bg-gray-200">
|
||||
<th className="p-2 text-left">Metric</th>
|
||||
<th className="p-2 text-left">{st.area ?? 'State'}</th>
|
||||
<th className="p-2 text-left">{nat.area ?? 'U.S.'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="p-2 font-medium">2022 Employment</td>
|
||||
<td className="p-2">{st.base ?? 'N/A'}</td>
|
||||
<td className="p-2">{nat.base ?? 'N/A'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-2 font-medium">2032 Employment</td>
|
||||
<td className="p-2">{st.projection ?? 'N/A'}</td>
|
||||
<td className="p-2">{nat.projection ?? 'N/A'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-2 font-medium">Annual Openings</td>
|
||||
<td className="p-2">{st.annualOpenings ?? 'N/A'}</td>
|
||||
<td className="p-2">{nat.annualOpenings ?? 'N/A'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Salary Data */}
|
||||
<div className="rounded bg-gray-50 p-4">
|
||||
<h3 className="mb-2 text-base font-medium">Salary Data</h3>
|
||||
{salaryData.length > 0 ? (
|
||||
<table className="w-full text-sm text-gray-700">
|
||||
<thead>
|
||||
<tr className="bg-gray-200 text-left">
|
||||
<th className="p-2">Percentile</th>
|
||||
<th className="p-2">Regional Salary</th>
|
||||
<th className="p-2">US Salary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{salaryData.map((point, idx) => (
|
||||
<tr key={idx} className="border-b">
|
||||
<td className="p-2">{point.percentile}</td>
|
||||
<td className="p-2">
|
||||
{point.regionalSalary > 0
|
||||
? `$${parseInt(point.regionalSalary, 10).toLocaleString()}`
|
||||
: "N/A"}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{point.nationalSalary > 0
|
||||
? `$${parseInt(point.nationalSalary, 10).toLocaleString()}`
|
||||
: "N/A"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
Salary data is not available.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schools Offering Programs */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-base font-medium">Schools Offering Programs</h3>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="mb-4 flex items-center space-x-4">
|
||||
<label className="text-sm text-gray-600">
|
||||
Sort:
|
||||
<select
|
||||
className="ml-2 rounded border px-2 py-1 text-sm"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<option value="tuition">Tuition</option>
|
||||
<option value="distance">Distance</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-gray-600">
|
||||
Tuition (max):
|
||||
<input
|
||||
type="number"
|
||||
className="ml-2 w-20 rounded border px-2 py-1 text-sm"
|
||||
value={maxTuition}
|
||||
onChange={(e) => setMaxTuition(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-gray-600">
|
||||
Distance (max):
|
||||
<input
|
||||
type="number"
|
||||
className="ml-2 w-20 rounded border px-2 py-1 text-sm"
|
||||
value={maxDistance}
|
||||
onChange={(e) => setMaxDistance(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{filteredAndSortedSchools.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{filteredAndSortedSchools.map((school, idx) => (
|
||||
<div key={idx} className="rounded border p-3 text-sm">
|
||||
<strong>{school["INSTNM"] || "Unnamed School"}</strong>
|
||||
<p>Degree Type: {school["CREDDESC"] || "N/A"}</p>
|
||||
<p>In-State Tuition: ${school["In_state cost"] || "N/A"}</p>
|
||||
<p>Out-of-State Tuition: ${school["Out_state cost"] || "N/A"}</p>
|
||||
<p>Distance: {school["distance"] || "N/A"}</p>
|
||||
<p>
|
||||
Website:{" "}
|
||||
<a
|
||||
href={school["Website"]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{school["Website"]}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
No schools matching your filters.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loan Repayment Analysis */}
|
||||
<section className="rounded bg-gray-50 p-4">
|
||||
<h3 className="mb-2 text-base font-medium">Loan Repayment Analysis</h3>
|
||||
<LoanRepayment
|
||||
schools={filteredAndSortedSchools.map((school, i) => ({
|
||||
schoolName: school["INSTNM"],
|
||||
inState: parseFloat(school["In_state cost"]) || 0,
|
||||
outOfState: parseFloat(school["Out_state cost"]) || 0,
|
||||
inStateGraduate:
|
||||
parseFloat(school["In State Graduate"]) ||
|
||||
parseFloat(school["In_state cost"]) ||
|
||||
0,
|
||||
outStateGraduate:
|
||||
parseFloat(school["Out State Graduate"]) ||
|
||||
parseFloat(school["Out_state cost"]) ||
|
||||
0,
|
||||
degreeType: school["CREDDESC"],
|
||||
programLength: programLengths[i],
|
||||
}))}
|
||||
salaryData={salaryData}
|
||||
setResults={setResults}
|
||||
setLoading={setLoadingCalculation}
|
||||
setPersistedROI={setPersistedROI}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Results Display */}
|
||||
{results.length > 0 && (
|
||||
<div className="results-container rounded bg-gray-50 p-4">
|
||||
<h3 className="mb-2 text-base font-medium">
|
||||
Comparisons by School over the life of the loan
|
||||
</h3>
|
||||
|
||||
{/*
|
||||
=========================================
|
||||
Here's the key part: a grid for results.
|
||||
=========================================
|
||||
*/}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{results.map((result, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded border p-3 text-sm text-gray-700"
|
||||
>
|
||||
<h4 className="mb-1 text-sm font-medium">
|
||||
{result.schoolName} - {result.degreeType || "N/A"}
|
||||
</h4>
|
||||
<p>Total Tuition: ${result.totalTuition}</p>
|
||||
<p>Monthly Payment: ${result.monthlyPayment}</p>
|
||||
<p>
|
||||
Total Monthly Payment (with extra): $
|
||||
{result.totalMonthlyPayment}
|
||||
</p>
|
||||
<p>Total Loan Cost: ${result.totalLoanCost}</p>
|
||||
<p
|
||||
className={
|
||||
parseFloat(result.netGain) < 0
|
||||
? "text-red-600"
|
||||
: "text-green-600"
|
||||
}
|
||||
>
|
||||
Net Gain: ${result.netGain}
|
||||
</p>
|
||||
<p>Monthly Salary (Gross): ${result.monthlySalary}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PopoutPanel;
|
@ -1,26 +0,0 @@
|
||||
import React from "react";
|
||||
import InfoTooltip from "./ui/infoTooltip.js";
|
||||
|
||||
/**
|
||||
* Compact badge that shows a 0-100 “readiness” score.
|
||||
*
|
||||
* Props:
|
||||
* • score (Number 0-100) – required
|
||||
*/
|
||||
export default function ReadinessPill({ score = 0 }) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(score)));
|
||||
|
||||
const bg =
|
||||
pct >= 80 ? "bg-green-600"
|
||||
: pct >= 60 ? "bg-yellow-500"
|
||||
: "bg-red-600";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`ml-2 inline-flex items-center gap-1 rounded-full px-2 py-px text-xs font-semibold text-white ${bg}`}
|
||||
>
|
||||
{pct}
|
||||
<InfoTooltip message="How long your portfolio can fund your desired spending, mapped onto a 30-year scale (100 ≈ ≥30 yrs)" />
|
||||
</span>
|
||||
);
|
||||
}
|
@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '../utils/cn.js';
|
||||
import { Button } from './ui/button.js';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
1. Static “OPS cheat-sheet” card
|
||||
@ -101,10 +102,26 @@ export default function RetirementChatBar({
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [forceCtx, setForceCtx] = useState(false);
|
||||
const [scenarios, setScenarios] = useState([]);
|
||||
const [currentScenario, setCurrentScenario] = useState(scenario);
|
||||
const bottomRef = useRef(null);
|
||||
|
||||
/* wipe chat on scenario change */
|
||||
useEffect(() => setChatHistory([]), [scenario?.id]);
|
||||
useEffect(() => setChatHistory([]), [currentScenario?.id]);
|
||||
|
||||
/* keep prop‑driven scenario in sync (e.g. user clicked a card) */
|
||||
useEffect(() => { if (scenario?.id) setCurrentScenario(scenario); }, [scenario]);
|
||||
|
||||
/* fetch the user’s scenarios once */
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await authFetch('/api/premium/career-profile/all');
|
||||
const json = await res.json();
|
||||
setScenarios(json.careerProfiles || []);
|
||||
} catch (e) { console.error('Scenario load failed', e); }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
/* autoscroll */
|
||||
useEffect(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }),
|
||||
@ -115,7 +132,7 @@ export default function RetirementChatBar({
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function sendPrompt() {
|
||||
const prompt = input.trim();
|
||||
if (!prompt || !scenario?.id) return;
|
||||
if (!prompt || !currentScenario?.id) return;
|
||||
|
||||
/* ① optimistic UI – show the user bubble immediately */
|
||||
const userMsg = { role: 'user', content: prompt };
|
||||
@ -128,7 +145,7 @@ async function sendPrompt() {
|
||||
const messagesToSend = buildMessages({
|
||||
chatHistory : [...chatHistory, userMsg],
|
||||
userProfile,
|
||||
scenarioRow : scenario,
|
||||
scenarioRow : currentScenario,
|
||||
milestoneGrid,
|
||||
largeSummaryCard: window.CACHED_SUMMARY || '',
|
||||
forceContext : forceCtx
|
||||
@ -140,7 +157,7 @@ async function sendPrompt() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({
|
||||
prompt,
|
||||
scenario_id : scenario.id, // ← keep it minimal
|
||||
scenario_id : currentScenario?.id, // ← keep it minimal
|
||||
chatHistory : messagesToSend // ← backend needs this to find userMsg
|
||||
})
|
||||
});
|
||||
@ -151,7 +168,7 @@ async function sendPrompt() {
|
||||
|
||||
/* ④ if we got a real patch, build + forward the diff array */
|
||||
if (data.scenarioPatch && onScenarioPatch) {
|
||||
onScenarioPatch(scenario.id, data.scenarioPatch); // ✅ id + patch
|
||||
onScenarioPatch(currentScenario.id, data.scenarioPatch); // ✅ id + patch
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -177,9 +194,10 @@ function handleKeyUp(e) {
|
||||
|
||||
|
||||
/* ----------------------- render ----------------------- */
|
||||
const scenarioLabel = scenario
|
||||
? (scenario.scenario_title || scenario.career_name || 'Untitled Scenario')
|
||||
: 'Select a scenario';
|
||||
const scenarioLabel =
|
||||
currentScenario
|
||||
? currentScenario.scenario_title || currentScenario.career_name || 'Untitled Scenario'
|
||||
: 'Pick a scenario';
|
||||
|
||||
return (
|
||||
<aside
|
||||
@ -188,12 +206,33 @@ function handleKeyUp(e) {
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* ---------- Header with a mini‑selector ---------- */}
|
||||
<header className="p-3 border-b flex items-center gap-2 text-sm font-semibold">
|
||||
{scenarioLabel}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={currentScenario?.id || ''}
|
||||
onChange={e => {
|
||||
const sc = scenarios.find(s => s.id === e.target.value);
|
||||
if (sc) {
|
||||
setCurrentScenario(sc);
|
||||
setChatHistory([]); // reset thread for new scenario
|
||||
}
|
||||
}}
|
||||
className="pr-6 border rounded px-2 py-[2px] text-sm bg-white"
|
||||
>
|
||||
<option value="" disabled>-- select scenario --</option>
|
||||
{scenarios.map(s => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.scenario_title || s.career_name || 'Untitled'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-1 top-1.5 h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!scenario}
|
||||
disabled={!currentScenario}
|
||||
onClick={() => setForceCtx(true)}
|
||||
title="Force refresh context for next turn"
|
||||
>
|
||||
@ -229,13 +268,13 @@ function handleKeyUp(e) {
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyUp={handleKeyUp}
|
||||
disabled={!scenario}
|
||||
placeholder={scenario
|
||||
? 'Ask about this scenario…'
|
||||
: 'Click a scenario card first'}
|
||||
disabled={!currentScenario}
|
||||
placeholder={currentScenario
|
||||
? 'Ask about this scenario…'
|
||||
: 'Pick a scenario first'}
|
||||
className="flex-1 resize-none border rounded px-2 py-1 text-sm disabled:bg-gray-100"
|
||||
/>
|
||||
<Button type="submit" disabled={!scenario || loading || !input.trim()}>
|
||||
<Button type="submit" disabled={!currentScenario || loading || !input.trim()}>
|
||||
{loading ? '…' : 'Send'}
|
||||
</Button>
|
||||
</form>
|
||||
|
@ -144,10 +144,11 @@ export default function RetirementPlanner () {
|
||||
baselineYears={baselineYears}
|
||||
onClone={handleCloneScenario}
|
||||
onRemove={handleRemoveScenario}
|
||||
onSelect={() => {
|
||||
onAskAI={() => { /* ← only fires from the new button */
|
||||
setSelectedScenario(sc);
|
||||
openRetire({
|
||||
scenario: sc,
|
||||
scenarios : scenarios,
|
||||
financialProfile,
|
||||
onScenarioPatch: applyPatch
|
||||
});
|
||||
|
@ -5,7 +5,6 @@ import annotationPlugin from 'chartjs-plugin-annotation';
|
||||
import moment from 'moment';
|
||||
|
||||
import { Button } from './ui/button.js';
|
||||
import InfoTooltip from "./ui/infoTooltip.js";
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||||
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
|
||||
|
@ -1,164 +0,0 @@
|
||||
// ScenarioEditWizard.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import CareerOnboarding from './PremiumOnboarding/CareerOnboarding.js';
|
||||
import FinancialOnboarding from './PremiumOnboarding/FinancialOnboarding.js';
|
||||
import CollegeOnboarding from './PremiumOnboarding/CollegeOnboarding.js';
|
||||
import ReviewPage from './PremiumOnboarding/ReviewPage.js';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||||
|
||||
export default function ScenarioEditWizard({
|
||||
show,
|
||||
onClose,
|
||||
scenarioId // or scenario object
|
||||
}) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [careerData, setCareerData] = useState({});
|
||||
const [financialData, setFinancialData] = useState({});
|
||||
const [collegeData, setCollegeData] = useState({});
|
||||
|
||||
// You might also store scenario + college IDs
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show || !scenarioId) return;
|
||||
// 1) fetch scenario => careerData
|
||||
// 2) fetch financial => financialData
|
||||
// 3) fetch college => collegeData
|
||||
// Pre-fill the same states your Onboarding steps expect.
|
||||
async function fetchExisting() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [scenRes, finRes, colRes] = await Promise.all([
|
||||
authFetch(`/api/premium/career-profile/${scenarioId}`),
|
||||
authFetch(`/api/premium/financial-profile`),
|
||||
authFetch(`/api/premium/college-profile?careerProfileId=${scenarioId}`)
|
||||
]);
|
||||
if (!scenRes.ok || !finRes.ok || !colRes.ok) {
|
||||
throw new Error('Failed fetching existing scenario or financial or college.');
|
||||
}
|
||||
const [scenData, finData, colDataRaw] = await Promise.all([
|
||||
scenRes.json(),
|
||||
finRes.json(),
|
||||
colRes.json()
|
||||
]);
|
||||
let colData = Array.isArray(colDataRaw) ? colDataRaw[0] : colDataRaw;
|
||||
|
||||
// Now put them into the same shape as your Onboarding step states:
|
||||
setCareerData({
|
||||
career_name: scenData.career_name,
|
||||
college_enrollment_status: scenData.college_enrollment_status,
|
||||
currently_working: scenData.currently_working,
|
||||
status: scenData.status,
|
||||
start_date: scenData.start_date,
|
||||
planned_monthly_expenses: scenData.planned_monthly_expenses,
|
||||
planned_monthly_debt_payments: scenData.planned_monthly_debt_payments,
|
||||
planned_monthly_retirement_contribution: scenData.planned_monthly_retirement_contribution,
|
||||
planned_monthly_emergency_contribution: scenData.planned_monthly_emergency_contribution,
|
||||
planned_surplus_emergency_pct: scenData.planned_surplus_emergency_pct,
|
||||
planned_surplus_retirement_pct: scenData.planned_surplus_retirement_pct,
|
||||
planned_additional_income: scenData.planned_additional_income,
|
||||
id: scenData.id,
|
||||
// etc...
|
||||
});
|
||||
|
||||
setFinancialData({
|
||||
// your financial table fields
|
||||
current_salary: finData.current_salary,
|
||||
additional_income: finData.additional_income,
|
||||
monthly_expenses: finData.monthly_expenses,
|
||||
monthly_debt_payments: finData.monthly_debt_payments,
|
||||
retirement_savings: finData.retirement_savings,
|
||||
emergency_fund: finData.emergency_fund,
|
||||
retirement_contribution: finData.retirement_contribution,
|
||||
emergency_contribution: finData.emergency_contribution,
|
||||
extra_cash_emergency_pct: finData.extra_cash_emergency_pct,
|
||||
extra_cash_retirement_pct: finData.extra_cash_retirement_pct
|
||||
});
|
||||
|
||||
setCollegeData({
|
||||
// from colData
|
||||
selected_school: colData.selected_school,
|
||||
selected_program: colData.selected_program,
|
||||
program_type: colData.program_type,
|
||||
academic_calendar: colData.academic_calendar,
|
||||
is_in_state: colData.is_in_state,
|
||||
is_in_district: colData.is_in_district,
|
||||
is_online: colData.is_online,
|
||||
college_enrollment_status: colData.college_enrollment_status,
|
||||
annual_financial_aid: colData.annual_financial_aid,
|
||||
existing_college_debt: colData.existing_college_debt,
|
||||
tuition: colData.tuition,
|
||||
tuition_paid: colData.tuition_paid,
|
||||
loan_deferral_until_graduation: colData.loan_deferral_until_graduation,
|
||||
loan_term: colData.loan_term,
|
||||
interest_rate: colData.interest_rate,
|
||||
extra_payment: colData.extra_payment,
|
||||
credit_hours_per_year: colData.credit_hours_per_year,
|
||||
hours_completed: colData.hours_completed,
|
||||
program_length: colData.program_length,
|
||||
credit_hours_required: colData.credit_hours_required,
|
||||
expected_graduation: colData.expected_graduation,
|
||||
expected_salary: colData.expected_salary
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchExisting();
|
||||
}, [show, scenarioId]);
|
||||
|
||||
const nextStep = () => setStep(s => s + 1);
|
||||
const prevStep = () => setStep(s => s - 1);
|
||||
|
||||
if (!show) return null;
|
||||
if (loading) return <div className="modal">Loading existing scenario...</div>;
|
||||
|
||||
const steps = [
|
||||
<CareerOnboarding
|
||||
data={careerData}
|
||||
setData={setCareerData}
|
||||
nextStep={nextStep}
|
||||
/>,
|
||||
<FinancialOnboarding
|
||||
data={{
|
||||
...financialData,
|
||||
currently_working: careerData.currently_working // pass along
|
||||
}}
|
||||
setData={setFinancialData}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
<CollegeOnboarding
|
||||
data={{
|
||||
...collegeData,
|
||||
college_enrollment_status: careerData.college_enrollment_status
|
||||
}}
|
||||
setData={setCollegeData}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
/>,
|
||||
<ReviewPage
|
||||
careerData={careerData}
|
||||
financialData={financialData}
|
||||
collegeData={collegeData}
|
||||
onSubmit={async () => {
|
||||
// same final logic from Onboarding: upsert scenario, financial, college
|
||||
// Then close
|
||||
onClose();
|
||||
}}
|
||||
onBack={prevStep}
|
||||
/>
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal-content" style={{ padding:'1rem' }}>
|
||||
{steps[step]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ClipLoader } from 'react-spinners';
|
||||
|
||||
function SchoolsList({ schools, distances, tuitionData, calculatingDistances }) {
|
||||
return (
|
||||
<div className="schools-list">
|
||||
<h2>Schools Offering Programs</h2>
|
||||
{schools.length > 0 ? (
|
||||
<ul>
|
||||
{schools.map((school, index) => {
|
||||
const matchingTuitionData = tuitionData.find(
|
||||
(tuition) =>
|
||||
tuition['school.name']?.toLowerCase().trim() ===
|
||||
school['Institution Name']?.toLowerCase().trim()
|
||||
);
|
||||
const isCalculating = calculatingDistances[school['UNITID']];
|
||||
return (
|
||||
<li key={index}>
|
||||
<strong>{school['Institution Name']}</strong>
|
||||
<br />
|
||||
Degree type: {school['CREDDESC'] || 'N/A'}
|
||||
<br />
|
||||
In-State Tuition: $
|
||||
{matchingTuitionData?.['latest.cost.tuition.in_state'] || 'N/A'}
|
||||
<br />
|
||||
Out-of-State Tuition: $
|
||||
{matchingTuitionData?.['latest.cost.tuition.out_of_state'] || 'N/A'}
|
||||
<br />
|
||||
Distance:{' '}
|
||||
{isCalculating ? (
|
||||
<span className="distance-spinner">
|
||||
<ClipLoader size={15} color="#4A90E2" />
|
||||
</span>
|
||||
) : (
|
||||
distances[school['UNITID']] || 'N/A'
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p>No schools found for the selected program.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SchoolsList;
|
@ -42,7 +42,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('api/signin', {
|
||||
const resp = await fetch('/api/signin', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({username, password}),
|
||||
|
@ -1,20 +0,0 @@
|
||||
// TangentialCareers.js
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const TangentialCareers = ({ careers }) => {
|
||||
return (
|
||||
<section className="tangential-careers">
|
||||
<h2>Tangential Career Options</h2>
|
||||
{careers.map((career, index) => (
|
||||
<div key={index} className="career-option">
|
||||
<h3>{career.title}</h3>
|
||||
<p>{career.description}</p>
|
||||
<p><strong>Match Score:</strong> {career.matchScore}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default TangentialCareers;
|
@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import "../styles/legacy/ZipCodeInput.legacy.css";
|
||||
export function ZipCodeInput({ zipCode, setZipCode, onZipSubmit }) {
|
||||
return (
|
||||
<div className="zip-code-input">
|
||||
<h3>Enter Your ZIP Code</h3>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onZipSubmit(zipCode); // Call the onZipSubmit callback
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={zipCode}
|
||||
onChange={(e) => setZipCode(e.target.value)} // Update the zipCode state
|
||||
placeholder="Enter ZIP code"
|
||||
/>
|
||||
<button type="submit">Check Distance</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
// FadingPromptModal.jsx
|
||||
import React from 'react';
|
||||
import PromptModal from './PromptModal.js';
|
||||
|
||||
export default function FadingPromptModal({ open, ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
fixed inset-0 z-50
|
||||
flex items-center justify-center
|
||||
transition-opacity duration-300
|
||||
${open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
|
||||
`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gray-800 bg-opacity-50 z-10" />
|
||||
<div className="relative z-20">
|
||||
<PromptModal open={open} {...props} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
|
||||
export function Badge({ className = '', children }) {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold
|
||||
bg-gray-200 text-gray-700 ${className}`
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
@ -7,14 +7,4 @@ export const Card = ({ className, ...props }) => (
|
||||
|
||||
export const CardContent = ({ className, ...props }) => (
|
||||
<div className={cn("p-4", className)} {...props} />
|
||||
);
|
||||
|
||||
export const CardHeader = ({ className, ...props }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-2 border-b flex items-center justify-between",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
@ -1,20 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
const Progress = ({ value, max = 100, className, ...props }) => {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
max={max}
|
||||
className="relative w-full h-4 bg-gray-200 rounded-md overflow-hidden"
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="absolute top-0 left-0 h-full bg-blue-600 transition-all"
|
||||
style={{ width: `${(value / max) * 100}%` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export { Progress };
|
@ -1,9 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
const TabsList = TabsPrimitive.List;
|
||||
const TabsTrigger = TabsPrimitive.Trigger;
|
||||
const TabsContent = TabsPrimitive.Content;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
@ -1,22 +1,5 @@
|
||||
import axios from 'axios';
|
||||
|
||||
//fetch areas by state
|
||||
export const fetchAreasByState = async (state) => {
|
||||
try {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_URL}/Institution_data.json`);
|
||||
|
||||
if (response.status === 200) {
|
||||
return response.data.areas; // Assume the API returns a list of areas
|
||||
} else {
|
||||
console.error('Failed to fetch areas:', response.status);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching areas:', error.message);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export async function clientGeocodeZip(zip) {
|
||||
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
|
||||
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(zip)}&key=${apiKey}`;
|
||||
@ -58,7 +41,7 @@ export async function fetchSchools(cipCodes) {
|
||||
const cipParam = codesArray.join(',');
|
||||
|
||||
// 3) Call your endpoint with `?cipCodes=1101,1409&state=NY`
|
||||
const response = await axios.get('api/schools', {
|
||||
const response = await axios.get('/api/schools', {
|
||||
params: {
|
||||
cipCodes: cipParam,
|
||||
},
|
||||
|
@ -1,22 +0,0 @@
|
||||
// utils/fetchCareerEnrichment.js
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
export async function fetchCareerEnrichment(socCode, area) {
|
||||
// strippedSoc = remove decimals from e.g. "15-1132.00" => "15-1132"
|
||||
const strippedSoc = socCode.includes('.') ? socCode.split('.')[0] : socCode;
|
||||
|
||||
const [cipData, jobDetailsData, economicData, salaryData] = await Promise.all([
|
||||
axios.get(`api/cip/${socCode}`).catch(() => null),
|
||||
axios.get(`api/onet/career-description/${socCode}`).catch(() => null),
|
||||
axios.get(`api/projections/${strippedSoc}`, { params: { area } }).catch(() => null),
|
||||
axios.get('api/salary', { params: { socCode: strippedSoc, area } }).catch(() => null),
|
||||
]);
|
||||
|
||||
return {
|
||||
cip: cipData?.data || null,
|
||||
jobDetails: jobDetailsData?.data || null,
|
||||
economic: economicData?.data || null,
|
||||
salary: salaryData?.data || null,
|
||||
};
|
||||
}
|
@ -34,7 +34,7 @@
|
||||
// Salary
|
||||
let salaryResponse;
|
||||
try {
|
||||
salaryResponse = await axios.get('api/salary', {
|
||||
salaryResponse = await axios.get('/api/salary', {
|
||||
params: { socCode: socCode.split('.')[0], area: areaTitle },
|
||||
});
|
||||
} catch (error) {
|
||||
@ -56,7 +56,7 @@
|
||||
// Tuition
|
||||
let tuitionResponse;
|
||||
try {
|
||||
tuitionResponse = await axios.get('api/tuition', {
|
||||
tuitionResponse = await axios.get('/api/tuition', {
|
||||
params: { cipCode: cleanedCipCode, state: userState },
|
||||
});
|
||||
} catch (error) {
|
||||
|
8
src/utils/isAllOther.js
Normal file
8
src/utils/isAllOther.js
Normal file
@ -0,0 +1,8 @@
|
||||
// src/utils/isAllOther.js
|
||||
export default function isAllOther({ title = '', socCode = '' } = {}) {
|
||||
const allOtherRegex = /\bAll\s*Other\b/i; // title contains “All Other”
|
||||
const residualSOC = /^\d{2}-\d{4}$/.test(socCode) &&
|
||||
socCode.endsWith('99'); // e.g. 13‑2099
|
||||
|
||||
return allOtherRegex.test(title) || residualSOC;
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
/* ───────────────────────────────────
|
||||
ONE place that owns the math
|
||||
─────────────────────────────────── */
|
||||
export function calcAnnualTuition({
|
||||
ipedsRows, schoolRow,
|
||||
programType, creditHoursPerYear,
|
||||
inState, inDistrict,
|
||||
}) {
|
||||
if (!ipedsRows?.length || !schoolRow || !programType) return 0;
|
||||
|
||||
const row = ipedsRows.find(r => r.UNITID === schoolRow.UNITID);
|
||||
if (!row) return 0;
|
||||
|
||||
const grad = [
|
||||
"Master's Degree", "Doctoral Degree",
|
||||
"Graduate/Professional Certificate", "First Professional Degree",
|
||||
].includes(programType);
|
||||
|
||||
const pick = (u1,u2,u3) => inDistrict ? row[u1] : inState ? row[u2] : row[u3];
|
||||
|
||||
const partTime = Number( grad ? pick('HRCHG5','HRCHG6','HRCHG7')
|
||||
: pick('HRCHG1','HRCHG2','HRCHG3') );
|
||||
const fullTime = Number( grad ? pick('TUITION5','TUITION6','TUITION7')
|
||||
: pick('TUITION1','TUITION2','TUITION3') );
|
||||
|
||||
const ch = Number(creditHoursPerYear) || 0;
|
||||
return (ch && ch < 24 && partTime) ? partTime * ch : fullTime;
|
||||
}
|
||||
|
||||
export function calcProgramLength({ programType, hrsPerYear, hrsCompleted=0, hrsRequired=0 }) {
|
||||
if (!programType || !hrsPerYear) return '0.00';
|
||||
let need = hrsRequired;
|
||||
|
||||
switch (programType) {
|
||||
case "Associate's Degree": need = 60; break;
|
||||
case "Bachelor's Degree" : need = 120; break;
|
||||
case "Master's Degree" : need = 180; break;
|
||||
case "Doctoral Degree" : need = 240; break;
|
||||
case "First Professional Degree": need = 180; break;
|
||||
}
|
||||
return ((need - hrsCompleted) / hrsPerYear).toFixed(2);
|
||||
}
|
26
unused_files.txt
Normal file
26
unused_files.txt
Normal file
@ -0,0 +1,26 @@
|
||||
10
|
||||
11
|
||||
19
|
||||
4
|
||||
10
|
||||
11
|
||||
14
|
||||
183
|
||||
2
|
||||
20
|
||||
3
|
||||
30
|
||||
37
|
||||
42
|
||||
44
|
||||
48
|
||||
5
|
||||
51
|
||||
519
|
||||
520
|
||||
54
|
||||
6
|
||||
7
|
||||
72
|
||||
8
|
||||
87
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user