So many changes: created CareerSearch.js to be included in MilestoneTracker.js, several version updates for Node, Chad UI, etc.

This commit is contained in:
Josh 2025-03-20 13:20:54 +00:00
parent 3c5a2b3631
commit d294d609d0
21 changed files with 3243 additions and 899 deletions

83
AutoSuggestFields.js Normal file
View File

@ -0,0 +1,83 @@
import React, { useState } from 'react';
const AutoSuggestFields = () => {
const [cluster, setCluster] = useState('');
const [subdivision, setSubdivision] = useState('');
const [career, setCareer] = useState('');
const clusters = ['Cluster A', 'Cluster B', 'Cluster C'];
const subdivisions = {
'Cluster A': ['Subdivision A1', 'Subdivision A2'],
'Cluster B': ['Subdivision B1', 'Subdivision B2'],
'Cluster C': ['Subdivision C1', 'Subdivision C2'],
};
const careers = {
'Subdivision A1': ['Career A1-1', 'Career A1-2'],
'Subdivision B1': ['Career B1-1', 'Career B1-2'],
'Subdivision C1': ['Career C1-1', 'Career C1-2'],
};
const handleClusterChange = (e) => {
setCluster(e.target.value);
setSubdivision('');
setCareer('');
};
const handleSubdivisionChange = (e) => {
setSubdivision(e.target.value);
setCareer('');
};
return (
<div>
<label>
Cluster:
<input
type="text"
list="cluster-options"
value={cluster}
onChange={handleClusterChange}
/>
<datalist id="cluster-options">
{clusters.map((c) => (
<option key={c} value={c} />
))}
</datalist>
</label>
<label>
Subdivision:
<input
type="text"
list="subdivision-options"
value={subdivision}
onChange={handleSubdivisionChange}
disabled={!cluster}
/>
<datalist id="subdivision-options">
{(subdivisions[cluster] || []).map((s) => (
<option key={s} value={s} />
))}
</datalist>
</label>
<label>
Career:
<select
value={career}
onChange={(e) => setCareer(e.target.value)}
disabled={!subdivision}
>
<option value="">Select a career</option>
{(careers[subdivision] || []).map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
</div>
);
};
export default AutoSuggestFields;

148
dist/output.css vendored Normal file
View File

@ -0,0 +1,148 @@
/*! tailwindcss v4.0.14 | MIT License | https://tailwindcss.com */
.absolute {
position: absolute;
}
.fixed {
position: fixed;
}
.relative {
position: relative;
}
.static {
position: static;
}
.mx-auto {
margin-inline: auto;
}
.block {
display: block;
}
.flex {
display: flex;
}
.grid {
display: grid;
}
.h-full {
height: 100%;
}
.w-full {
width: 100%;
}
.grow {
flex-grow: 1;
}
.cursor-pointer {
cursor: pointer;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.justify-end {
justify-content: flex-end;
}
.overflow-hidden {
overflow: hidden;
}
.overflow-y-auto {
overflow-y: auto;
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
}
.text-center {
text-align: center;
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter;
transition-timing-function: var(--tw-ease, ease);
transition-duration: var(--tw-duration, 0s);
}
.transition-all {
transition-property: all;
transition-timing-function: var(--tw-ease, ease);
transition-duration: var(--tw-duration, 0s);
}
.focus\:ring {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentColor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
syntax: "*";
inherits: false;
}
@property --tw-ring-offset-width {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --tw-ring-offset-color {
syntax: "*";
inherits: false;
initial-value: #fff;
}
@property --tw-ring-offset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}

2941
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,23 +4,33 @@
"private": true,
"type": "module",
"dependencies": {
"@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-tabs": "^1.0.3",
"axios": "^1.7.9",
"bcrypt": "^5.1.1",
"chart.js": "^4.4.7",
"class-variance-authority": "^0.7.1",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"cra-template": "1.2.0",
"dotenv": "^16.4.7",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.483.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.1.1",
"react-router": "^7.3.0",
"react-router-dom": "^6.20.1",
"react-scripts": "^5.0.1",
"react-spinners": "^0.15.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.0.2",
"web-vitals": "^4.2.4",
"xlsx": "^0.18.5"
},
@ -54,6 +64,9 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11"
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17"
}
}

8
postcss.config.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -6,6 +6,7 @@ import SignUp from './components/SignUp.js';
import InterestInventory from './components/InterestInventory.js';
import Dashboard from './components/Dashboard.js';
import UserProfile from './components/UserProfile.js';
import MilestoneTracker from "./components/MilestoneTracker.js";
import './App.css';
function App() {
@ -42,7 +43,10 @@ function App() {
path="/profile"
element={isAuthenticated ? <UserProfile /> : <Navigate to="/signin" />}
/>
<Route
path="/milestone-tracker"
element={isAuthenticated ? <MilestoneTracker /> : <Navigate to="/signin" />}
/>
{/* Catch-all for unknown routes */}
<Route path="*" element={<Navigate to="/signin" />} />
</Routes>

View File

@ -0,0 +1,113 @@
import React, { useState, useEffect } from "react";
import { Input } from "./ui/input.js"; // Assuming Input is a basic text input component
const CareerSearch = () => {
const [careerClusters, setCareerClusters] = useState({});
const [selectedCluster, setSelectedCluster] = useState("");
const [selectedSubdivision, setSelectedSubdivision] = useState("");
const [selectedCareer, setSelectedCareer] = useState("");
const [careerSearch, setCareerSearch] = useState("");
useEffect(() => {
const fetchCareerClusters = async () => {
try {
const response = await fetch('/career_clusters.json');
const data = await response.json();
setCareerClusters(data);
} catch (error) {
console.error("Error fetching career clusters:", error);
}
};
fetchCareerClusters();
}, []);
// Handle Cluster Selection
const handleClusterSelect = (cluster) => {
setSelectedCluster(cluster);
setSelectedSubdivision(""); // Reset subdivision on cluster change
setSelectedCareer(""); // Reset career on cluster change
};
// Handle Subdivision Selection
const handleSubdivisionSelect = (subdivision) => {
setSelectedSubdivision(subdivision);
setSelectedCareer(""); // Reset career on subdivision change
};
// Handle Career Selection
const handleCareerSearch = (e) => {
const query = e.target.value.toLowerCase();
setCareerSearch(query);
};
// Get subdivisions based on selected cluster
const subdivisions = selectedCluster ? Object.keys(careerClusters[selectedCluster] || {}) : [];
// Get careers based on selected subdivision
const careers = selectedSubdivision ? careerClusters[selectedCluster]?.[selectedSubdivision] || [] : [];
return (
<div>
<h2>Milestone Tracker Loaded</h2>
{/* Career Cluster Selection */}
<div>
<h3>Select a Career Cluster</h3>
<Input
value={selectedCluster}
onChange={(e) => handleClusterSelect(e.target.value)}
placeholder="Search for a Career Cluster"
list="career-clusters"
/>
<datalist id="career-clusters">
{Object.keys(careerClusters).map((cluster, index) => (
<option key={index} value={cluster} />
))}
</datalist>
</div>
{/* Subdivision Selection based on Cluster */}
{selectedCluster && (
<div>
<h3>Select a Subdivision</h3>
<Input
value={selectedSubdivision}
onChange={(e) => handleSubdivisionSelect(e.target.value)}
placeholder="Search for a Subdivision"
list="subdivisions"
/>
<datalist id="subdivisions">
{subdivisions.map((subdivision, index) => (
<option key={index} value={subdivision} />
))}
</datalist>
</div>
)}
{/* Career Selection based on Subdivision */}
{selectedSubdivision && (
<div>
<h3>Select a Career</h3>
<Input
value={careerSearch}
onChange={handleCareerSearch}
placeholder="Search for a Career"
list="careers"
/>
<datalist id="careers">
{careers
.filter((career) => career.title.toLowerCase().includes(careerSearch)) // Filter careers based on search input
.map((career, index) => (
<option key={index} value={career.title} onClick={() => setSelectedCareer(career.title)} />
))}
</datalist>
</div>
)}
{/* Display selected career */}
{selectedCareer && <div>Selected Career: {selectedCareer}</div>}
</div>
);
};
export default CareerSearch;

View File

@ -73,13 +73,6 @@ export function CareerSuggestions({ careerSuggestions = [], userState, areaTitle
const isEconomicMissing = !economicData || Object.values(economicData).every(val => val === "N/A" || val === "*");
const isSalaryMissing = salaryResponse === null || salaryResponse === undefined;
// ✅ Log only when needed
if (isSalaryMissing) {
console.warn(`⚠️ Missing Salary Data for ${career.title} (${career.code})`);
} else {
console.log(`✅ Salary Data Available for ${career.title} (${career.code})`);
}
const isLimitedData = isCipMissing || isJobDetailsMissing || isEconomicMissing || isSalaryMissing;
if (isLimitedData) console.log(`⚠️ Setting limitedData for ${career.title} (${career.code})`);

View File

@ -47,14 +47,6 @@ const Chatbot = ({ context }) => {
: "Not available"
}
- ROI Analysis: ${
context.persistedROI && Array.isArray(context.persistedROI) && context.persistedROI.length > 0
? context.persistedROI
.map(roi => `${roi.schoolName}: Net Gain: $${roi.netGain || "N/A"}, Monthly Payment: $${roi.monthlyPayment || "N/A"}`)
.join("; ")
: "No ROI data available."
}
- User State: ${context.userState || "Not provided"}
- User Area: ${context.areaTitle || "Not provided"}
- User Zipcode: ${context.userZipcode || "Not provided"}

View File

@ -5,17 +5,15 @@ 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 './PopoutPanel.css';
import './Dashboard.css';
import Chatbot from "./Chatbot.js";
import { Bar } from 'react-chartjs-2';
import { fetchSchools } from '../utils/apiUtils.js';
import './Dashboard.css';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
function Dashboard() {
const location = useLocation()
const location = useLocation();
const navigate = useNavigate();
const [careerSuggestions, setCareerSuggestions] = useState([]);
@ -33,12 +31,11 @@ function Dashboard() {
const [userZipcode, setUserZipcode] = useState(null);
const [riaSecDescriptions, setRiaSecDescriptions] = useState([]);
const [selectedJobZone, setSelectedJobZone] = useState('');
const [careersWithJobZone, setCareersWithJobZone] = useState([]); // Store careers with job zone info
const [careersWithJobZone, setCareersWithJobZone] = useState([]);
const [selectedFit, setSelectedFit] = useState('');
const [results, setResults] = useState([]); // Add results state
const [results, setResults] = useState([]);
const [chatbotContext, setChatbotContext] = useState({});
const jobZoneLabels = {
'1': 'Little or No Preparation',
'2': 'Some Preparation Needed',
@ -53,10 +50,8 @@ function Dashboard() {
'Good': 'Good - Less Strong Match'
};
// Dynamic API URL
const apiUrl = process.env.REACT_APP_API_URL || '';
// Fetch job zone mappings after career suggestions are loaded
useEffect(() => {
const fetchJobZones = async () => {
if (careerSuggestions.length === 0) return;
@ -68,17 +63,17 @@ function Dashboard() {
const updatedCareers = careerSuggestions.map((career) => ({
...career,
job_zone: jobZoneData[career.code.slice(0, -3)]?.job_zone || null, // Extract correct value
job_zone: jobZoneData[career.code.slice(0, -3)]?.job_zone || null,
}));
setCareersWithJobZone(updatedCareers); // Update state
setCareersWithJobZone(updatedCareers);
} catch (error) {
console.error('Error fetching job zone information:', error);
}
};
fetchJobZones();
}, [careerSuggestions, apiUrl]);
}, [careerSuggestions, apiUrl]);
const filteredCareers = useMemo(() => {
return careersWithJobZone.filter((career) => {
@ -89,38 +84,34 @@ function Dashboard() {
Number(career.job_zone) === Number(selectedJobZone)
: true;
const fitMatches = selectedFit ? career.fit === selectedFit : true;
const fitMatches = selectedFit ? career.fit === selectedFit : true;
return jobZoneMatches && fitMatches;
});
}, [careersWithJobZone, selectedJobZone, selectedFit]);
return jobZoneMatches && fitMatches;
});
}, [careersWithJobZone, selectedJobZone, selectedFit]);
const updateChatbotContext = (updatedData) => {
setChatbotContext((prevContext) => {
const mergedContext = {
...prevContext, // ✅ Preserve existing context (Dashboard Data)
...Object.keys(updatedData).reduce((acc, key) => {
if (updatedData[key] !== undefined && updatedData[key] !== null) {
acc[key] = updatedData[key]; // ✅ Only update fields with actual data
}
return acc;
}, {}),
};
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;
});
};
console.log("🔄 Updated Chatbot Context (Merged Dashboard + PopoutPanel):", mergedContext);
return mergedContext;
});
};
const memoizedCareerSuggestions = useMemo(() => filteredCareers, [filteredCareers]);
const memoizedCareerSuggestions = useMemo(() => filteredCareers, [filteredCareers]);
const memoizedPopoutPanel = useMemo(() => {
console.log("Passing careerDetails to PopoutPanel:", careerDetails);
return (
<PopoutPanel
isVisible={!!selectedCareer} // ✅ Ensures it's only visible when needed
isVisible={!!selectedCareer}
data={careerDetails}
schools={schools}
salaryData={salaryData}
@ -130,20 +121,20 @@ const memoizedCareerSuggestions = useMemo(() => filteredCareers, [filteredCareer
loading={loading}
error={error}
userState={userState}
results={results}
results={results}
updateChatbotContext={updateChatbotContext}
/>
);
}, [selectedCareer, careerDetails, schools, salaryData, economicProjections, tuitionData, loading, error, userState]);
useEffect(() => {
let descriptions = []; // Declare outside for scope accessibility
let descriptions = [];
if (location.state) {
const { careerSuggestions: suggestions, riaSecScores: scores } = location.state || {};
descriptions = scores.map((score) => score.description || "No description available.");
descriptions = scores.map((score) => score.description || "No description available.");
setCareerSuggestions(suggestions || []);
setRiaSecScores(scores || []);
setRiaSecDescriptions(descriptions); // Set descriptions
setRiaSecDescriptions(descriptions);
} else {
console.warn('No data found, redirecting to Interest Inventory');
navigate('/interest-inventory');
@ -161,10 +152,10 @@ const memoizedCareerSuggestions = useMemo(() => filteredCareers, [filteredCareer
if (profileResponse.ok) {
const profileData = await profileResponse.json();
const { state, area, zipcode } = profileData; // Use 'area' instead of 'AREA_TITLE'
const { state, area, zipcode } = profileData;
setUserState(state);
setAreaTitle(area && area.trim() ? area.trim() : ''); // Ensure 'area' is set correctly
setUserZipcode(zipcode); // Set 'zipcode' in the state
setAreaTitle(area && area.trim() ? area.trim() : '');
setUserZipcode(zipcode);
} else {
console.error('Failed to fetch user profile');
}
@ -184,134 +175,117 @@ const memoizedCareerSuggestions = useMemo(() => filteredCareers, [filteredCareer
areaTitle !== null &&
userZipcode !== null
) {
console.log("✅ All data ready, forcing chatbotContext update...");
// ✅ Create a completely new object so React detects the change
const newChatbotContext = {
careerSuggestions: [...careersWithJobZone], // Ensure fresh array reference
riaSecScores: [...riaSecScores], // Ensure fresh array reference
careerSuggestions: [...careersWithJobZone],
riaSecScores: [...riaSecScores],
userState: userState || "",
areaTitle: areaTitle || "",
userZipcode: userZipcode || "",
};
setChatbotContext(newChatbotContext);
} else {
console.log("⏳ Skipping chatbotContext update because data is not ready yet.");
}
}, [careerSuggestions, riaSecScores, userState, areaTitle, userZipcode, careersWithJobZone]);
const handleCareerClick = useCallback(
async (career) => {
const socCode = career.code; // Extract SOC code from career object
setSelectedCareer(career); // Set career first to trigger loading panel
setLoading(true); // Enable loading state only when career is clicked
setError(null); // Clear previous errors
setCareerDetails({}); // Reset career details to avoid undefined errors
setSchools([]); // Reset schools
setSalaryData([]); // Reset salary data
setEconomicProjections({}); // Reset economic projections
setTuitionData([]); // Reset tuition data
const handleCareerClick = useCallback(
async (career) => {
const socCode = career.code;
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 {
// Step 1: Fetch CIP Code
const cipResponse = await fetch(`${apiUrl}/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);
// Step 2: Fetch Job Description and Tasks
const jobDetailsResponse = await fetch(`${apiUrl}/onet/career-description/${socCode}`);
if (!jobDetailsResponse.ok) throw new Error('Failed to fetch job description');
const { description, tasks } = await jobDetailsResponse.json();
// Step 3: Fetch Data in Parallel for other career details
// Salary API call with error handling
let salaryResponse;
try {
salaryResponse = await axios.get(`${apiUrl}/salary`, { params: { socCode: socCode.split('.')[0], area: areaTitle }});
} catch (error) {
salaryResponse = { data: {} }; // Prevents breaking the whole update
}
// Projections API call with error handling
let economicResponse;
try {
economicResponse = await axios.get(`${apiUrl}/projections/${socCode.split('.')[0]}`);
} catch (error) {
economicResponse = { data: {} }; // Prevents breaking the whole update
}
// Tuition API call with error handling
let tuitionResponse;
try {
tuitionResponse = await axios.get(`${apiUrl}/tuition`, { params: { cipCode: cleanedCipCode, state: userState }});
} catch (error) {
tuitionResponse = { data: {} };
}
// Fetch schools separately (this one seems to be working fine)
const filteredSchools = await fetchSchools(cleanedCipCode, userState);
// Handle Distance Calculation
const schoolsWithDistance = await Promise.all(filteredSchools.map(async (school) => {
const schoolAddress = `${school.Address}, ${school.City}, ${school.State} ${school.ZIP}`;
try {
const response = await axios.post(`${apiUrl}/maps/distance`, {
userZipcode,
destinations: schoolAddress,
});
const { distance, duration } = response.data;
return { ...school, distance, duration };
} catch (error) {
return { ...school, distance: 'N/A', duration: 'N/A' };
if (!socCode) {
console.error('SOC Code is missing');
setError('SOC Code is missing');
return;
}
}));
// Process Salary Data
const salaryDataPoints = salaryResponse.data && Object.keys(salaryResponse.data).length > 0
? [
{ percentile: "10th Percentile", regionalSalary: parseInt(salaryResponse.data.regional?.regional_PCT10, 10) || 0, nationalSalary: parseInt(salaryResponse.data.national?.national_PCT10, 10) || 0 },
{ percentile: "25th Percentile", regionalSalary: parseInt(salaryResponse.data.regional?.regional_PCT25, 10) || 0, nationalSalary: parseInt(salaryResponse.data.national?.national_PCT25, 10) || 0 },
{ percentile: "Median", regionalSalary: parseInt(salaryResponse.data.regional?.regional_MEDIAN, 10) || 0, nationalSalary: parseInt(salaryResponse.data.national?.national_MEDIAN, 10) || 0 },
{ percentile: "75th Percentile", regionalSalary: parseInt(salaryResponse.data.regional?.regional_PCT75, 10) || 0, nationalSalary: parseInt(salaryResponse.data.national?.national_PCT75, 10) || 0 },
{ percentile: "90th Percentile", regionalSalary: parseInt(salaryResponse.data.regional?.regional_PCT90, 10) || 0, nationalSalary: parseInt(salaryResponse.data.national?.national_PCT90, 10) || 0 },
]
: [];
try {
const cipResponse = await fetch(`${apiUrl}/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);
const updatedCareerDetails = {
...career,
jobDescription: description,
tasks: tasks,
economicProjections: economicResponse.data || {},
salaryData: salaryDataPoints,
schools: schoolsWithDistance,
tuitionData: tuitionResponse.data || [],
};
// Ensure `careerDetails` is fully updated before passing to chatbot
setCareerDetails(updatedCareerDetails);
updateChatbotContext({ careerDetails: updatedCareerDetails });
const jobDetailsResponse = await fetch(`${apiUrl}/onet/career-description/${socCode}`);
if (!jobDetailsResponse.ok) throw new Error('Failed to fetch job description');
const { description, tasks } = await jobDetailsResponse.json();
} catch (error) {
console.error('Error processing career click:', error.message);
setError('Failed to load data');
} finally {
setLoading(false);
}
},
[userState, apiUrl, areaTitle, userZipcode]
);
let salaryResponse;
try {
salaryResponse = await axios.get(`${apiUrl}/salary`, { params: { socCode: socCode.split('.')[0], area: areaTitle } });
} catch (error) {
salaryResponse = { data: {} };
}
let economicResponse;
try {
economicResponse = await axios.get(`${apiUrl}/projections/${socCode.split('.')[0]}`);
} catch (error) {
economicResponse = { data: {} };
}
let tuitionResponse;
try {
tuitionResponse = await axios.get(`${apiUrl}/tuition`, { params: { cipCode: cleanedCipCode, state: userState } });
} catch (error) {
tuitionResponse = { data: {} };
}
const filteredSchools = await fetchSchools(cleanedCipCode, userState);
const schoolsWithDistance = await Promise.all(filteredSchools.map(async (school) => {
const schoolAddress = `${school.Address}, ${school.City}, ${school.State} ${school.ZIP}`;
try {
const response = await axios.post(`${apiUrl}/maps/distance`, {
userZipcode,
destinations: schoolAddress,
});
const { distance, duration } = response.data;
return { ...school, distance, duration };
} catch (error) {
return { ...school, distance: 'N/A', duration: 'N/A' };
}
}));
const salaryDataPoints = salaryResponse.data && Object.keys(salaryResponse.data).length > 0
? [
{ percentile: "10th Percentile", regionalSalary: parseInt(salaryResponse.data.regional?.regional_PCT10, 10) || 0, nationalSalary: parseInt(salaryResponse.data.national?.national_PCT10, 10) || 0 },
{ percentile: "25th Percentile", regionalSalary: parseInt(salaryResponse.data.regional?.regional_PCT25, 10) || 0, nationalSalary: parseInt(salaryResponse.data.national?.national_PCT25, 10) || 0 },
{ percentile: "Median", regionalSalary: parseInt(salaryResponse.data.regional?.regional_MEDIAN, 10) || 0, nationalSalary: parseInt(salaryResponse.data.national?.national_MEDIAN, 10) || 0 },
{ percentile: "75th Percentile", regionalSalary: parseInt(salaryResponse.data.regional?.regional_PCT75, 10) || 0, nationalSalary: parseInt(salaryResponse.data.national?.national_PCT75, 10) || 0 },
{ percentile: "90th Percentile", regionalSalary: parseInt(salaryResponse.data.regional?.regional_PCT90, 10) || 0, nationalSalary: parseInt(salaryResponse.data.national?.national_PCT90, 10) || 0 },
]
: [];
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, apiUrl, areaTitle, userZipcode]
);
const chartData = {
labels: riaSecScores.map((score) => score.area),
@ -326,111 +300,97 @@ async (career) => {
],
};
console.log("Passing context to Chatbot:", {
careerSuggestions,
riaSecScores,
selectedCareer,
userState,
areaTitle,
userZipcode,
});
return (
<div className="dashboard">
<div className="dashboard-content">
<div className="career-suggestions-container">
<div
className="career-suggestions-header"
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '15px',
justifyContent: 'center',
gap: '15px'
}}
<div className="dashboard-content">
<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' }}
>
<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>
<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>
<CareerSuggestions
careerSuggestions={memoizedCareerSuggestions}
onCareerClick={handleCareerClick}
/>
</div>
<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>
<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>
<CareerSuggestions
careerSuggestions={memoizedCareerSuggestions}
onCareerClick={handleCareerClick}
/>
</div>
{memoizedPopoutPanel}
<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>
{memoizedPopoutPanel}
{/* Pass context to Chatbot */}
<div className="chatbot-widget">
<div className="chatbot-widget">
{careerSuggestions.length > 0 ? (
<Chatbot context={chatbotContext} />
) : (
<p>Loading Chatbot...</p>
)}
</div>
</div>
{/* Acknowledgment Section */}
<div
className="data-source-acknowledgment"
style={{
marginTop: '20px',
padding: '10px',
borderTop: '1px solid #ccc',
fontSize: '12px',
color: '#666',
textAlign: 'center'
<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

View File

@ -1,226 +0,0 @@
import React, { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { CheckCircle, Clock, Target, PlusCircle, Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
const MilestoneTracker = () => {
const location = useLocation();
const initialCareer = location.state?.career || "";
const [careerClusters, setCareerClusters] = useState({});
const [selectedCluster, setSelectedCluster] = useState(null);
const [selectedSubdivision, setSelectedSubdivision] = useState(null);
const [selectedCareer, setSelectedCareer] = useState(initialCareer);
const [filteredClusters, setFilteredClusters] = useState([]);
const [activeTab, setActiveTab] = useState("career");
const [customMilestones, setCustomMilestones] = useState({ career: [], financial: [], retirement: [] });
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [newMilestone, setNewMilestone] = useState("");
const [careerSearch, setCareerSearch] = useState("");
const [filteredCareers, setFilteredCareers] = useState(careerClusters);
useEffect(() => {
const fetchUserProfile = async () => {
try {
const response = await fetch("/api/user/profile", {
method: "GET",
credentials: "include", // Ensure cookies/session are sent
});
if (response.ok) {
const data = await response.json();
setIsPremiumUser(data.is_premium === 1); // Expecting { is_premium: 0 or 1 }
} else {
setIsPremiumUser(false); // Default to false if there's an error
}
} catch (error) {
console.error("Error fetching user profile:", error);
setIsPremiumUser(false);
}
};
fetchUserProfile();
}, []);
if (isPremiumUser === null) {
return <div className="p-6 text-center">Loading...</div>; // Show loading state while fetching
}
if (!isPremiumUser) {
return (
<div className="p-6 text-center">
<Lock className="mx-auto text-gray-400 w-16 h-16 mb-4" />
<h2 className="text-xl font-bold">Access Restricted</h2>
<p className="text-gray-600">Upgrade to Aptiva Premium to access the Milestone Tracker.</p>
<Button className="mt-4">Upgrade Now</Button>
</div>
);
}
useEffect(() => {
fetch("/data/career_clusters.json")
.then((response) => response.json())
.then((data) => {
setCareerClusters(data);
setFilteredClusters(Object.keys(data));
})
.catch((error) => console.error("Error loading career clusters:", error));
}, []);
const handleAddMilestone = () => {
if (newMilestone.trim() !== "") {
setCustomMilestones((prev) => ({
...prev,
[activeTab]: [...prev[activeTab], { title: newMilestone, status: "upcoming", progress: 0 }],
}));
setNewMilestone("");
setIsDialogOpen(false);
}
};
const handleCareerSearch = (e) => {
const query = e.target.value.toLowerCase();
setCareerSearch(query);
setFilteredCareers(
careerClusters.filter((career) => career.toLowerCase().includes(query))
);
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Milestone Tracker</h1>
{/* Career Cluster Selection */}
<div className="mb-6 p-4 border rounded-lg shadow-md bg-white">
<h2 className="text-xl font-semibold mb-2">Search & Select a Career Cluster</h2>
<Input
value={careerSearch}
onChange={handleCareerSearch}
placeholder="Search for a career cluster"
className="mb-2"
/>
<div className="max-h-40 overflow-y-auto border rounded p-2">
{filteredClusters.map((cluster, index) => (
<div
key={index}
className={`p-2 hover:bg-gray-200 cursor-pointer rounded ${cluster === selectedCluster ? 'bg-blue-200' : ''}`}
onClick={() => {
setSelectedCluster(cluster);
setSelectedSubdivision(null);
setSelectedCareer(null);
}}
>
<strong>{cluster}</strong>
</div>
))}
</div>
</div>
{/* Subdivision Selection within Cluster */}
{selectedCluster && careerClusters[selectedCluster] && (
<div className="mb-6 p-4 border rounded-lg shadow-md bg-white">
<h2 className="text-xl font-semibold mb-2">Select a Specialization in {selectedCluster}</h2>
<div className="max-h-40 overflow-y-auto border rounded p-2">
{Object.keys(careerClusters[selectedCluster]).map((subdivision, index) => (
<div
key={index}
className={`p-2 hover:bg-gray-200 cursor-pointer rounded ${subdivision === selectedSubdivision ? 'bg-blue-200' : ''}`}
onClick={() => {
setSelectedSubdivision(subdivision);
setSelectedCareer(null);
}}
>
{subdivision}
</div>
))}
</div>
</div>
)}
{/* Career Selection within Subdivision */}
{selectedSubdivision && careerClusters[selectedCluster][selectedSubdivision] && (
<div className="mb-6 p-4 border rounded-lg shadow-md bg-white">
<h2 className="text-xl font-semibold mb-2">Select a Career in {selectedSubdivision}</h2>
<div className="max-h-40 overflow-y-auto border rounded p-2">
{Object.keys(careerClusters[selectedCluster][selectedSubdivision]).map((career, index) => (
<div
key={index}
className={`p-2 hover:bg-gray-200 cursor-pointer rounded ${career === selectedCareer ? 'bg-blue-200' : ''}`}
onClick={() => setSelectedCareer(career)}
>
{career}
</div>
))}
</div>
</div>
)}
{/* Milestone Tracker Section */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList>
<TabsTrigger value="career">Career</TabsTrigger>
<TabsTrigger value="financial">Financial</TabsTrigger>
<TabsTrigger value="retirement">Retirement</TabsTrigger>
</TabsList>
</Tabs>
{/* Add Milestone Section */}
<Button onClick={() => setIsDialogOpen(true)} className="mb-4 flex items-center gap-2">
<PlusCircle /> Add Milestone
</Button>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add a New Milestone</DialogTitle>
</DialogHeader>
<Input value={newMilestone} onChange={(e) => setNewMilestone(e.target.value)} placeholder="Enter milestone title" />
<DialogFooter>
<Button onClick={handleAddMilestone}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Display User-Added Milestones */}
<TabsContent value={activeTab}>
{customMilestones[activeTab].map((milestone, index) => (
<Card key={index} className="mb-4 p-4">
<CardContent className="flex justify-between items-center">
<div className="flex items-center gap-4">
{milestone.status === "completed" && <CheckCircle className="text-green-500" />}
{milestone.status === "in-progress" && <Clock className="text-yellow-500" />}
{milestone.status === "upcoming" && <Target className="text-gray-500" />}
<h2 className="text-lg font-semibold">{milestone.title}</h2>
</div>
<Progress value={milestone.progress} className="w-40" />
</CardContent>
</Card>
))}
</TabsContent>
<Button onClick={() => setIsDialogOpen(true)} className="mb-4 flex items-center gap-2">
<PlusCircle /> Add Milestone
</Button>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add a New Milestone</DialogTitle>
</DialogHeader>
<Input value={newMilestone} onChange={(e) => setNewMilestone(e.target.value)} placeholder="Enter milestone title" />
<DialogFooter>
<Button onClick={handleAddMilestone}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default MilestoneTracker;

View File

@ -1,3 +1,5 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { ClipLoader } from 'react-spinners';
import LoanRepayment from './LoanRepayment.js';
import SchoolFilters from './SchoolFilters';
@ -21,6 +23,8 @@ function PopoutPanel({
const [sortBy, setSortBy] = useState('tuition'); // Default sorting
const [maxTuition, setMaxTuition] = useState(50000); // Set default max tuition value
const [maxDistance, setMaxDistance] = useState(200); // Set default max distance value
const navigate = useNavigate();
const {
jobDescription = null,
@ -41,7 +45,6 @@ function PopoutPanel({
}, [schools]);
useEffect(() => {
console.log("📩 Updating Chatbot Context from PopoutPanel:", data);
if (data && Object.keys(data).length > 0) {
updateChatbotContext({
@ -53,7 +56,6 @@ function PopoutPanel({
persistedROI, // ✅ Make sure ROI is included!
});
} else {
console.log("⚠️ No valid PopoutPanel data to update chatbot context.");
}
}, [data, schools, salaryData, economicProjections, results, persistedROI, updateChatbotContext]);
@ -110,7 +112,15 @@ function PopoutPanel({
{/* Header with Close & Plan My Path Buttons */}
<div className="panel-header">
<button className="close-btn" onClick={closePanel}>X</button>
<button className="plan-path-btn">Plan My Path</button>
<button
className="plan-path-btn"
onClick={() => {
console.log("Navigating to Milestone Tracker with career title:", title); // Log the title
navigate("/milestone-tracker", { state: { career: title } });
}}
>
Plan My Path
</button>
</div>
<h2>{title}</h2>

View File

@ -0,0 +1,17 @@
import * as React from "react";
import { cn } from "../../utils/cn.js";
const Button = React.forwardRef(({ className, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition",
className
)}
{...props}
/>
);
});
export { Button };

10
src/components/ui/card.js Normal file
View File

@ -0,0 +1,10 @@
import * as React from "react";
import { cn } from "../../utils/cn.js";
export const Card = ({ className, ...props }) => (
<div className={cn("rounded-lg border bg-white shadow-sm", className)} {...props} />
);
export const CardContent = ({ className, ...props }) => (
<div className={cn("p-4", className)} {...props} />
);

View File

@ -0,0 +1,27 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogContent = React.forwardRef(({ className, ...props }, ref) => {
return (
<DialogPrimitive.Content
ref={ref}
className="fixed inset-0 bg-white shadow-lg p-4 max-w-lg mx-auto mt-20 rounded-md"
{...props}
/>
);
});
const DialogHeader = ({ children }) => {
return <div className="text-lg font-semibold border-b pb-2 mb-4">{children}</div>;
};
const DialogFooter = ({ children }) => {
return <div className="flex justify-end gap-2">{children}</div>;
};
const DialogTitle = DialogPrimitive.Title;
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle };

View File

@ -0,0 +1,17 @@
import * as React from "react";
import { cn } from "../../utils/cn.js";
const Input = React.forwardRef(({ className, ...props }, ref) => {
return (
<input
ref={ref}
className={cn(
"border border-gray-300 rounded-md px-3 py-2 focus:ring focus:ring-blue-400",
className
)}
{...props}
/>
);
});
export { Input };

View File

@ -0,0 +1,20 @@
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 };

View File

@ -0,0 +1,9 @@
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 };

View File

@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',

4
src/utils/cn.js Normal file
View File

@ -0,0 +1,4 @@
export function cn(...classes) {
return classes.filter(Boolean).join(" ");
}

9
tailwind.config.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
content: [
'./src/**/*.{js,jsx,ts,tsx}', // Ensure this matches your file structure
],
theme: {
extend: {},
},
plugins: [],
};