MilestoneTracker.js refactor

This commit is contained in:
Josh 2025-03-28 14:05:17 +00:00
parent dd1d6bec88
commit 20214f9069
11 changed files with 445 additions and 747 deletions

0
MilestoneTimeline.js Normal file
View File

View File

@ -82,13 +82,19 @@ app.get('/api/premium/planned-path/all', authenticatePremiumUser, async (req, re
// Save a new planned path
app.post('/api/premium/planned-path', authenticatePremiumUser, async (req, res) => {
const { career_name } = req.body;
let { career_name } = req.body;
if (!career_name) {
return res.status(400).json({ error: 'Career name is required.' });
}
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
const existingCareerPath = await db.get(
`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) {
// Return the existing path — do NOT define or reuse newCareerPathId
return res.status(200).json({
message: 'Career path already exists. Would you like to reload it or create a new one?',
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();
await db.run(
`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({
message: 'Career path saved.',
career_path_id: newCareerPathId,
action_required: 'new_created' // Action flag for newly created path
action_required: 'new_created'
});
} catch (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
app.post('/api/premium/milestones', authenticatePremiumUser, async (req, res) => {
const {

View 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;

View File

@ -1,160 +1,74 @@
import React, { useState, useEffect } from "react";
import { Input } from "./ui/input.js"; // Assuming Input is a basic text input component
const CareerSearch = ({ onSelectCareer, existingCareerPaths }) => {
const [careerClusters, setCareerClusters] = useState({});
const [selectedCluster, setSelectedCluster] = useState("");
const [selectedSubdivision, setSelectedSubdivision] = useState("");
const [selectedCareer, setSelectedCareer] = useState("");
const [careerSearch, setCareerSearch] = useState("");
import React, { useEffect, useState } from 'react';
import { Input } from './ui/input.js';
const CareerSearch = ({ setPendingCareerForModal }) => {
const [careers, setCareers] = useState([]);
const [searchInput, setSearchInput] = useState('');
useEffect(() => {
const fetchCareerClusters = async () => {
const fetchCareerTitles = async () => {
try {
const response = await fetch('/career_clusters.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) {
console.error("Error fetching career clusters:", error);
console.error("Error fetching or processing career_clusters.json:", error);
}
};
fetchCareerClusters();
fetchCareerTitles();
}, []);
useEffect(() => {
if (selectedCareer && careerClusters) {
for (const cluster in careerClusters) {
for (const subdivision in careerClusters[cluster]) {
if (careerClusters[cluster][subdivision].some(job => job.title === selectedCareer)) {
setSelectedCluster(cluster);
setSelectedSubdivision(subdivision);
return;
}
}
}
const handleConfirmCareer = () => {
if (careers.includes(searchInput)) {
setPendingCareerForModal(searchInput);
} else {
alert("Please select a valid career from the suggestions.");
}
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 (
<div>
{/* 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>
<h3>Search for Career</h3>
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Start typing a career..."
list="career-titles"
/>
<datalist id="career-titles">
{careers.map((career, index) => (
<option key={index} value={career} />
))}
</datalist>
{/* 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 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>
)}
<button onClick={handleConfirmCareer}>
Confirm New Career
</button>
</div>
);
};

View 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;

View File

@ -41,7 +41,6 @@ export function CareerSuggestions({ careerSuggestions = [], userState, areaTitle
updateProgress(); // ✅ Update progress on success
return response.data;
} catch (error) {
console.warn(`⚠️ Error fetching ${url}:`, error.response?.status);
updateProgress(); // ✅ Update progress even if failed
return null;
}
@ -61,7 +60,6 @@ export function CareerSuggestions({ careerSuggestions = [], userState, areaTitle
}).catch((error) => {
updateProgress();
if (error.response?.status === 404) {
console.warn(`⚠️ Salary data missing for ${career.title} (${career.code})`);
return null;
}
return error.response;
@ -74,12 +72,10 @@ export function CareerSuggestions({ careerSuggestions = [], userState, areaTitle
const isSalaryMissing = salaryResponse === null || salaryResponse === undefined;
const isLimitedData = isCipMissing || isJobDetailsMissing || isEconomicMissing || isSalaryMissing;
if (isLimitedData) console.log(`⚠️ Setting limitedData for ${career.title} (${career.code})`);
return { ...career, limitedData: isLimitedData };
} catch (error) {
console.error(`Error checking API response for ${career.title}:`, error);
return { ...career, limitedData: true };
}
});

View File

@ -9,7 +9,6 @@ import MilestoneTracker from './MilestoneTracker.js'
import './Dashboard.css';
import Chatbot from "./Chatbot.js";
import { Bar } from 'react-chartjs-2';
import { authFetch } from '../utils/authFetch.js';
import { fetchSchools } from '../utils/apiUtils.js';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
@ -40,14 +39,67 @@ function Dashboard() {
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false);
const [sessionHandled, setSessionHandled] = useState(false);
const handleUnauthorized = () => {
if (!sessionHandled) {
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 = {
'1': 'Little or No Preparation',
'2': 'Some Preparation Needed',
@ -64,6 +116,24 @@ function Dashboard() {
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(() => {
const fetchJobZones = async () => {
if (careerSuggestions.length === 0) return;
@ -153,23 +223,7 @@ function Dashboard() {
}
}, [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(() => {
if (
@ -307,25 +361,28 @@ function Dashboard() {
return (
<div className="dashboard">
const sessionModal = showSessionExpiredModal && (
<div className="modal-overlay">
<div className="modal">
<h3>Session Expired</h3>
<p>Your session has expired or is invalid.</p>
<div className="modal-actions">
<button className="confirm-btn" onClick={() => navigate("/signin")}>Stay Signed In</button>
<button className="confirm-btn" onClick={() => {
localStorage.removeItem("token");
localStorage.removeItem("UserId");
setShowSessionExpiredModal(false);
navigate("/signin");
}}>
Sign In Again
</button>
</div>
{showSessionExpiredModal && (
<div className="modal-overlay">
<div className="modal">
<h3>Session Expired</h3>
<p>Your session has expired or is invalid.</p>
<div className="modal-actions">
<button className="confirm-btn" onClick={() => setShowSessionExpiredModal(false)}>
Stay Signed In
</button>
<button className="confirm-btn" onClick={() => {
localStorage.removeItem("token");
localStorage.removeItem("UserId");
setShowSessionExpiredModal(false);
navigate("/signin");
}}>
Sign In Again
</button>
</div>
</div>
);
</div>
)}
<div className="dashboard-content">
<div className="career-suggestions-container">
<div

View 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;

View File

@ -2,605 +2,125 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { v4 as uuidv4 } from 'uuid';
import CareerSelectDropdown from './CareerSelectDropdown.js';
import CareerSearch from './CareerSearch.js';
import MilestoneTimeline from './MilestoneTimeline.js';
import AISuggestedMilestones from './AISuggestedMilestones.js';
import './MilestoneTracker.css';
const today = new Date();
const MilestoneTracker = ({ selectedCareer: initialCareer, careerClusters }) => {
const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
const location = useLocation();
const navigate = useNavigate();
const [activeView, setActiveView] = useState('Career');
const [milestones, setMilestones] = useState({
Career: [],
Financial: [],
Retirement: [],
});
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
const [careerPathId, setCareerPathId] = useState(null);
const [showForm, setShowForm] = useState(false);
const [newMilestone, setNewMilestone] = useState({ title: '', date: '', progress: 0 });
const [editingMilestone, setEditingMilestone] = useState(null);
const [existingCareerPaths, setExistingCareerPaths] = useState([]);
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false);
const [selectedCareer, setSelectedCareer] = useState(initialCareer || '');
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 [pendingCareerForModal, setPendingCareerForModal] = useState(null);
const handleUnauthorized = () => {
if (!sessionHandled) {
setSessionHandled(true);
setShowSessionExpiredModal(true);
}
};
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 token = localStorage.getItem("token");
const token = localStorage.getItem('token');
if (!token) {
setShowSessionExpiredModal(true);
return null;
}
const finalOptions = {
const res = await fetch(url, {
...options,
headers: {
...(options.headers || {}),
Authorization: `Bearer ${token}`,
},
};
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);
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...options.headers },
});
if ([401, 403].includes(res.status)) {
setShowSessionExpiredModal(true);
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;
useEffect(() => {
const fetchCareerPaths = async () => {
const res = await authFetch(`${apiURL}/premium/planned-path/all`);
if (!res) return;
const data = await res.json();
// Organize by type
const categorized = {
Career: [],
Financial: [],
Retirement: [],
};
// Flatten nested array
const flatPaths = data.careerPath.flat();
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,
});
// Handle duplicates
const uniquePaths = Array.from(
new Set(flatPaths.map(cp => cp.career_name))
).map(name => flatPaths.find(cp => cp.career_name === name));
setExistingCareerPaths(uniquePaths);
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();
}, []);
setMilestones(categorized);
} catch (error) {
console.error('Error loading milestones:', error);
}
};
useEffect(() => {
if (selectedCareer) {
fetchAISuggestedMilestones(selectedCareer?.career_name);
}
}, [selectedCareer]);
const handleAddMilestone = async () => {
if (!careerPathId) {
console.error('No career_path_id available for milestone.');
return;
}
const token = localStorage.getItem('token');
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, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response) return;
if (!response.ok) throw new Error('Error saving milestone');
setNewMilestone({ title: '', date: '', progress: 0 });
setShowForm(false);
setEditingMilestone(null);
loadMilestonesFromServer(careerPathId);
} catch (error) {
console.error('Error saving milestone:', error);
}
};
const handleEditMilestone = async () => {
if (!careerPathId || !editingMilestone) {
console.error('Missing career path ID or milestone ID for update.');
return;
}
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 handleCareerChange = (careerName) => {
const match = existingCareerPaths.find(p => p.career_name === careerName);
if (match) {
setSelectedCareer(match);
setCareerPathId(match.career_path_id);
}
};
const handleConfirmCareerSelection = async () => {
const token = localStorage.getItem('token');
const newCareerPath = {
career_path_id: uuidv4(),
career_name: selectedCareer,
soc_code: SelectedSocCode,
start_date: new Date().toISOString().split('T')[0],
};
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 newId = uuidv4();
const body = { career_path_id: newId, career_name: pendingCareerForModal, start_date: new Date().toISOString().split('T')[0] };
const res = await authFetch(`${apiURL}/premium/planned-path`, { method: 'POST', body: JSON.stringify(body) });
if (!res || !res.ok) return;
setSelectedCareer({ career_name: pendingCareerForModal });
setCareerPathId(newId);
setPendingCareerForModal(null);
};
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 (
<div className="milestone-tracker">
{showSessionExpiredModal && (
<div className="modal-overlay">
<div className="modal">
<h3>Session Expired</h3>
<p>Your session has expired or is invalid.</p>
<div className="modal-actions">
<button
className="confirm-btn"
onClick={() => {
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 className="modal-overlay">
<div className="modal">
<h3>Session Expired</h3>
<button onClick={() => navigate('/signin')}>Go to Sign In</button>
</div>
</div>
)}
<div className="view-selector">
{['Career', 'Financial', 'Retirement'].map((view) => (
<button
key={view}
className={activeView === view ? 'active' : ''}
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) })
}
<CareerSelectDropdown
existingCareerPaths={existingCareerPaths}
selectedCareer={selectedCareer?.career_name}
onChange={handleCareerChange}
loading={!existingCareerPaths.length}
/>
</>
)}
</div>
) : (
showForm && <p style={{ color: 'red' }}>Please select a career before adding milestones.</p>
)}
<div className="timeline-container">
<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>
<MilestoneTimeline careerPathId={careerPathId} authFetch={authFetch} />
<div className="career-search-container minimized">
<p>Not sure about this career path? Choose a different one here.</p>
<CareerSearch
onSelectCareer={handleCareerSelection}
existingCareerPaths={existingCareerPaths}
initialCareer={selectedCareer}
cluster={careerCluster}
subdivision={careerSubdivision}
/>
<AISuggestedMilestones career={selectedCareer?.career_name} careerPathId={careerPathId} authFetch={authFetch} />
{selectedCareer && (
<>
{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>
Youre 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>
<CareerSearch
onSelectCareer={(careerName) => setPendingCareerForModal(careerName)}
/>
<div className="modal-actions">
<button onClick={() => setShowModal(false)}>Cancel</button>
<button
className="confirm-btn"
onClick={() => {
handleConfirmCareerSelection();
setShowModal(false);
}}
>
Yes, Start New Path
</button>
</div>
</div>
</div>
)}
</>
)}
</div>
{pendingCareerForModal && (
<button onClick={handleConfirmCareerSelection}>
Confirm Career Change to {pendingCareerForModal}
</button>
)}
</div>
);
};

View File

@ -3,7 +3,10 @@
export const authFetch = async (url, options = {}, onUnauthorized) => {
const token = localStorage.getItem("token");
console.log("Token:", token); // Log token value
if (!token) {
console.log("Token is missing, triggering onUnauthorized");
if (typeof onUnauthorized === 'function') onUnauthorized();
return null;
}
@ -19,7 +22,10 @@ export const authFetch = async (url, options = {}, onUnauthorized) => {
try {
const res = await fetch(url, finalOptions);
console.log("Response Status:", res.status); // Log response status
if (res.status === 401 || res.status === 403) {
console.log("Unauthorized response received, triggering onUnauthorized");
if (typeof onUnauthorized === 'function') onUnauthorized();
return null;
}
@ -29,5 +35,4 @@ export const authFetch = async (url, options = {}, onUnauthorized) => {
console.error("Fetch error:", err);
return null;
}
};
};

Binary file not shown.