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
|
SERVER1_PORT=5000
|
||||||
SERVER2_PORT=5001
|
SERVER2_PORT=5001
|
||||||
SERVER3_PORT=5002
|
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',
|
app.post('/api/premium/stripe/webhook',
|
||||||
express.raw({ type: 'application/json' }),
|
express.raw({ type: 'application/json' }),
|
||||||
|
@ -10,78 +10,3 @@ const BASE = (
|
|||||||
process.env.REACT_APP_API_URL ||
|
process.env.REACT_APP_API_URL ||
|
||||||
''
|
''
|
||||||
).replace(/\/+$/, ''); // trim *all* trailing “/”
|
).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,
|
authenticateUser,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.writeHead(200, {
|
const headers = {
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
// streaming MIME type – browsers still treat it as text, but
|
||||||
"Cache-Control": "no-cache",
|
// it signals “keep pushing” semantics more clearly
|
||||||
Connection : "keep-alive",
|
"Content-Type": "text/event-stream; charset=utf-8",
|
||||||
"X-Accel-Buffering": "no"
|
"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?.();
|
res.flushHeaders?.();
|
||||||
|
|
||||||
const sendChunk = (txt = "") => { res.write(txt); res.flush?.(); };
|
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": {
|
"devDependencies": {
|
||||||
"@babel/parser": "^7.28.0",
|
"@babel/parser": "^7.28.0",
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
|
"@eslint/js": "^9.32.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"autoprefixer": "^10.4.21",
|
"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",
|
"glob": "^11.0.3",
|
||||||
|
"globals": "^16.3.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"tailwindcss": "^3.4.17"
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript-eslint": "^8.38.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@ -150,9 +159,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/eslint-parser": {
|
"node_modules/@babel/eslint-parser": {
|
||||||
"version": "7.27.5",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.27.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.0.tgz",
|
||||||
"integrity": "sha512-HLkYQfRICudzcOtjGwkPvGc5nF1b4ljLZh1IRDj50lRZ718NAKVgQpIAUX8bfg6u/yuSKY3L7E0YzIV+OxrB8Q==",
|
"integrity": "sha512-N4ntErOlKvcbTt01rr5wj3y55xnIdx1ymrfIr8C2WnM1Y9glFgWaGDEULJIazOX3XM9NRzhfJ6zZnQ1sBNWU+w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
|
"@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
|
||||||
@ -1069,6 +1078,15 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@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": {
|
"node_modules/@babel/plugin-transform-computed-properties": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz",
|
"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": ">=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": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.28.0",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
|
||||||
@ -2452,12 +2479,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "8.57.1",
|
"version": "9.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz",
|
||||||
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
|
"integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"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": {
|
"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": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "5.62.0",
|
"version": "5.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
|
"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"
|
"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": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "5.62.0",
|
"version": "5.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz",
|
||||||
@ -8495,6 +8579,48 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/eslint-config-react-app": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/eslint/node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
@ -10191,12 +10326,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "11.12.0",
|
"version": "16.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz",
|
||||||
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
|
"integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/globalthis": {
|
"node_modules/globalthis": {
|
||||||
@ -19470,6 +19609,19 @@
|
|||||||
"integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==",
|
"integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ts-interface-checker": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||||
@ -19724,6 +19876,275 @@
|
|||||||
"node": ">=4.2.0"
|
"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": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
"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"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
"start": "react-scripts start --host 0.0.0.0",
|
"start": "react-scripts start --host 0.0.0.0",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
@ -87,11 +89,20 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/parser": "^7.28.0",
|
"@babel/parser": "^7.28.0",
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
|
"@eslint/js": "^9.32.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"autoprefixer": "^10.4.21",
|
"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",
|
"glob": "^11.0.3",
|
||||||
|
"globals": "^16.3.0",
|
||||||
"postcss": "^8.5.3",
|
"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;
|
|
27
src/App.js
27
src/App.js
@ -22,7 +22,6 @@ import EducationalProgramsPage from './components/EducationalProgramsPage.js';
|
|||||||
import EnhancingLanding from './components/EnhancingLanding.js';
|
import EnhancingLanding from './components/EnhancingLanding.js';
|
||||||
import RetirementLanding from './components/RetirementLanding.js';
|
import RetirementLanding from './components/RetirementLanding.js';
|
||||||
import InterestInventory from './components/InterestInventory.js';
|
import InterestInventory from './components/InterestInventory.js';
|
||||||
import Dashboard from './components/Dashboard.js';
|
|
||||||
import UserProfile from './components/UserProfile.js';
|
import UserProfile from './components/UserProfile.js';
|
||||||
import FinancialProfileForm from './components/FinancialProfileForm.js';
|
import FinancialProfileForm from './components/FinancialProfileForm.js';
|
||||||
import CareerProfileList from './components/CareerProfileList.js';
|
import CareerProfileList from './components/CareerProfileList.js';
|
||||||
@ -78,6 +77,10 @@ const uiToolHandlers = useMemo(() => {
|
|||||||
return {}; // every other page exposes no UI tools
|
return {}; // every other page exposes no UI tools
|
||||||
}, [pageContext]);
|
}, [pageContext]);
|
||||||
|
|
||||||
|
// Retirement bot is only relevant on these pages
|
||||||
|
const canShowRetireBot =
|
||||||
|
pageContext === 'RetirementPlanner' ||
|
||||||
|
pageContext === 'RetirementLanding';
|
||||||
|
|
||||||
// Auth states
|
// Auth states
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
@ -228,18 +231,16 @@ const uiToolHandlers = useMemo(() => {
|
|||||||
<ChatCtx.Provider value={{ setChatSnapshot,
|
<ChatCtx.Provider value={{ setChatSnapshot,
|
||||||
openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); },
|
openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); },
|
||||||
openRetire : (props) => {
|
openRetire : (props) => {
|
||||||
setRetireProps(props);
|
if (!canShowRetireBot) {
|
||||||
setDrawerPane('retire');
|
console.warn('Retirement bot disabled on this page');
|
||||||
setDrawerOpen(true);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pageContext === 'RetirementPlanner' || pageContext === 'RetirementLanding') {
|
setRetireProps(props);
|
||||||
setRetireProps(props);
|
setDrawerPane('retire');
|
||||||
setDrawerPane('retire');
|
setDrawerOpen(true);
|
||||||
setDrawerOpen(true);
|
}
|
||||||
} else {
|
}}>
|
||||||
console.warn('Retirement bot disabled on this page');
|
|
||||||
}
|
|
||||||
}}}>
|
|
||||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
|
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex items-center justify-between border-b bg-white px-6 py-4 shadow-sm relative">
|
<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="/signin-landing" element={<SignInLanding user={user} />}/>
|
||||||
<Route path="/interest-inventory" element={<InterestInventory />} />
|
<Route path="/interest-inventory" element={<InterestInventory />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
|
||||||
<Route path="/profile" element={<UserProfile />} />
|
<Route path="/profile" element={<UserProfile />} />
|
||||||
<Route path="/planning" element={<PlanningLanding />} />
|
<Route path="/planning" element={<PlanningLanding />} />
|
||||||
<Route path="/career-explorer" element={<CareerExplorer />} />
|
<Route path="/career-explorer" element={<CareerExplorer />} />
|
||||||
@ -608,6 +608,7 @@ const uiToolHandlers = useMemo(() => {
|
|||||||
pageContext={pageContext}
|
pageContext={pageContext}
|
||||||
snapshot={chatSnapshot}
|
snapshot={chatSnapshot}
|
||||||
uiToolHandlers={uiToolHandlers}
|
uiToolHandlers={uiToolHandlers}
|
||||||
|
canShowRetireBot={canShowRetireBot}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Session Handler (Optional) */}
|
{/* 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>
|
</p>
|
||||||
|
|
||||||
<Button asChild className="w-full">
|
<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>
|
||||||
|
|
||||||
<Button variant="secondary" asChild className="w-full">
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -59,7 +59,7 @@ export default function BillingResult() {
|
|||||||
<p className="text-gray-600">No changes were made to your account.</p>
|
<p className="text-gray-600">No changes were made to your account.</p>
|
||||||
|
|
||||||
<Button asChild className="w-full">
|
<Button asChild className="w-full">
|
||||||
<Link to="/paywall">Back to pricing</Link>
|
<Link to="/paywall" className="block w-full">Back to pricing</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useMemo, useCallback, useContext } from 'react';
|
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 ChatCtx from '../contexts/ChatCtx.js';
|
||||||
|
|
||||||
import CareerSuggestions from './CareerSuggestions.js';
|
import CareerSuggestions from './CareerSuggestions.js';
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
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="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">
|
<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 */}
|
{/* Title row */}
|
||||||
<div className="flex justify-between items-center mb-4 pb-2 border-b">
|
<div className="flex justify-between items-center mb-4 pb-2 border-b">
|
||||||
<div>
|
<div>
|
||||||
|
@ -56,8 +56,8 @@ export default function CareerProfileForm() {
|
|||||||
career_name : d.career_name ?? '',
|
career_name : d.career_name ?? '',
|
||||||
soc_code : d.soc_code ?? '',
|
soc_code : d.soc_code ?? '',
|
||||||
status : d.status ?? 'current',
|
status : d.status ?? 'current',
|
||||||
start_date : d.start_date ?? '',
|
start_date : (d.start_date || '').slice(0, 10), // ← trim
|
||||||
retirement_start_date : d.retirement_start_date ?? '',
|
retirement_start_date : (d.retirement_start_date || '').slice(0, 10),
|
||||||
college_enrollment_status : d.college_enrollment_status ?? '',
|
college_enrollment_status : d.college_enrollment_status ?? '',
|
||||||
career_goals : d.career_goals ?? '',
|
career_goals : d.career_goals ?? '',
|
||||||
desired_retirement_income_monthly :
|
desired_retirement_income_monthly :
|
||||||
@ -68,16 +68,18 @@ export default function CareerProfileForm() {
|
|||||||
|
|
||||||
/* ---------- 4. save ---------- */
|
/* ---------- 4. save ---------- */
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.soc_code) {
|
if (!careerLocked && !form.soc_code) {
|
||||||
alert('Please pick a valid career from the list first.');
|
alert('Please pick a valid career from the list first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/premium/career-profile', {
|
const res = await authFetch('/api/premium/career-profile', {
|
||||||
method : 'POST',
|
method : 'POST',
|
||||||
headers : { 'Content-Type': 'application/json' },
|
headers : { 'Content-Type': 'application/json' },
|
||||||
body : JSON.stringify({
|
body : JSON.stringify({
|
||||||
...form,
|
...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
|
id: id === 'new' ? undefined : id // upsert
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
@ -693,7 +693,7 @@ useEffect(() => {
|
|||||||
|
|
||||||
(async function init () {
|
(async function init () {
|
||||||
/* 1 ▸ get every row the user owns */
|
/* 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;
|
if (!r?.ok || cancelled) return;
|
||||||
const { careerProfiles=[] } = await r.json();
|
const { careerProfiles=[] } = await r.json();
|
||||||
setExistingCareerProfiles(careerProfiles);
|
setExistingCareerProfiles(careerProfiles);
|
||||||
@ -777,7 +777,7 @@ useEffect(() => {
|
|||||||
|
|
||||||
const refetchScenario = useCallback(async () => {
|
const refetchScenario = useCallback(async () => {
|
||||||
if (!careerProfileId) return;
|
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());
|
if (r.ok) setScenarioRow(await r.json());
|
||||||
}, [careerProfileId]);
|
}, [careerProfileId]);
|
||||||
|
|
||||||
@ -835,7 +835,7 @@ try {
|
|||||||
if (err.response && err.response.status === 404) {
|
if (err.response && err.response.status === 404) {
|
||||||
try {
|
try {
|
||||||
// Call GPT via server3
|
// 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,
|
socCode,
|
||||||
careerName,
|
careerName,
|
||||||
jobDescription: description,
|
jobDescription: description,
|
||||||
@ -869,7 +869,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3) Store in server2
|
// 3) Store in server2
|
||||||
await axios.post('api/ai-risk', storePayload);
|
await axios.post('/api/ai-risk', storePayload);
|
||||||
|
|
||||||
// Construct final object for usage here
|
// Construct final object for usage here
|
||||||
aiRisk = {
|
aiRisk = {
|
||||||
|
@ -1,23 +1,63 @@
|
|||||||
|
// src/components/CareerSuggestions.js
|
||||||
import React from 'react';
|
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 = [],
|
careerSuggestions = [],
|
||||||
onCareerClick,
|
onCareerClick,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="career-suggestions-grid">
|
<div
|
||||||
{careerSuggestions.map((career) => (
|
/* similar to: grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap:10px; padding:10px */
|
||||||
<button
|
className="
|
||||||
key={career.code}
|
grid w-full
|
||||||
className={`career-button ${career.limitedData ? 'limited-data' : ''}`}
|
gap-[10px] p-[10px]
|
||||||
onClick={() => onCareerClick(career)}
|
[grid-template-columns:repeat(auto-fit,minmax(12rem,1fr))]
|
||||||
>
|
"
|
||||||
{career.title}
|
>
|
||||||
{career.limitedData && <span className="warning-icon"> ⚠️</span>}
|
{careerSuggestions.map((career) => {
|
||||||
</button>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CareerSuggestions;
|
|
||||||
|
@ -22,6 +22,7 @@ export default function ChatDrawer({
|
|||||||
pane: controlledPane = 'support',
|
pane: controlledPane = 'support',
|
||||||
setPane: setControlledPane,
|
setPane: setControlledPane,
|
||||||
retireProps = null, // { scenario, financialProfile, … }
|
retireProps = null, // { scenario, financialProfile, … }
|
||||||
|
canShowRetireBot
|
||||||
}) {
|
}) {
|
||||||
/* ─────────────────────────── internal / fallback state ───────── */
|
/* ─────────────────────────── internal / fallback state ───────── */
|
||||||
const [openLocal, setOpenLocal] = useState(false);
|
const [openLocal, setOpenLocal] = useState(false);
|
||||||
@ -59,6 +60,13 @@ export default function ChatDrawer({
|
|||||||
return [...prev, { role: 'assistant', content: chunk }];
|
return [...prev, { role: 'assistant', content: chunk }];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canShowRetireBot && pane === 'retire') {
|
||||||
|
setPane('support');
|
||||||
|
}
|
||||||
|
}, [canShowRetireBot, pane, setPane]);
|
||||||
|
|
||||||
/* ───────────────────────── send support-bot prompt ───────────── */
|
/* ───────────────────────── send support-bot prompt ───────────── */
|
||||||
async function sendPrompt() {
|
async function sendPrompt() {
|
||||||
const text = prompt.trim();
|
const text = prompt.trim();
|
||||||
@ -125,49 +133,63 @@ export default function ChatDrawer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ──────────────────────────── UI ─────────────────────────────── */
|
/* ---------- render ---------- */
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
{/* floating FAB */}
|
{/* floating action button */}
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
aria-label="Open chat"
|
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"
|
className="fixed bottom-6 right-6 z-40 flex h-14 w-14
|
||||||
onClick={() => onOpenChange(!open)}
|
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>
|
</button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
|
|
||||||
{/* side-drawer */}
|
{/* side drawer */}
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
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 */}
|
{/* header (tabs only if retirement bot is allowed) */}
|
||||||
<div className="flex border-b text-sm font-semibold">
|
<div className="flex border-b">
|
||||||
{[
|
<button
|
||||||
{ id: 'support', label: 'Aptiva Support' },
|
className={
|
||||||
{ id: 'retire', label: 'Retirement Helper' },
|
pane === 'support'
|
||||||
].map((tab) => (
|
? '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
|
<button
|
||||||
key={tab.id}
|
className={
|
||||||
onClick={() => setPane(tab.id)}
|
pane === 'retire'
|
||||||
className={cn(
|
? 'flex-1 px-4 py-3 text-sm font-semibold border-b-2 border-blue-600'
|
||||||
'flex-1 py-2',
|
: 'flex-1 px-4 py-3 text-sm text-gray-500 hover:bg-gray-50'
|
||||||
pane === tab.id
|
}
|
||||||
? 'border-b-2 border-blue-600'
|
onClick={() => setPane('retire')}
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{tab.label}
|
Retirement Helper
|
||||||
</button>
|
</button>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* body – conditional panes */}
|
{/* body */}
|
||||||
{pane === 'support' ? (
|
{pane === 'support' ? (
|
||||||
/* ─────────── Support bot ─────────── */
|
/* ── Support bot pane ── */
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
@ -175,13 +197,9 @@ export default function ChatDrawer({
|
|||||||
>
|
>
|
||||||
{messages.map((m, i) => (
|
{messages.map((m, i) => (
|
||||||
<div
|
<div
|
||||||
/* eslint-disable react/no-array-index-key */
|
|
||||||
key={i}
|
key={i}
|
||||||
/* eslint-enable react/no-array-index-key */
|
|
||||||
className={
|
className={
|
||||||
m.role === 'user'
|
m.role === 'user' ? 'text-right' : 'text-left text-gray-800'
|
||||||
? 'text-right'
|
|
||||||
: 'text-left text-gray-800'
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{m.content}
|
{m.content}
|
||||||
@ -200,8 +218,13 @@ export default function ChatDrawer({
|
|||||||
<Input
|
<Input
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Ask me anything…"
|
placeholder="Ask me anything…"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendPrompt();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={!prompt.trim()}>
|
<Button type="submit" disabled={!prompt.trim()}>
|
||||||
@ -211,9 +234,10 @@ export default function ChatDrawer({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : retireProps ? (
|
) : retireProps ? (
|
||||||
/* ───────── Retirement helper ─────── */
|
/* ── Retirement helper pane ── */
|
||||||
<RetirementChatBar {...retireProps} />
|
<RetirementChatBar {...retireProps} />
|
||||||
) : (
|
) : (
|
||||||
|
/* failsafe (retire tab opened before selecting a scenario) */
|
||||||
<div className="m-auto px-6 text-center text-sm text-gray-400">
|
<div className="m-auto px-6 text-center text-sm text-gray-400">
|
||||||
Select a scenario in
|
Select a scenario in
|
||||||
<strong>Retirement Planner</strong>
|
<strong>Retirement Planner</strong>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import authFetch from '../utils/authFetch.js';
|
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 [ipeds, setIpeds] = useState([]);
|
||||||
const [schoolValid, setSchoolValid] = useState(true);
|
const [schoolValid, setSchoolValid] = useState(true);
|
||||||
const [programValid, setProgramValid] = useState(true);
|
const [programValid, setProgramValid] = useState(true);
|
||||||
|
const [autoGradDate, setAutoGradDate] = useState('');
|
||||||
|
const [graduationTouched, setGraduationTouched] = useState(false);
|
||||||
|
const [programLengthTouched, setProgramLengthTouched] = useState(false);
|
||||||
|
|
||||||
const schoolData = cipRows;
|
const schoolData = cipRows;
|
||||||
|
|
||||||
@ -88,6 +92,7 @@ const handleFieldChange = (e) => {
|
|||||||
].includes(name)
|
].includes(name)
|
||||||
) {
|
) {
|
||||||
draft[name] = value === '' ? '' : parseFloat(value);
|
draft[name] = value === '' ? '' : parseFloat(value);
|
||||||
|
if (name === 'program_length') setProgramLengthTouched(true);
|
||||||
} else {
|
} else {
|
||||||
draft[name] = value;
|
draft[name] = value;
|
||||||
}
|
}
|
||||||
@ -178,7 +183,6 @@ useEffect(()=>{
|
|||||||
setTypes([...new Set(t)]);
|
setTypes([...new Set(t)]);
|
||||||
},[form.selected_school, form.selected_program, cipRows]);
|
},[form.selected_school, form.selected_program, cipRows]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ipeds.length) return;
|
if (!ipeds.length) return;
|
||||||
if (!form.selected_school ||
|
if (!form.selected_school ||
|
||||||
@ -235,6 +239,73 @@ const chosenTuition = manualTuition.trim() === ''
|
|||||||
? autoTuition
|
? autoTuition
|
||||||
: parseFloat(manualTuition);
|
: 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 (
|
return (
|
||||||
<div className="max-w-md mx-auto p-6 space-y-4">
|
<div className="max-w-md mx-auto p-6 space-y-4">
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">
|
||||||
@ -378,6 +449,36 @@ return (
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* 7 │ Tuition & aid */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="block font-medium">Yearly Tuition</label>
|
<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 {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setError(null);
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ answers }),
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -512,9 +512,3 @@ export default function MilestoneEditModal({
|
|||||||
</div>
|
</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 { cn } from '../utils/cn.js';
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
import authFetch from '../utils/authFetch.js';
|
import authFetch from '../utils/authFetch.js';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────────────────────
|
/* ──────────────────────────────────────────────────────────────
|
||||||
1. Static “OPS cheat-sheet” card
|
1. Static “OPS cheat-sheet” card
|
||||||
@ -101,10 +102,26 @@ export default function RetirementChatBar({
|
|||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [forceCtx, setForceCtx] = useState(false);
|
const [forceCtx, setForceCtx] = useState(false);
|
||||||
|
const [scenarios, setScenarios] = useState([]);
|
||||||
|
const [currentScenario, setCurrentScenario] = useState(scenario);
|
||||||
const bottomRef = useRef(null);
|
const bottomRef = useRef(null);
|
||||||
|
|
||||||
/* wipe chat on scenario change */
|
/* 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 */
|
/* autoscroll */
|
||||||
useEffect(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }),
|
useEffect(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }),
|
||||||
@ -115,7 +132,7 @@ export default function RetirementChatBar({
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
async function sendPrompt() {
|
async function sendPrompt() {
|
||||||
const prompt = input.trim();
|
const prompt = input.trim();
|
||||||
if (!prompt || !scenario?.id) return;
|
if (!prompt || !currentScenario?.id) return;
|
||||||
|
|
||||||
/* ① optimistic UI – show the user bubble immediately */
|
/* ① optimistic UI – show the user bubble immediately */
|
||||||
const userMsg = { role: 'user', content: prompt };
|
const userMsg = { role: 'user', content: prompt };
|
||||||
@ -128,7 +145,7 @@ async function sendPrompt() {
|
|||||||
const messagesToSend = buildMessages({
|
const messagesToSend = buildMessages({
|
||||||
chatHistory : [...chatHistory, userMsg],
|
chatHistory : [...chatHistory, userMsg],
|
||||||
userProfile,
|
userProfile,
|
||||||
scenarioRow : scenario,
|
scenarioRow : currentScenario,
|
||||||
milestoneGrid,
|
milestoneGrid,
|
||||||
largeSummaryCard: window.CACHED_SUMMARY || '',
|
largeSummaryCard: window.CACHED_SUMMARY || '',
|
||||||
forceContext : forceCtx
|
forceContext : forceCtx
|
||||||
@ -140,7 +157,7 @@ async function sendPrompt() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body : JSON.stringify({
|
body : JSON.stringify({
|
||||||
prompt,
|
prompt,
|
||||||
scenario_id : scenario.id, // ← keep it minimal
|
scenario_id : currentScenario?.id, // ← keep it minimal
|
||||||
chatHistory : messagesToSend // ← backend needs this to find userMsg
|
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 we got a real patch, build + forward the diff array */
|
||||||
if (data.scenarioPatch && onScenarioPatch) {
|
if (data.scenarioPatch && onScenarioPatch) {
|
||||||
onScenarioPatch(scenario.id, data.scenarioPatch); // ✅ id + patch
|
onScenarioPatch(currentScenario.id, data.scenarioPatch); // ✅ id + patch
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -177,9 +194,10 @@ function handleKeyUp(e) {
|
|||||||
|
|
||||||
|
|
||||||
/* ----------------------- render ----------------------- */
|
/* ----------------------- render ----------------------- */
|
||||||
const scenarioLabel = scenario
|
const scenarioLabel =
|
||||||
? (scenario.scenario_title || scenario.career_name || 'Untitled Scenario')
|
currentScenario
|
||||||
: 'Select a scenario';
|
? currentScenario.scenario_title || currentScenario.career_name || 'Untitled Scenario'
|
||||||
|
: 'Pick a scenario';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@ -188,12 +206,33 @@ function handleKeyUp(e) {
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* ---------- Header with a mini‑selector ---------- */}
|
||||||
<header className="p-3 border-b flex items-center gap-2 text-sm font-semibold">
|
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!scenario}
|
disabled={!currentScenario}
|
||||||
onClick={() => setForceCtx(true)}
|
onClick={() => setForceCtx(true)}
|
||||||
title="Force refresh context for next turn"
|
title="Force refresh context for next turn"
|
||||||
>
|
>
|
||||||
@ -229,13 +268,13 @@ function handleKeyUp(e) {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => setInput(e.target.value)}
|
||||||
onKeyUp={handleKeyUp}
|
onKeyUp={handleKeyUp}
|
||||||
disabled={!scenario}
|
disabled={!currentScenario}
|
||||||
placeholder={scenario
|
placeholder={currentScenario
|
||||||
? 'Ask about this scenario…'
|
? 'Ask about this scenario…'
|
||||||
: 'Click a scenario card first'}
|
: 'Pick a scenario first'}
|
||||||
className="flex-1 resize-none border rounded px-2 py-1 text-sm disabled:bg-gray-100"
|
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'}
|
{loading ? '…' : 'Send'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -144,10 +144,11 @@ export default function RetirementPlanner () {
|
|||||||
baselineYears={baselineYears}
|
baselineYears={baselineYears}
|
||||||
onClone={handleCloneScenario}
|
onClone={handleCloneScenario}
|
||||||
onRemove={handleRemoveScenario}
|
onRemove={handleRemoveScenario}
|
||||||
onSelect={() => {
|
onAskAI={() => { /* ← only fires from the new button */
|
||||||
setSelectedScenario(sc);
|
setSelectedScenario(sc);
|
||||||
openRetire({
|
openRetire({
|
||||||
scenario: sc,
|
scenario: sc,
|
||||||
|
scenarios : scenarios,
|
||||||
financialProfile,
|
financialProfile,
|
||||||
onScenarioPatch: applyPatch
|
onScenarioPatch: applyPatch
|
||||||
});
|
});
|
||||||
|
@ -5,7 +5,6 @@ import annotationPlugin from 'chartjs-plugin-annotation';
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
import InfoTooltip from "./ui/infoTooltip.js";
|
|
||||||
import authFetch from '../utils/authFetch.js';
|
import authFetch from '../utils/authFetch.js';
|
||||||
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
|
||||||
import parseFloatOrZero from '../utils/ParseFloatorZero.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 {
|
try {
|
||||||
const resp = await fetch('api/signin', {
|
const resp = await fetch('/api/signin', {
|
||||||
method : 'POST',
|
method : 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body : JSON.stringify({username, password}),
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -8,13 +8,3 @@ export const Card = ({ className, ...props }) => (
|
|||||||
export const CardContent = ({ className, ...props }) => (
|
export const CardContent = ({ className, ...props }) => (
|
||||||
<div className={cn("p-4", 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';
|
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) {
|
export async function clientGeocodeZip(zip) {
|
||||||
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
|
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 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(',');
|
const cipParam = codesArray.join(',');
|
||||||
|
|
||||||
// 3) Call your endpoint with `?cipCodes=1101,1409&state=NY`
|
// 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: {
|
params: {
|
||||||
cipCodes: cipParam,
|
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
|
// Salary
|
||||||
let salaryResponse;
|
let salaryResponse;
|
||||||
try {
|
try {
|
||||||
salaryResponse = await axios.get('api/salary', {
|
salaryResponse = await axios.get('/api/salary', {
|
||||||
params: { socCode: socCode.split('.')[0], area: areaTitle },
|
params: { socCode: socCode.split('.')[0], area: areaTitle },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -56,7 +56,7 @@
|
|||||||
// Tuition
|
// Tuition
|
||||||
let tuitionResponse;
|
let tuitionResponse;
|
||||||
try {
|
try {
|
||||||
tuitionResponse = await axios.get('api/tuition', {
|
tuitionResponse = await axios.get('/api/tuition', {
|
||||||
params: { cipCode: cleanedCipCode, state: userState },
|
params: { cipCode: cleanedCipCode, state: userState },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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