MilestoneTracker.js refactor
This commit is contained in:
parent
dd1d6bec88
commit
20214f9069
0
MilestoneTimeline.js
Normal file
0
MilestoneTimeline.js
Normal file
@ -82,13 +82,19 @@ app.get('/api/premium/planned-path/all', authenticatePremiumUser, async (req, re
|
|||||||
|
|
||||||
// Save a new planned path
|
// Save a new planned path
|
||||||
app.post('/api/premium/planned-path', authenticatePremiumUser, async (req, res) => {
|
app.post('/api/premium/planned-path', authenticatePremiumUser, async (req, res) => {
|
||||||
const { career_name } = req.body;
|
let { career_name } = req.body;
|
||||||
|
|
||||||
if (!career_name) {
|
if (!career_name) {
|
||||||
return res.status(400).json({ error: 'Career name is required.' });
|
return res.status(400).json({ error: 'Career name is required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ensure that career_name is always a string
|
||||||
|
if (typeof career_name !== 'string') {
|
||||||
|
console.warn('career_name was not a string. Converting to string.');
|
||||||
|
career_name = String(career_name); // Convert to string
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the career path already exists for the user
|
// Check if the career path already exists for the user
|
||||||
const existingCareerPath = await db.get(
|
const existingCareerPath = await db.get(
|
||||||
`SELECT id FROM career_path WHERE user_id = ? AND career_name = ?`,
|
`SELECT id FROM career_path WHERE user_id = ? AND career_name = ?`,
|
||||||
@ -96,7 +102,6 @@ app.post('/api/premium/planned-path', authenticatePremiumUser, async (req, res)
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (existingCareerPath) {
|
if (existingCareerPath) {
|
||||||
// Return the existing path — do NOT define or reuse newCareerPathId
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
message: 'Career path already exists. Would you like to reload it or create a new one?',
|
message: 'Career path already exists. Would you like to reload it or create a new one?',
|
||||||
career_path_id: existingCareerPath.id,
|
career_path_id: existingCareerPath.id,
|
||||||
@ -104,7 +109,7 @@ app.post('/api/premium/planned-path', authenticatePremiumUser, async (req, res)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only define newCareerPathId *when* creating a new path
|
// Define a new career path id and insert into the database
|
||||||
const newCareerPathId = uuidv4();
|
const newCareerPathId = uuidv4();
|
||||||
await db.run(
|
await db.run(
|
||||||
`INSERT INTO career_path (id, user_id, career_name) VALUES (?, ?, ?)`,
|
`INSERT INTO career_path (id, user_id, career_name) VALUES (?, ?, ?)`,
|
||||||
@ -114,7 +119,7 @@ app.post('/api/premium/planned-path', authenticatePremiumUser, async (req, res)
|
|||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: 'Career path saved.',
|
message: 'Career path saved.',
|
||||||
career_path_id: newCareerPathId,
|
career_path_id: newCareerPathId,
|
||||||
action_required: 'new_created' // Action flag for newly created path
|
action_required: 'new_created'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving career path:', error);
|
console.error('Error saving career path:', error);
|
||||||
@ -123,6 +128,7 @@ app.post('/api/premium/planned-path', authenticatePremiumUser, async (req, res)
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Save a new milestone
|
// Save a new milestone
|
||||||
app.post('/api/premium/milestones', authenticatePremiumUser, async (req, res) => {
|
app.post('/api/premium/milestones', authenticatePremiumUser, async (req, res) => {
|
||||||
const {
|
const {
|
||||||
|
45
src/components/AISuggestedMilestones.js
Normal file
45
src/components/AISuggestedMilestones.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// src/components/AISuggestedMilestones.js
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const AISuggestedMilestones = ({ career, careerPathId, authFetch }) => {
|
||||||
|
const [suggestedMilestones, setSuggestedMilestones] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!career) return;
|
||||||
|
setSuggestedMilestones([
|
||||||
|
{ title: `Entry-Level ${career}`, date: '2025-06-01', progress: 0 },
|
||||||
|
{ title: `Mid-Level ${career}`, date: '2027-01-01', progress: 0 },
|
||||||
|
{ title: `Senior-Level ${career}`, date: '2030-01-01', progress: 0 },
|
||||||
|
]);
|
||||||
|
}, [career]);
|
||||||
|
|
||||||
|
const confirmMilestones = async () => {
|
||||||
|
for (const milestone of suggestedMilestones) {
|
||||||
|
await authFetch(`/api/premium/milestones`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
milestone_type: 'Career',
|
||||||
|
title: milestone.title,
|
||||||
|
description: milestone.title,
|
||||||
|
date: milestone.date,
|
||||||
|
career_path_id: careerPathId,
|
||||||
|
progress: milestone.progress,
|
||||||
|
status: 'planned',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSuggestedMilestones([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!suggestedMilestones.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="suggested-milestones">
|
||||||
|
<h4>AI-Suggested Milestones</h4>
|
||||||
|
<ul>{suggestedMilestones.map((m, i) => <li key={i}>{m.title} - {m.date}</li>)}</ul>
|
||||||
|
<button onClick={confirmMilestones}>Confirm Milestones</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AISuggestedMilestones;
|
@ -1,160 +1,74 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Input } from "./ui/input.js"; // Assuming Input is a basic text input component
|
import { Input } from './ui/input.js';
|
||||||
|
|
||||||
const CareerSearch = ({ onSelectCareer, existingCareerPaths }) => {
|
|
||||||
const [careerClusters, setCareerClusters] = useState({});
|
|
||||||
const [selectedCluster, setSelectedCluster] = useState("");
|
|
||||||
const [selectedSubdivision, setSelectedSubdivision] = useState("");
|
|
||||||
const [selectedCareer, setSelectedCareer] = useState("");
|
|
||||||
const [careerSearch, setCareerSearch] = useState("");
|
|
||||||
|
|
||||||
|
const CareerSearch = ({ setPendingCareerForModal }) => {
|
||||||
|
const [careers, setCareers] = useState([]);
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCareerClusters = async () => {
|
const fetchCareerTitles = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/career_clusters.json');
|
const response = await fetch('/career_clusters.json');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setCareerClusters(data);
|
|
||||||
|
const careerTitlesSet = new Set();
|
||||||
|
|
||||||
|
// Iterate using Object.keys at every level (no .forEach or .map)
|
||||||
|
const clusters = Object.keys(data);
|
||||||
|
for (let i = 0; i < clusters.length; i++) {
|
||||||
|
const cluster = clusters[i];
|
||||||
|
const subdivisions = Object.keys(data[cluster]);
|
||||||
|
|
||||||
|
for (let j = 0; j < subdivisions.length; j++) {
|
||||||
|
const subdivision = subdivisions[j];
|
||||||
|
const careersArray = data[cluster][subdivision];
|
||||||
|
|
||||||
|
for (let k = 0; k < careersArray.length; k++) {
|
||||||
|
const careerObj = careersArray[k];
|
||||||
|
if (careerObj.title) {
|
||||||
|
careerTitlesSet.add(careerObj.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCareers([...careerTitlesSet]);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching career clusters:", error);
|
console.error("Error fetching or processing career_clusters.json:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchCareerClusters();
|
fetchCareerTitles();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleConfirmCareer = () => {
|
||||||
if (selectedCareer && careerClusters) {
|
if (careers.includes(searchInput)) {
|
||||||
for (const cluster in careerClusters) {
|
setPendingCareerForModal(searchInput);
|
||||||
for (const subdivision in careerClusters[cluster]) {
|
} else {
|
||||||
if (careerClusters[cluster][subdivision].some(job => job.title === selectedCareer)) {
|
alert("Please select a valid career from the suggestions.");
|
||||||
setSelectedCluster(cluster);
|
|
||||||
setSelectedSubdivision(subdivision);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setSelectedCluster('');
|
|
||||||
setSelectedSubdivision('');
|
|
||||||
}, [selectedCareer, careerClusters]);
|
|
||||||
|
|
||||||
// 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] || [] : [];
|
|
||||||
|
|
||||||
// Check if the selected career already has an existing career path
|
|
||||||
const hasCareerPath = existingCareerPaths.some(career => career.title === selectedCareer);
|
|
||||||
|
|
||||||
// Check if the selected career is the current one
|
|
||||||
const isCurrentCareer = selectedCareer === selectedCareer?.career_name;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Career Cluster Selection */}
|
<h3>Search for Career</h3>
|
||||||
<div>
|
<Input
|
||||||
<h3>Select a Career Cluster</h3>
|
value={searchInput}
|
||||||
<Input
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
value={selectedCluster}
|
placeholder="Start typing a career..."
|
||||||
onChange={(e) => handleClusterSelect(e.target.value)}
|
list="career-titles"
|
||||||
placeholder="Search for a Career Cluster"
|
/>
|
||||||
list="career-clusters"
|
<datalist id="career-titles">
|
||||||
/>
|
{careers.map((career, index) => (
|
||||||
<datalist id="career-clusters">
|
<option key={index} value={career} />
|
||||||
{Object.keys(careerClusters).map((cluster, index) => (
|
))}
|
||||||
<option key={index} value={cluster} />
|
</datalist>
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subdivision Selection based on Cluster */}
|
<button onClick={handleConfirmCareer}>
|
||||||
{selectedCluster && (
|
Confirm New Career
|
||||||
<div>
|
</button>
|
||||||
<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 style={{ marginTop: '10px' }}>
|
|
||||||
<div>Selected Career: {selectedCareer}</div>
|
|
||||||
{!hasCareerPath && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const matchedCareer = careers.find(
|
|
||||||
(c) => c.title.toLowerCase() === selectedCareer.toLowerCase()
|
|
||||||
);
|
|
||||||
if (matchedCareer) {
|
|
||||||
onSelectCareer(matchedCareer.title, matchedCareer.soc); // 🔥 this triggers the update upstream
|
|
||||||
} else {
|
|
||||||
alert("Please select a valid career from the list.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm Career Selection
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasCareerPath && (
|
|
||||||
<p>This career already has a career path. Do you want to reload it or create a new one?</p>
|
|
||||||
)}
|
|
||||||
{isCurrentCareer && (
|
|
||||||
<p>You are already on this career path.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
28
src/components/CareerSelectDropdown.js
Normal file
28
src/components/CareerSelectDropdown.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// src/components/CareerSelectDropdown.js
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const CareerSelectDropdown = ({ existingCareerPaths, selectedCareer, onChange, loading }) => {
|
||||||
|
return (
|
||||||
|
<div className="career-select-dropdown">
|
||||||
|
<label>Select Career Path:</label>
|
||||||
|
{loading ? (
|
||||||
|
<p>Loading career paths...</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={selectedCareer || ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
>
|
||||||
|
|
||||||
|
<option value="" disabled>Select career path...</option>
|
||||||
|
{existingCareerPaths.map((path) => (
|
||||||
|
<option key={path.career_path_id} value={path.career_name}>
|
||||||
|
{path.career_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CareerSelectDropdown;
|
@ -41,7 +41,6 @@ export function CareerSuggestions({ careerSuggestions = [], userState, areaTitle
|
|||||||
updateProgress(); // ✅ Update progress on success
|
updateProgress(); // ✅ Update progress on success
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`⚠️ Error fetching ${url}:`, error.response?.status);
|
|
||||||
updateProgress(); // ✅ Update progress even if failed
|
updateProgress(); // ✅ Update progress even if failed
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -61,7 +60,6 @@ export function CareerSuggestions({ careerSuggestions = [], userState, areaTitle
|
|||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
updateProgress();
|
updateProgress();
|
||||||
if (error.response?.status === 404) {
|
if (error.response?.status === 404) {
|
||||||
console.warn(`⚠️ Salary data missing for ${career.title} (${career.code})`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return error.response;
|
return error.response;
|
||||||
@ -74,12 +72,10 @@ export function CareerSuggestions({ careerSuggestions = [], userState, areaTitle
|
|||||||
const isSalaryMissing = salaryResponse === null || salaryResponse === undefined;
|
const isSalaryMissing = salaryResponse === null || salaryResponse === undefined;
|
||||||
|
|
||||||
const isLimitedData = isCipMissing || isJobDetailsMissing || isEconomicMissing || isSalaryMissing;
|
const isLimitedData = isCipMissing || isJobDetailsMissing || isEconomicMissing || isSalaryMissing;
|
||||||
if (isLimitedData) console.log(`⚠️ Setting limitedData for ${career.title} (${career.code})`);
|
|
||||||
|
|
||||||
return { ...career, limitedData: isLimitedData };
|
return { ...career, limitedData: isLimitedData };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error checking API response for ${career.title}:`, error);
|
|
||||||
return { ...career, limitedData: true };
|
return { ...career, limitedData: true };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -9,7 +9,6 @@ import MilestoneTracker from './MilestoneTracker.js'
|
|||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
import Chatbot from "./Chatbot.js";
|
import Chatbot from "./Chatbot.js";
|
||||||
import { Bar } from 'react-chartjs-2';
|
import { Bar } from 'react-chartjs-2';
|
||||||
import { authFetch } from '../utils/authFetch.js';
|
|
||||||
import { fetchSchools } from '../utils/apiUtils.js';
|
import { fetchSchools } from '../utils/apiUtils.js';
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
||||||
@ -39,15 +38,68 @@ function Dashboard() {
|
|||||||
const [chatbotContext, setChatbotContext] = useState({});
|
const [chatbotContext, setChatbotContext] = useState({});
|
||||||
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false);
|
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false);
|
||||||
const [sessionHandled, setSessionHandled] = useState(false);
|
const [sessionHandled, setSessionHandled] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
const handleUnauthorized = () => {
|
const handleUnauthorized = () => {
|
||||||
if (!sessionHandled) {
|
if (!sessionHandled) {
|
||||||
setSessionHandled(true);
|
setSessionHandled(true);
|
||||||
setShowSessionExpiredModal(true);
|
setShowSessionExpiredModal(true); // Show session expired modal
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Function to handle the token check and fetch requests
|
||||||
|
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(); // Show session expired modal
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const finalOptions = {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(options.headers || {}),
|
||||||
|
Authorization: `Bearer ${token}`, // Attach the token to the request
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, finalOptions);
|
||||||
|
|
||||||
|
// Log the response status for debugging
|
||||||
|
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(); // Show session expired modal
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fetch error:", err);
|
||||||
|
if (typeof onUnauthorized === 'function') onUnauthorized(); // Show session expired modal
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch User Profile (with proper session handling)
|
||||||
|
const fetchUserProfile = async () => {
|
||||||
|
const res = await authFetch(`${apiUrl}/user-profile`);
|
||||||
|
if (!res) return;
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const profileData = await res.json();
|
||||||
|
setUserState(profileData.state);
|
||||||
|
setAreaTitle(profileData.area.trim() || '');
|
||||||
|
setUserZipcode(profileData.zipcode);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch user profile');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const jobZoneLabels = {
|
const jobZoneLabels = {
|
||||||
'1': 'Little or No Preparation',
|
'1': 'Little or No Preparation',
|
||||||
'2': 'Some Preparation Needed',
|
'2': 'Some Preparation Needed',
|
||||||
@ -64,6 +116,24 @@ function Dashboard() {
|
|||||||
|
|
||||||
const apiUrl = process.env.REACT_APP_API_URL || '';
|
const apiUrl = process.env.REACT_APP_API_URL || '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserProfile = async () => {
|
||||||
|
const res = await authFetch(`${apiUrl}/user-profile`);
|
||||||
|
if (!res) return;
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const profileData = await res.json();
|
||||||
|
setUserState(profileData.state);
|
||||||
|
setAreaTitle(profileData.area.trim() || '');
|
||||||
|
setUserZipcode(profileData.zipcode);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch user profile');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserProfile();
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchJobZones = async () => {
|
const fetchJobZones = async () => {
|
||||||
if (careerSuggestions.length === 0) return;
|
if (careerSuggestions.length === 0) return;
|
||||||
@ -153,23 +223,7 @@ function Dashboard() {
|
|||||||
}
|
}
|
||||||
}, [location.state, navigate]);
|
}, [location.state, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchUserProfile = async () => {
|
|
||||||
const res = await authFetch(`${apiUrl}/user-profile`, {}, handleUnauthorized);
|
|
||||||
if (!res) return;
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const profileData = await res.json();
|
|
||||||
setUserState(profileData.state);
|
|
||||||
setAreaTitle(profileData.area.trim() || '');
|
|
||||||
setUserZipcode(profileData.zipcode);
|
|
||||||
} else {
|
|
||||||
console.error('Failed to fetch user profile');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUserProfile();
|
|
||||||
}, [apiUrl]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@ -307,25 +361,28 @@ function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
const sessionModal = showSessionExpiredModal && (
|
{showSessionExpiredModal && (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal">
|
<div className="modal">
|
||||||
<h3>Session Expired</h3>
|
<h3>Session Expired</h3>
|
||||||
<p>Your session has expired or is invalid.</p>
|
<p>Your session has expired or is invalid.</p>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button className="confirm-btn" onClick={() => navigate("/signin")}>Stay Signed In</button>
|
<button className="confirm-btn" onClick={() => setShowSessionExpiredModal(false)}>
|
||||||
<button className="confirm-btn" onClick={() => {
|
Stay Signed In
|
||||||
localStorage.removeItem("token");
|
</button>
|
||||||
localStorage.removeItem("UserId");
|
<button className="confirm-btn" onClick={() => {
|
||||||
setShowSessionExpiredModal(false);
|
localStorage.removeItem("token");
|
||||||
navigate("/signin");
|
localStorage.removeItem("UserId");
|
||||||
}}>
|
setShowSessionExpiredModal(false);
|
||||||
Sign In Again
|
navigate("/signin");
|
||||||
</button>
|
}}>
|
||||||
</div>
|
Sign In Again
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="dashboard-content">
|
<div className="dashboard-content">
|
||||||
<div className="career-suggestions-container">
|
<div className="career-suggestions-container">
|
||||||
<div
|
<div
|
||||||
|
127
src/components/MilestoneTimeline.js
Normal file
127
src/components/MilestoneTimeline.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
// src/components/MilestoneTimeline.js
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
const MilestoneTimeline = ({ careerPathId, authFetch }) => {
|
||||||
|
const [milestones, setMilestones] = useState({ Career: [], Financial: [], Retirement: [] });
|
||||||
|
const [activeView, setActiveView] = useState('Career');
|
||||||
|
const [newMilestone, setNewMilestone] = useState({ title: '', date: '', progress: 0 });
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingMilestone, setEditingMilestone] = useState(null);
|
||||||
|
|
||||||
|
const fetchMilestones = useCallback(async () => {
|
||||||
|
if (!careerPathId) return;
|
||||||
|
|
||||||
|
const res = await authFetch(`api/premium/milestones`);
|
||||||
|
if (!res) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const categorized = { Career: [], Financial: [], Retirement: [] };
|
||||||
|
|
||||||
|
data.milestones.forEach((m) => {
|
||||||
|
if (m.career_path_id === careerPathId && categorized[m.milestone_type]) {
|
||||||
|
categorized[m.milestone_type].push(m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setMilestones(categorized);
|
||||||
|
}, [careerPathId, authFetch]);
|
||||||
|
|
||||||
|
// ✅ useEffect simply calls the function
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMilestones();
|
||||||
|
}, [fetchMilestones]);
|
||||||
|
|
||||||
|
const saveMilestone = async () => {
|
||||||
|
const url = editingMilestone ? `/api/premium/milestones/${editingMilestone.id}` : `/api/premium/milestones`;
|
||||||
|
const method = editingMilestone ? 'PUT' : 'POST';
|
||||||
|
const payload = {
|
||||||
|
milestone_type: activeView,
|
||||||
|
title: newMilestone.title,
|
||||||
|
description: newMilestone.title,
|
||||||
|
date: newMilestone.date,
|
||||||
|
career_path_id: careerPathId,
|
||||||
|
progress: newMilestone.progress,
|
||||||
|
status: newMilestone.progress === 100 ? 'completed' : 'planned',
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await authFetch(url, { method, body: JSON.stringify(payload) });
|
||||||
|
if (res && res.ok) {
|
||||||
|
fetchMilestones();
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingMilestone(null);
|
||||||
|
setNewMilestone({ title: '', date: '', progress: 0 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate last milestone date properly by combining all arrays
|
||||||
|
const allMilestones = [...milestones.Career, ...milestones.Financial, ...milestones.Retirement];
|
||||||
|
const lastDate = allMilestones.reduce(
|
||||||
|
(latest, m) => (new Date(m.date) > latest ? new Date(m.date) : latest),
|
||||||
|
today
|
||||||
|
);
|
||||||
|
|
||||||
|
const calcPosition = (date) => {
|
||||||
|
const start = today.getTime();
|
||||||
|
const end = lastDate.getTime();
|
||||||
|
const position = ((new Date(date).getTime() - start) / (end - start)) * 100;
|
||||||
|
return Math.min(Math.max(position, 0), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="milestone-timeline">
|
||||||
|
<div className="view-selector">
|
||||||
|
{['Career', 'Financial', 'Retirement'].map((view) => (
|
||||||
|
<button key={view} className={activeView === view ? 'active' : ''} onClick={() => setActiveView(view)}>
|
||||||
|
{view}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="timeline">
|
||||||
|
{milestones[activeView]?.map((m) => (
|
||||||
|
<div key={m.id} className="milestone-entry">
|
||||||
|
<h4>{m.title}</h4>
|
||||||
|
<p>{m.description}</p>
|
||||||
|
<p>Date: {m.date}</p>
|
||||||
|
<p>Progress: {m.progress}%</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={() => setShowForm(true)}>+ New Milestone</button>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="form">
|
||||||
|
<input type="text" placeholder="Title" value={newMilestone.title} onChange={(e) => setNewMilestone({ ...newMilestone, title: 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} onChange={(e) => setNewMilestone({ ...newMilestone, progress: parseInt(e.target.value, 10) })} />
|
||||||
|
<button onClick={saveMilestone}>{editingMilestone ? 'Update' : 'Add'} Milestone</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="timeline-container">
|
||||||
|
<div className="timeline-line" />
|
||||||
|
{milestones[activeView]?.map((m) => (
|
||||||
|
<div key={m.id} className="milestone-post" style={{ left: `${calcPosition(m.date)}%` }} onClick={() => {
|
||||||
|
setEditingMilestone(m);
|
||||||
|
setNewMilestone({ title: m.title, date: m.date, progress: m.progress });
|
||||||
|
setShowForm(true);
|
||||||
|
}}>
|
||||||
|
<div className="milestone-dot" />
|
||||||
|
<div className="milestone-content">
|
||||||
|
<div className="title">{m.title}</div>
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div className="progress" style={{ width: `${m.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="date">{m.date}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MilestoneTimeline;
|
@ -2,607 +2,127 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import CareerSelectDropdown from './CareerSelectDropdown.js';
|
||||||
import CareerSearch from './CareerSearch.js';
|
import CareerSearch from './CareerSearch.js';
|
||||||
|
import MilestoneTimeline from './MilestoneTimeline.js';
|
||||||
|
import AISuggestedMilestones from './AISuggestedMilestones.js';
|
||||||
import './MilestoneTracker.css';
|
import './MilestoneTracker.css';
|
||||||
|
|
||||||
const today = new Date();
|
const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
|
||||||
|
|
||||||
|
|
||||||
const MilestoneTracker = ({ selectedCareer: initialCareer, careerClusters }) => {
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activeView, setActiveView] = useState('Career');
|
|
||||||
const [milestones, setMilestones] = useState({
|
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
|
||||||
Career: [],
|
|
||||||
Financial: [],
|
|
||||||
Retirement: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [careerPathId, setCareerPathId] = useState(null);
|
const [careerPathId, setCareerPathId] = useState(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [existingCareerPaths, setExistingCareerPaths] = useState([]);
|
||||||
const [newMilestone, setNewMilestone] = useState({ title: '', date: '', progress: 0 });
|
|
||||||
const [editingMilestone, setEditingMilestone] = useState(null);
|
|
||||||
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false);
|
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false);
|
||||||
const [selectedCareer, setSelectedCareer] = useState(initialCareer || '');
|
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
|
||||||
const [SelectedSocCode, setSelectedSocCode] = useState('');
|
|
||||||
const [suggestedMilestones, setSuggestedMilestones] = useState([]);
|
|
||||||
const [careerCluster, setCareerCluster] = useState('');
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [careerSubdivision, setCareerSubdivision] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [sessionHandled, setSessionHandled] = useState(false);
|
|
||||||
const [hasHandledCareerPath, setHasHandledCareerPath] = useState(false);
|
|
||||||
const [existingCareerPaths, setExistingCareerPaths] = useState([]); // To store existing career paths
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleUnauthorized = () => {
|
|
||||||
if (!sessionHandled) {
|
|
||||||
setSessionHandled(true);
|
|
||||||
setShowSessionExpiredModal(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const apiURL = process.env.REACT_APP_API_URL;
|
const apiURL = process.env.REACT_APP_API_URL;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchExistingPaths = async () => {
|
|
||||||
const response = await fetch('/api/career-paths'); // Replace with the actual API endpoint
|
|
||||||
const data = await response.json();
|
|
||||||
setExistingCareerPaths(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchExistingPaths();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fromState = location.state?.selectedCareer;
|
|
||||||
|
|
||||||
if (fromState && !hasHandledCareerPath) {
|
|
||||||
setSelectedCareer(fromState);
|
|
||||||
setCareerPathId(fromState.career_path_id);
|
|
||||||
loadMilestonesFromServer(fromState.career_path_id);
|
|
||||||
handleCareerPathDecision(fromState.career_name);
|
|
||||||
setHasHandledCareerPath(true);
|
|
||||||
}
|
|
||||||
}, [location.state]);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadMilestonesFromServer();
|
|
||||||
}, [selectedCareer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
|
|
||||||
authFetch(`${apiURL}/premium/planned-path/latest`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!response) return; // session expired
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
throw new Error('Unauthorized or token expired');
|
|
||||||
} else {
|
|
||||||
throw new Error(`Server error (${response.status})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
return data;
|
|
||||||
})
|
|
||||||
|
|
||||||
.then(data => {
|
|
||||||
if (!location.state?.selectedCareer && data && data.id) {
|
|
||||||
setCareerPathId(data.id);
|
|
||||||
setSelectedCareer({
|
|
||||||
career_name: data.career_name,
|
|
||||||
career_path_id: data.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Could not fetch latest career path:", error);
|
|
||||||
setCareerPathId(null);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const authFetch = async (url, options = {}) => {
|
const authFetch = async (url, options = {}) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem('token');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setShowSessionExpiredModal(true);
|
setShowSessionExpiredModal(true);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
const finalOptions = {
|
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...options.headers },
|
||||||
...(options.headers || {}),
|
});
|
||||||
Authorization: `Bearer ${token}`,
|
if ([401, 403].includes(res.status)) {
|
||||||
},
|
setShowSessionExpiredModal(true);
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, finalOptions);
|
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
setShowSessionExpiredModal(true);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Fetch error:", err);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCareerPathDecision = async (careerName) => {
|
|
||||||
if (hasHandledCareerPath || !careerName || careerName === 'Not Selected') return; // ✅ already processed, do nothing
|
|
||||||
setHasHandledCareerPath(true); // ✅ prevent duplicate handling
|
|
||||||
setLoading(true);
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
|
|
||||||
const response = await authFetch('/api/premium/planned-path', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ career_name: careerName }),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
if (!response) return;
|
|
||||||
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
const fromGettingStarted = location?.state?.fromGettingStarted;
|
|
||||||
|
|
||||||
if (data.action_required === 'reload_or_create') {
|
|
||||||
const decision = window.confirm( `A career path for "${data.title}" already exists.\n\nClick OK to RELOAD the existing path.\nClick Cancel to CREATE a new one.`);
|
|
||||||
|
|
||||||
if (decision) {
|
|
||||||
reloadExistingCareerPath(data.career_path_id);
|
|
||||||
} else {
|
|
||||||
createNewCareerPath(careerName);
|
|
||||||
}
|
|
||||||
} else if (data.action_required === 'new_created') {
|
|
||||||
setCareerPathId(data.career_path_id);
|
|
||||||
}
|
|
||||||
if (fromGettingStarted) {
|
|
||||||
navigate(location.pathname, { replace: true, state: {} }); // clear state
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const reloadExistingCareerPath = (careerPathId) => {
|
|
||||||
console.log('Reloading career path with ID:', careerPathId);
|
|
||||||
setCareerPathId(careerPathId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createNewCareerPath = async (careerName) => {
|
|
||||||
console.log('Creating new career path for:', careerName);
|
|
||||||
const response = await authFetch('/api/premium/planned-path', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ career_name: careerName, career_path_id: uuidv4() }),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
if (!response) return;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('Error creating new career path');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newPath = await response.json();
|
|
||||||
setCareerPathId(newPath.career_path_id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadMilestonesFromServer = async (pathId = careerPathId) => {
|
|
||||||
if (!pathId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await authFetch(`${apiURL}/premium/milestones`);
|
|
||||||
if (!res) return;
|
|
||||||
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
// Organize by type
|
|
||||||
const categorized = {
|
|
||||||
Career: [],
|
|
||||||
Financial: [],
|
|
||||||
Retirement: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
data.milestones.forEach(m => {
|
|
||||||
if (m.career_path_id === pathId && categorized[m.milestone_type]) {
|
|
||||||
categorized[m.milestone_type].push({
|
|
||||||
id: m.id,
|
|
||||||
title: m.description,
|
|
||||||
date: m.date,
|
|
||||||
progress: m.progress || 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setMilestones(categorized);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading milestones:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCareer) {
|
const fetchCareerPaths = async () => {
|
||||||
fetchAISuggestedMilestones(selectedCareer?.career_name);
|
const res = await authFetch(`${apiURL}/premium/planned-path/all`);
|
||||||
}
|
if (!res) return;
|
||||||
}, [selectedCareer]);
|
|
||||||
|
|
||||||
const handleAddMilestone = async () => {
|
|
||||||
if (!careerPathId) {
|
|
||||||
console.error('No career_path_id available for milestone.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const data = await res.json();
|
||||||
try {
|
|
||||||
const url = `${apiURL}/premium/milestones`;
|
|
||||||
const method = editingMilestone !== null ? 'PUT' : 'POST';
|
|
||||||
const payload = {
|
|
||||||
milestone_type: activeView,
|
|
||||||
title: newMilestone.title,
|
|
||||||
description: newMilestone.title,
|
|
||||||
date: newMilestone.date,
|
|
||||||
career_path_id: careerPathId,
|
|
||||||
progress: newMilestone.progress,
|
|
||||||
status: newMilestone.progress === 100 ? 'completed' : 'planned',
|
|
||||||
date_completed: newMilestone.progress === 100 ? newMilestone.date : null,
|
|
||||||
context_snapshot: JSON.stringify(selectedCareer), // pass as stringified JSON
|
|
||||||
salary_increase: activeView === 'Financial' ? newMilestone.salary_increase || null : null
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await authFetch(url, {
|
// Flatten nested array
|
||||||
method,
|
const flatPaths = data.careerPath.flat();
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!response) return;
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Error saving milestone');
|
// Handle duplicates
|
||||||
|
const uniquePaths = Array.from(
|
||||||
setNewMilestone({ title: '', date: '', progress: 0 });
|
new Set(flatPaths.map(cp => cp.career_name))
|
||||||
setShowForm(false);
|
).map(name => flatPaths.find(cp => cp.career_name === name));
|
||||||
setEditingMilestone(null);
|
|
||||||
loadMilestonesFromServer(careerPathId);
|
setExistingCareerPaths(uniquePaths);
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving milestone:', error);
|
const fromPopout = location.state?.selectedCareer;
|
||||||
}
|
if (fromPopout) {
|
||||||
};
|
setSelectedCareer(fromPopout);
|
||||||
|
setCareerPathId(fromPopout.career_path_id);
|
||||||
|
} else if (!selectedCareer) {
|
||||||
|
const latest = await authFetch(`${apiURL}/premium/planned-path/latest`);
|
||||||
|
if (latest) {
|
||||||
|
const latestData = await latest.json();
|
||||||
|
if (latestData?.id) {
|
||||||
|
setSelectedCareer(latestData);
|
||||||
|
setCareerPathId(latestData.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchCareerPaths();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCareerChange = (careerName) => {
|
||||||
const handleEditMilestone = async () => {
|
const match = existingCareerPaths.find(p => p.career_name === careerName);
|
||||||
if (!careerPathId || !editingMilestone) {
|
if (match) {
|
||||||
console.error('Missing career path ID or milestone ID for update.');
|
setSelectedCareer(match);
|
||||||
return;
|
setCareerPathId(match.career_path_id);
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
try {
|
|
||||||
const url = `${apiURL}/premium/milestones/${editingMilestone.id}`;
|
|
||||||
const payload = {
|
|
||||||
milestone_type: activeView,
|
|
||||||
title: newMilestone.title, // ✅ add this
|
|
||||||
description: newMilestone.title,
|
|
||||||
date: newMilestone.date,
|
|
||||||
career_path_id: careerPathId,
|
|
||||||
progress: newMilestone.progress,
|
|
||||||
status: newMilestone.progress === 100 ? 'completed' : 'planned',
|
|
||||||
date_completed: newMilestone.progress === 100 ? newMilestone.date : null,
|
|
||||||
salary_increase: activeView === 'Financial' ? newMilestone.salary_increase || null : null,
|
|
||||||
context_snapshot: JSON.stringify(selectedCareer),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await authFetch(url, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!response) return;
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Error updating milestone');
|
|
||||||
|
|
||||||
setNewMilestone({ title: '', date: '', progress: 0 });
|
|
||||||
setShowForm(false);
|
|
||||||
setEditingMilestone(null);
|
|
||||||
loadMilestonesFromServer(careerPathId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating milestone:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleCareerSelection = async (career, socCode) => {
|
|
||||||
setSelectedCareer(career);
|
|
||||||
setSelectedSocCode(socCode);
|
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${apiURL}/premium/planned-path`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
career_path_id: uuidv4(),
|
|
||||||
career_name: career
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response) return;
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Error saving career path');
|
|
||||||
|
|
||||||
const newPath = await response.json();
|
|
||||||
setCareerPathId(newPath.id); // ✅ Update stored career path ID
|
|
||||||
loadMilestonesFromServer(newPath.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error selecting career:', error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmCareerSelection = async () => {
|
const handleConfirmCareerSelection = async () => {
|
||||||
const token = localStorage.getItem('token');
|
const newId = uuidv4();
|
||||||
const newCareerPath = {
|
const body = { career_path_id: newId, career_name: pendingCareerForModal, start_date: new Date().toISOString().split('T')[0] };
|
||||||
career_path_id: uuidv4(),
|
const res = await authFetch(`${apiURL}/premium/planned-path`, { method: 'POST', body: JSON.stringify(body) });
|
||||||
career_name: selectedCareer,
|
if (!res || !res.ok) return;
|
||||||
soc_code: SelectedSocCode,
|
setSelectedCareer({ career_name: pendingCareerForModal });
|
||||||
start_date: new Date().toISOString().split('T')[0],
|
setCareerPathId(newId);
|
||||||
};
|
setPendingCareerForModal(null);
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authFetch(`${apiURL}/premium/planned-path`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(newCareerPath),
|
|
||||||
});
|
|
||||||
if (!response) return;
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
|
|
||||||
setCareerPathId(newCareerPath.career_path_id);
|
|
||||||
loadMilestonesFromServer(newCareerPath.career_path_id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error confirming career selection:', error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const fetchAISuggestedMilestones = (career) => {
|
|
||||||
console.log(`Fetching AI suggested milestones for: ${career}`);
|
|
||||||
const mockSuggestedMilestones = [
|
|
||||||
{ title: `Entry-Level Certification for ${career}`, date: '2025-06-01', progress: 0 },
|
|
||||||
{ title: `Mid-Level Position in ${career}`, date: '2027-01-01', progress: 0 },
|
|
||||||
{ title: `Senior-Level Mastery in ${career}`, date: '2030-01-01', progress: 0 },
|
|
||||||
];
|
|
||||||
setSuggestedMilestones(mockSuggestedMilestones);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmSuggestedMilestones = async () => {
|
|
||||||
if (!careerPathId) return;
|
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const milestone of suggestedMilestones) {
|
|
||||||
const payload = {
|
|
||||||
milestone_type: activeView,
|
|
||||||
title: milestone.title,
|
|
||||||
description: milestone.title,
|
|
||||||
date: milestone.date,
|
|
||||||
career_path_id: careerPathId,
|
|
||||||
progress: milestone.progress || 0,
|
|
||||||
status: milestone.progress === 100 ? 'completed' : 'planned',
|
|
||||||
date_completed: milestone.progress === 100 ? milestone.date : null,
|
|
||||||
context_snapshot: JSON.stringify(selectedCareer),
|
|
||||||
salary_increase: activeView === 'Financial' ? milestone.salary_increase || null : null
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await authFetch(`${apiURL}/premium/milestones`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!res) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSuggestedMilestones([]);
|
|
||||||
loadMilestonesFromServer(careerPathId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error confirming suggested milestones:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const lastMilestoneDate = () => {
|
|
||||||
const allDates = milestones[activeView].map((m) => new Date(m.date));
|
|
||||||
return allDates.length ? new Date(Math.max(...allDates)) : today;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculatePosition = (date) => {
|
|
||||||
const start = today.getTime();
|
|
||||||
const end = lastMilestoneDate().getTime();
|
|
||||||
return ((new Date(date).getTime() - start) / (end - start)) * 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!careerPathId) {
|
|
||||||
console.warn('Career path not selected yet. Prompting user to choose one.');
|
|
||||||
// Continue rendering UI for selecting career path
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="milestone-tracker">
|
<div className="milestone-tracker">
|
||||||
{showSessionExpiredModal && (
|
{showSessionExpiredModal && (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal">
|
<div className="modal">
|
||||||
<h3>Session Expired</h3>
|
<h3>Session Expired</h3>
|
||||||
<p>Your session has expired or is invalid.</p>
|
<button onClick={() => navigate('/signin')}>Go to Sign In</button>
|
||||||
<div className="modal-actions">
|
</div>
|
||||||
<button
|
|
||||||
className="confirm-btn"
|
|
||||||
onClick={() => {
|
|
||||||
localStorage.removeItem("token");
|
|
||||||
localStorage.removeItem("UserId");
|
|
||||||
navigate("/signin");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Go to Sign In
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="header">
|
|
||||||
<h2>Milestone Tracker</h2>
|
|
||||||
<p>Selected Career: {selectedCareer?.career_name || 'Not Selected'}</p>
|
|
||||||
{loading ? <p>Loading...</p> : <p>Career Path ID: {careerPathId}</p>}
|
|
||||||
<button onClick={() => setShowForm(!showForm)}>+ New Milestone</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{suggestedMilestones.length > 0 && (
|
|
||||||
<div className="suggested-milestones">
|
|
||||||
<h3>AI-Suggested Milestones</h3>
|
|
||||||
<ul>
|
|
||||||
{suggestedMilestones.map((milestone, idx) => (
|
|
||||||
<li key={idx}>{milestone.title} - {milestone.date}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<button onClick={confirmSuggestedMilestones}>Confirm Milestones</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="view-selector">
|
<CareerSelectDropdown
|
||||||
{['Career', 'Financial', 'Retirement'].map((view) => (
|
existingCareerPaths={existingCareerPaths}
|
||||||
<button
|
selectedCareer={selectedCareer?.career_name}
|
||||||
key={view}
|
onChange={handleCareerChange}
|
||||||
className={activeView === view ? 'active' : ''}
|
loading={!existingCareerPaths.length}
|
||||||
onClick={() => setActiveView(view)}
|
|
||||||
>
|
|
||||||
{view}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showForm && careerPathId ? (
|
|
||||||
<div className="form">
|
|
||||||
<input type="text" placeholder="Milestone Title" value={newMilestone.title} onChange={e => setNewMilestone({ ...newMilestone, title: e.target.value })} />
|
|
||||||
<input type="date" value={newMilestone.date} onChange={e => setNewMilestone({ ...newMilestone, date: e.target.value })} />
|
|
||||||
<label>Progress (%)</label>
|
|
||||||
<input type="number" placeholder="Enter progress" value={newMilestone.progress} onChange={e => setNewMilestone({ ...newMilestone, progress: Number(e.target.value) })} />
|
|
||||||
<button onClick={editingMilestone !== null ? handleEditMilestone : handleAddMilestone}>
|
|
||||||
{editingMilestone !== null ? 'Update Milestone' : 'Add Milestone'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{activeView === 'Financial' && (
|
|
||||||
<>
|
|
||||||
<label>Expected Salary Increase ($)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="Enter estimated salary boost"
|
|
||||||
value={newMilestone.salary_increase || ''}
|
|
||||||
onChange={e =>
|
|
||||||
setNewMilestone({ ...newMilestone, salary_increase: parseFloat(e.target.value) })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
showForm && <p style={{ color: 'red' }}>Please select a career before adding milestones.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="timeline-container">
|
<MilestoneTimeline careerPathId={careerPathId} authFetch={authFetch} />
|
||||||
<div className="timeline-line" />
|
|
||||||
{milestones[activeView].map((milestone, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="milestone-post"
|
|
||||||
style={{ left: `${calculatePosition(milestone.date)}%` }}
|
|
||||||
onClick={() => {
|
|
||||||
setEditingMilestone(milestone);
|
|
||||||
setNewMilestone({
|
|
||||||
title: milestone.title,
|
|
||||||
date: milestone.date,
|
|
||||||
progress: milestone.progress
|
|
||||||
});
|
|
||||||
setShowForm(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="milestone-dot" />
|
|
||||||
<div className="milestone-content">
|
|
||||||
<div className="title">{milestone.title}</div>
|
|
||||||
<div className="progress-bar">
|
|
||||||
<div className="progress" style={{ width: `${milestone.progress}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="date">{milestone.date}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="career-search-container minimized">
|
<AISuggestedMilestones career={selectedCareer?.career_name} careerPathId={careerPathId} authFetch={authFetch} />
|
||||||
<p>Not sure about this career path? Choose a different one here.</p>
|
|
||||||
<CareerSearch
|
|
||||||
onSelectCareer={handleCareerSelection}
|
|
||||||
existingCareerPaths={existingCareerPaths}
|
|
||||||
initialCareer={selectedCareer}
|
|
||||||
cluster={careerCluster}
|
|
||||||
subdivision={careerSubdivision}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedCareer && (
|
<CareerSearch
|
||||||
<>
|
onSelectCareer={(careerName) => setPendingCareerForModal(careerName)}
|
||||||
{selectedCareer !== selectedCareer?.career_name && (
|
/>
|
||||||
<button onClick={() => setShowModal(true)} style={{ marginTop: '10px' }}>
|
|
||||||
Confirm Career Selection
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{showModal && (
|
|
||||||
<div className="modal-overlay">
|
|
||||||
<div className="modal">
|
|
||||||
<h3>Start a New Career Path?</h3>
|
|
||||||
<p>
|
|
||||||
You’re about to start a <strong>brand new career path</strong> for:{" "}
|
|
||||||
<em>{selectedCareer?.career_name || selectedCareer}</em>.
|
|
||||||
</p>
|
|
||||||
<p>This will reset your milestone plan. Do you want to continue?</p>
|
|
||||||
|
|
||||||
<div className="modal-actions">
|
{pendingCareerForModal && (
|
||||||
<button onClick={() => setShowModal(false)}>Cancel</button>
|
<button onClick={handleConfirmCareerSelection}>
|
||||||
<button
|
Confirm Career Change to {pendingCareerForModal}
|
||||||
className="confirm-btn"
|
</button>
|
||||||
onClick={() => {
|
)}
|
||||||
handleConfirmCareerSelection();
|
|
||||||
setShowModal(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Yes, Start New Path
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MilestoneTracker;
|
export default MilestoneTracker;
|
@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
export const authFetch = async (url, options = {}, onUnauthorized) => {
|
export const authFetch = async (url, options = {}, onUnauthorized) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
|
console.log("Token:", token); // Log token value
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
console.log("Token is missing, triggering onUnauthorized");
|
||||||
if (typeof onUnauthorized === 'function') onUnauthorized();
|
if (typeof onUnauthorized === 'function') onUnauthorized();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalOptions = {
|
const finalOptions = {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
@ -15,19 +18,21 @@ export const authFetch = async (url, options = {}, onUnauthorized) => {
|
|||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, finalOptions);
|
const res = await fetch(url, finalOptions);
|
||||||
|
|
||||||
|
console.log("Response Status:", res.status); // Log response status
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
console.log("Unauthorized response received, triggering onUnauthorized");
|
||||||
if (typeof onUnauthorized === 'function') onUnauthorized();
|
if (typeof onUnauthorized === 'function') onUnauthorized();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Fetch error:", err);
|
console.error("Fetch error:", err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user