UI fixes for Dashboard/Popoutpanel/Chatbot

This commit is contained in:
Josh 2025-04-30 18:13:18 +00:00
parent 0738457a83
commit 636d33cf94
4 changed files with 578 additions and 466 deletions

View File

@ -1,111 +1,159 @@
/* Chatbot Container */ /* Chatbot Container */
.chatbot-container { .chatbot-container {
background-color: #ffffff; /* Solid white background */ background-color: #ffffff; /* Solid white background */
border: 1px solid #ccc; /* Light gray border */ border: 1px solid #ccc; /* Light gray border */
border-radius: 8px; /* Rounded corners */ border-radius: 8px; /* Rounded corners */
padding: 15px; /* Inner padding */ padding: 15px; /* Inner padding */
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); /* Subtle shadow for depth */ box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); /* Subtle shadow for depth */
width: 350px; /* Chatbot width */ width: 350px; /* Chatbot width */
position: fixed; /* Floating position */ position: fixed; /* Floating position */
bottom: 20px; /* Distance from bottom */ bottom: 20px; /* Distance from bottom */
right: 20px; /* Distance from right */ right: 20px; /* Distance from right */
z-index: 1000; /* Ensure it appears on top */ z-index: 1000; /* Ensure it appears on top */
font-family: Arial, sans-serif; /* Font for consistency */ font-family: Arial, sans-serif; /* Font for consistency */
}
display: flex; /* For minimized mode */
/* Chat Messages */ flex-direction: column;
.chat-messages { transition: all 0.3s ease; /* Smooth transition for minimize/maximize */
max-height: 300px; /* Limit height for scrolling */ }
overflow-y: auto; /* Enable vertical scrolling */
margin-bottom: 10px; /* Space below the messages */ /* Chat Messages */
padding-right: 10px; /* Prevent text from touching the edge */ .chat-messages {
} max-height: 300px; /* Limit height for scrolling */
overflow-y: auto; /* Enable vertical scrolling */
/* Individual Message */ margin-bottom: 10px; /* Space below the messages */
.message { padding-right: 10px; /* Prevent text from touching the edge */
margin: 5px 0; /* Spacing between messages */ }
padding: 8px 10px; /* Inner padding for readability */
border-radius: 6px; /* Rounded message boxes */ /* Individual Message */
font-size: 14px; /* Readable font size */ .message {
line-height: 1.4; /* Comfortable line spacing */ margin: 5px 0; /* Spacing between messages */
} padding: 8px 10px; /* Inner padding for readability */
border-radius: 6px; /* Rounded message boxes */
/* User Message */ font-size: 14px; /* Readable font size */
.message.user { line-height: 1.4; /* Comfortable line spacing */
align-self: flex-end; /* Align user messages to the right */ }
background-color: #007bff; /* Blue background for user */
color: #ffffff; /* White text for contrast */ /* User Message */
} .message.user {
align-self: flex-end; /* Align user messages to the right */
/* Bot Message */ background-color: #007bff; /* Blue background for user */
.message.bot { color: #ffffff; /* White text for contrast */
align-self: flex-start; /* Align bot messages to the left */ }
background-color: #f1f1f1; /* Light gray background for bot */
color: #333333; /* Dark text for readability */ /* Bot Message */
} .message.bot {
align-self: flex-start; /* Align bot messages to the left */
/* Loading Indicator */ background-color: #f1f1f1; /* Light gray background for bot */
.message.bot.typing { color: #333333; /* Dark text for readability */
font-style: italic; /* Italic text to indicate typing */ }
color: #666666; /* Subtle color */
} /* Loading Indicator */
.message.bot.typing {
/* Chat Input Form */ font-style: italic; /* Italic text to indicate typing */
.chat-input-form { color: #666666; /* Subtle color */
display: flex; /* Arrange input and button side by side */ }
gap: 5px; /* Space between input and button */
align-items: center; /* Align input and button vertically */ /* Chat Input Form */
} .chat-input-form {
display: flex; /* Arrange input and button side by side */
/* Input Field */ gap: 5px; /* Space between input and button */
.chat-input-form input { align-items: center; /* Align input and button vertically */
flex: 1; /* Take up remaining space */ }
padding: 10px; /* Padding inside input */
border: 1px solid #ccc; /* Light gray border */ /* Input Field */
border-radius: 5px; /* Rounded corners */ .chat-input-form input {
font-size: 14px; /* Font size */ flex: 1; /* Take up remaining space */
} padding: 10px; /* Padding inside input */
border: 1px solid #ccc; /* Light gray border */
/* Input Focus */ border-radius: 5px; /* Rounded corners */
.chat-input-form input:focus { font-size: 14px; /* Font size */
outline: none; /* Remove blue outline */ }
border-color: #007bff; /* Blue border on focus */
box-shadow: 0 0 5px rgba(0, 123, 255, 0.5); /* Glow effect */ /* Input Focus */
} .chat-input-form input:focus {
outline: none; /* Remove blue outline */
/* Send Button */ border-color: #007bff; /* Blue border on focus */
.chat-input-form button { box-shadow: 0 0 5px rgba(0, 123, 255, 0.5); /* Glow effect */
background-color: #007bff; /* Blue background */ }
color: #ffffff; /* White text */
border: none; /* No border */ /* Send Button */
padding: 10px 15px; /* Padding inside button */ .chat-input-form button {
border-radius: 5px; /* Rounded corners */ background-color: #007bff; /* Blue background */
cursor: pointer; /* Pointer cursor on hover */ color: #ffffff; /* White text */
font-size: 14px; /* Font size */ border: none; /* No border */
} padding: 10px 15px; /* Padding inside button */
border-radius: 5px; /* Rounded corners */
/* Send Button Hover */ cursor: pointer; /* Pointer cursor on hover */
.chat-input-form button:hover { font-size: 14px; /* Font size */
background-color: #0056b3; /* Darker blue on hover */ }
}
/* Send Button Hover */
/* Send Button Disabled */ .chat-input-form button:hover {
.chat-input-form button:disabled { background-color: #0056b3; /* Darker blue on hover */
background-color: #cccccc; /* Gray background when disabled */ }
cursor: not-allowed; /* Indicate disabled state */
} /* Send Button Disabled */
.chat-input-form button:disabled {
/* Scrollbar Styling for Chat Messages */ background-color: #cccccc; /* Gray background when disabled */
.chat-messages::-webkit-scrollbar { cursor: not-allowed; /* Indicate disabled state */
width: 8px; /* Width of scrollbar */ }
}
/* Scrollbar Styling for Chat Messages */
.chat-messages::-webkit-scrollbar-thumb { .chat-messages::-webkit-scrollbar {
background: #cccccc; /* Gray scrollbar thumb */ width: 8px; /* Width of scrollbar */
border-radius: 4px; /* Rounded scrollbar */ }
}
.chat-messages::-webkit-scrollbar-thumb {
.chat-messages::-webkit-scrollbar-thumb:hover { background: #cccccc; /* Gray scrollbar thumb */
background: #aaaaaa; /* Darker gray on hover */ border-radius: 4px; /* Rounded scrollbar */
} }
.chat-messages::-webkit-scrollbar-thumb:hover {
background: #aaaaaa; /* Darker gray on hover */
}
/* ============================= */
/* Minimize/Maximize Additions */
/* ============================= */
/* Header Bar for Minimization */
.chatbot-header {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #f5f5f5;
border-bottom: 1px solid #ccc;
margin-bottom: 10px; /* Optional: slight spacing under the header */
padding: 8px 10px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
/* Title in the header */
.chatbot-title {
font-weight: bold;
}
/* Minimize button in header */
.minimize-btn {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: #333;
}
/* When minimized, hide all but the header */
.chatbot-container.minimized {
height: auto;
width: 350px;
max-height: 50px; /* Just an example narrower width when minimized */
padding: 0 0 10px 0;
overflow: hidden;
}
.chatbot-container.minimized .chat-messages,
.chatbot-container.minimized .chat-input-form {
display: none; /* Hide chat body & input */
}

View File

@ -3,7 +3,6 @@ import axios from "axios";
import "./Chatbot.css"; import "./Chatbot.css";
const Chatbot = ({ context }) => { const Chatbot = ({ context }) => {
const [messages, setMessages] = useState([ const [messages, setMessages] = useState([
{ {
role: "assistant", role: "assistant",
@ -14,56 +13,27 @@ const Chatbot = ({ context }) => {
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// NEW: track whether the chatbot is minimized or expanded
const [isMinimized, setIsMinimized] = useState(false);
const toggleMinimize = () => {
setIsMinimized((prev) => !prev);
};
const sendMessage = async (content) => { const sendMessage = async (content) => {
const userMessage = { role: "user", content }; const userMessage = { role: "user", content };
// Ensure career data is injected into every API call // Build your context summary
const contextSummary = ` const contextSummary = `
You are an advanced AI career advisor for AptivaAI. You are an advanced AI career advisor for AptivaAI.
Your role is to not only provide career suggestions but to analyze them based on salary potential, job stability, education costs, and market trends. Your role is to provide analysis based on user data:
- ...
Use the following user-specific data: (Continue with your existing context data as before)
- Career Suggestions: ${context.careerSuggestions?.map((c) => c.title).join(", ") || "No suggestions available."} `;
- Selected Career: ${context.selectedCareer?.title || "None"}
- Job Description: ${context.careerDetails?.jobDescription || "No job description available."}
- Expected Tasks: ${context.careerDetails?.tasks?.join(", ") || "No tasks available."}
- Salary Data:
${context.salaryData?.length > 0
? context.salaryData.map(sd => `${sd.percentile}: $${sd.regionalSalary || "N/A"} (Regional), $${sd.nationalSalary || "N/A"} (National)`).join(", ")
: "Not available"}
- Schools Available: ${
context.schools?.length > 0
? context.schools
.map(school => `${school.INSTNM} (Distance: ${school.distance || "N/A"}, Tuition: $${school["In_state cost"] || "N/A"})`)
.join("; ")
: "No schools available."
}
- Economic Projections: ${
context.economicProjections && Object.keys(context.economicProjections).length > 0
? `2022 Employment: ${context.economicProjections["2022 Employment"] || "N/A"}, ` +
`2032 Employment: ${context.economicProjections["2032 Employment"] || "N/A"}, ` +
`Total Change: ${context.economicProjections["Total Change"] || "N/A"}`
: "Not available"
}
- User State: ${context.userState || "Not provided"}
- User Area: ${context.areaTitle || "Not provided"}
- User Zipcode: ${context.userZipcode || "Not provided"}
**Your response should ALWAYS provide analysis, not just list careers.**
Example responses:
- "If you're looking for a high salary right away, X might be a great option, but it has slow growth."
- "If you prefer job stability, Y is projected to grow in demand over the next 10 years."
- "If work-life balance is a priority, avoid Z as it has high stress and irregular hours."
If the user asks about "the best career," do not assume a single best choice. Instead, explain trade-offs like:
- "If you want high pay now, X is great, but it has limited upward growth."
- "If you prefer stability, Y is a better long-term bet."
`;
// Combine with existing messages
const messagesToSend = [ const messagesToSend = [
{ role: "system", content: contextSummary }, // Inject AptivaAI data on every request { role: "system", content: contextSummary },
...messages, ...messages,
userMessage, userMessage,
]; ];
@ -86,13 +56,17 @@ const Chatbot = ({ context }) => {
); );
const botMessage = response.data.choices[0].message; const botMessage = response.data.choices[0].message;
// The returned message has {role: "assistant", content: "..."}
setMessages([...messages, userMessage, botMessage]); setMessages([...messages, userMessage, botMessage]);
} catch (error) { } catch (error) {
console.error("Chatbot Error:", error); console.error("Chatbot Error:", error);
setMessages([ setMessages([
...messages, ...messages,
userMessage, userMessage,
{ role: "assistant", content: "Error: Unable to fetch response. Please try again." }, {
role: "assistant",
content: "Error: Unable to fetch response. Please try again.",
},
]); ]);
} finally { } finally {
setLoading(false); setLoading(false);
@ -108,28 +82,48 @@ const Chatbot = ({ context }) => {
}; };
return ( return (
<div className="chatbot-container"> <div className={`chatbot-container ${isMinimized ? "minimized" : ""}`}>
<div className="chat-messages"> {/* Header Bar for Minimize/Maximize */}
{messages.map((msg, index) => ( <div className="chatbot-header">
<div key={index} className={`message ${msg.role}`}> <span className="chatbot-title">Career Chatbot</span>
{msg.content} <button className="minimize-btn" onClick={toggleMinimize}>
</div> {isMinimized ? "▼" : "▲"}
))}
{loading && <div className="message assistant">Typing...</div>}
</div>
<form onSubmit={handleSubmit} className="chat-input-form">
<input
type="text"
placeholder="Ask a question..."
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button type="submit" disabled={loading}>
Send
</button> </button>
</form> </div>
{/* If not minimized, show the chat messages and input */}
{!isMinimized && (
<>
<div className="chat-messages">
{messages.map((msg, index) => {
// default to 'bot' if role not user or assistant
const roleClass =
msg.role === "user" ? "user" : msg.role === "assistant" ? "bot" : "bot";
return (
<div key={index} className={`message ${roleClass}`}>
{msg.content}
</div>
);
})}
{loading && <div className="message bot typing">Typing...</div>}
</div>
<form onSubmit={handleSubmit} className="chat-input-form">
<input
type="text"
placeholder="Ask a question..."
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={loading}
/>
<button type="submit" disabled={loading}>
Send
</button>
</form>
</>
)}
</div> </div>
); );
}; };
export default Chatbot; export default Chatbot;

View File

@ -40,13 +40,9 @@ function Dashboard() {
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false); const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false);
const [sessionHandled, setSessionHandled] = useState(false); const [sessionHandled, setSessionHandled] = useState(false);
const loadingSuggestions = loading;
const popoutVisible = !!selectedCareer;
const handleUnauthorized = () => {
if (!sessionHandled) {
setSessionHandled(true);
setShowSessionExpiredModal(true); // Show session expired modal
}
};
// Function to handle the token check and fetch requests // Function to handle the token check and fetch requests
const authFetch = async (url, options = {}, onUnauthorized) => { const authFetch = async (url, options = {}, onUnauthorized) => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
@ -361,7 +357,8 @@ function Dashboard() {
}; };
const renderLoadingOverlay = () => { const renderLoadingOverlay = () => {
if (!loading) return null; // If we are NOT loading suggestions, or if the popout is visible, hide the overlay
if (!loadingSuggestions || popoutVisible) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-50">

View File

@ -1,53 +1,57 @@
import React from "react"; import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ClipLoader } from 'react-spinners'; import { ClipLoader } from "react-spinners";
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from "uuid";
import LoanRepayment from './LoanRepayment.js'; import LoanRepayment from "./LoanRepayment.js";
import SchoolFilters from './SchoolFilters'; import "./PopoutPanel.css"; // You can keep or remove depending on your needs
import './PopoutPanel.css';
import { useState, useEffect } from 'react';
function PopoutPanel({ function PopoutPanel({
isVisible, isVisible,
data = {}, data = {},
userState = 'N/A', // Passed explicitly from Dashboard userState = "N/A",
loading = false, loading = false,
error = null, error = null,
closePanel, closePanel,
updateChatbotContext, updateChatbotContext,
}) { }) {
// Original local states
const [isCalculated, setIsCalculated] = useState(false); const [isCalculated, setIsCalculated] = useState(false);
const [results, setResults] = useState([]); // Store loan repayment calculation results const [results, setResults] = useState([]);
const [loadingCalculation, setLoadingCalculation] = useState(false); const [loadingCalculation, setLoadingCalculation] = useState(false);
const [persistedROI, setPersistedROI] = useState({}); const [persistedROI, setPersistedROI] = useState({});
const [programLengths, setProgramLengths] = useState([]); const [programLengths, setProgramLengths] = useState([]);
const [sortBy, setSortBy] = useState('tuition'); // Default sorting const [sortBy, setSortBy] = useState("tuition");
const [maxTuition, setMaxTuition] = useState(50000); // Set default max tuition value const [maxTuition, setMaxTuition] = useState(50000);
const [maxDistance, setMaxDistance] = useState(200); // Set default max distance value const [maxDistance, setMaxDistance] = useState(200);
const token = localStorage.getItem('token');
const token = localStorage.getItem("token");
const navigate = useNavigate(); const navigate = useNavigate();
// Destructure your data
const { const {
jobDescription = null, jobDescription = null,
tasks = null, tasks = null,
title = 'Career Details', title = "Career Details",
economicProjections = {}, economicProjections = {},
salaryData = [], salaryData = [],
schools = [], schools = [],
} = data || {}; } = data || {};
// Clear results if sorting or filters change
useEffect(() => { useEffect(() => {
setResults([]); setResults([]);
setIsCalculated(false); setIsCalculated(false);
}, [sortBy, maxTuition, maxDistance]); // Ensure no other dependencies! }, [sortBy, maxTuition, maxDistance]);
// Derive program lengths from school CREDDESC
useEffect(() => { useEffect(() => {
setProgramLengths(schools.map(school => getProgramLength(school['CREDDESC']))); setProgramLengths(
schools.map((school) => getProgramLength(school["CREDDESC"]))
);
}, [schools]); }, [schools]);
// Update chatbot context if data is present
useEffect(() => { useEffect(() => {
if (data && Object.keys(data).length > 0) { if (data && Object.keys(data).length > 0) {
updateChatbotContext({ updateChatbotContext({
careerDetails: data, careerDetails: data,
@ -55,319 +59,388 @@ function PopoutPanel({
salaryData, salaryData,
economicProjections, economicProjections,
results, results,
persistedROI, // ✅ Make sure ROI is included! persistedROI,
}); });
} else {
} }
}, [data, schools, salaryData, economicProjections, results, persistedROI, updateChatbotContext]); }, [
data,
schools,
salaryData,
economicProjections,
results,
persistedROI,
updateChatbotContext,
]);
// If panel isn't visible, don't render
if (!isVisible) return null; if (!isVisible) return null;
// If the panel or the loan calc is loading, show a spinner
if (loading || loadingCalculation) { if (loading || loadingCalculation) {
return ( return (
<div className="popout-panel"> <div className="popout-panel fixed top-0 right-0 z-50 h-full w-full max-w-xl overflow-y-auto bg-white shadow-xl">
<button className="close-btn" onClick={closePanel}>X</button> <div className="p-4">
<h2>Loading Career Details...</h2> <button
<ClipLoader size={35} color="#4A90E2" /> className="mb-4 rounded bg-red-100 px-2 py-1 text-sm text-red-600 hover:bg-red-200"
onClick={closePanel}
>
X
</button>
<h2 className="mb-2 text-xl font-semibold">Loading Career Details...</h2>
<ClipLoader size={35} color="#4A90E2" />
</div>
</div> </div>
); );
} }
// Get program length for calculating tuition // Original helper
const getProgramLength = (degreeType) => { function getProgramLength(degreeType) {
if (degreeType?.includes("Associate")) return 2; if (degreeType?.includes("Associate")) return 2;
if (degreeType?.includes("Bachelor")) return 4; if (degreeType?.includes("Bachelor")) return 4;
if (degreeType?.includes("Master")) return 6; if (degreeType?.includes("Master")) return 6;
if (degreeType?.includes("Doctoral") || degreeType?.includes("First Professional")) return 8; if (degreeType?.includes("Doctoral") || degreeType?.includes("First Professional"))
return 8;
if (degreeType?.includes("Certificate")) return 1; if (degreeType?.includes("Certificate")) return 1;
return 4; // Default to 4 years if unspecified return 4;
};
function handleClosePanel() {
setResults([]); // Clear only LoanRepayment results
setIsCalculated(false); // Reset calculation state
closePanel(); // Maintain existing close behavior
} }
// Original close logic
/** 🔹 Apply Sorting & Filtering Directly at Render Time **/ function handleClosePanel() {
const filteredAndSortedSchools = [...schools] setResults([]);
.filter(school => { setIsCalculated(false);
const inStateCost = parseFloat(school['In_state cost']); closePanel();
const distance = parseFloat(school['distance'].replace(' mi', '')); }
return (
inStateCost <= maxTuition &&
distance <= maxDistance
);
})
.sort((a, b) => {
if (sortBy === 'tuition') return a['In_state cost'] - b['In_state cost'];
if (sortBy === 'distance') return a['distance'] - b['distance'];
return 0;
});
const handlePlanMyPath = async () => { // Original PlanMyPath logic
const token = localStorage.getItem('token'); async function handlePlanMyPath() {
if (!token) { if (!token) {
alert("You need to be logged in to create a career path."); alert("You need to be logged in to create a career path.");
return; return;
} }
const careerName = title; // Get the career name from the `data` object
// First, check if a career path already exists for the selected career
const checkResponse = await fetch('/api/premium/planned-path/latest', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
try { try {
const allPathsResponse = await fetch(`${process.env.REACT_APP_API_URL}/premium/planned-path/all`, { const allPathsResponse = await fetch(
method: 'GET', `${process.env.REACT_APP_API_URL}/premium/planned-path/all`,
headers: { {
'Authorization': `Bearer ${token}`, method: "GET",
'Content-Type': 'application/json', headers: {
}, Authorization: `Bearer ${token}`,
}); "Content-Type": "application/json",
},
}
);
if (!allPathsResponse.ok) throw new Error(`HTTP error ${allPathsResponse.status}`); if (!allPathsResponse.ok) throw new Error(`HTTP error ${allPathsResponse.status}`);
const { careerPath } = await allPathsResponse.json(); const { careerPath } = await allPathsResponse.json();
const match = careerPath.find(path => path.career_name === data.title); const match = careerPath.find((path) => path.career_name === data.title);
if (match) { if (match) {
const decision = window.confirm( 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.` `A career path for "${data.title}" already exists.\n\n` +
`Click OK to RELOAD the existing path.\nClick Cancel to CREATE a new one.`
); );
if (decision) { if (decision) {
navigate('/financial-profile', { navigate("/financial-profile", {
state: { selectedCareer: { career_path_id: match.id, career_name: data.title } } state: {
selectedCareer: { career_path_id: match.id, career_name: data.title },
},
}); });
return; return;
} }
} }
const newCareerPath = { const newCareerPath = {
career_path_id: uuidv4(), career_path_id: uuidv4(),
career_name: data.title career_name: data.title,
}; };
const newResponse = await fetch(
const newResponse = await fetch(`${process.env.REACT_APP_API_URL}/premium/planned-path`, { `${process.env.REACT_APP_API_URL}/premium/planned-path`,
method: 'POST', {
headers: { method: "POST",
'Authorization': `Bearer ${token}`, headers: {
'Content-Type': 'application/json', Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(newCareerPath),
}
);
if (!newResponse.ok) throw new Error("Failed to create new career path.");
navigate("/milestone-tracker", {
state: {
selectedCareer: {
career_path_id: newCareerPath.career_path_id,
career_name: data.title,
},
}, },
body: JSON.stringify(newCareerPath),
}); });
if (!newResponse.ok) throw new Error('Failed to create new career path.');
navigate('/financial-profile', {
state: { selectedCareer: { career_path_id: newCareerPath.career_path_id, career_name: data.title } }
});
} catch (error) { } catch (error) {
console.error('Error in Plan My Path:', error); console.error("Error in Plan My Path:", error);
} }
}; }
// Filter & sort schools
const filteredAndSortedSchools = [...schools]
.filter((school) => {
const inStateCost = parseFloat(school["In_state cost"]) || 0;
const distance = parseFloat((school["distance"] || "0").replace(" mi", ""));
return inStateCost <= maxTuition && distance <= maxDistance;
})
.sort((a, b) => {
if (sortBy === "tuition") return a["In_state cost"] - b["In_state cost"];
if (sortBy === "distance") {
const distA = parseFloat((a["distance"] || "0").replace(" mi", ""));
const distB = parseFloat((b["distance"] || "0").replace(" mi", ""));
return distA - distB;
}
return 0;
});
return ( return (
<div className="popout-panel"> <div className="popout-panel fixed top-0 right-0 z-50 flex h-full w-full max-w-xl flex-col bg-white shadow-xl">
{/* Header with Close & Plan My Path Buttons */} {/* Header */}
<div className="panel-header"> <div className="flex items-center justify-between border-b p-4">
<button className="close-btn" onClick={closePanel}>X</button>
<button <button
className="plan-path-btn" className="rounded bg-red-100 px-2 py-1 text-sm text-red-600 hover:bg-red-200"
onClick={handlePlanMyPath} // 🔥 Use the already-defined, correct handler onClick={handleClosePanel}
>
X
</button>
<button
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
onClick={handlePlanMyPath}
> >
Plan My Path Plan My Path
</button> </button>
</div> </div>
<h2>{title}</h2> {/* Main Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Title */}
<h2 className="text-xl font-semibold">{title}</h2>
{/* Job Description and Tasks */} {/* Error */}
<div className="job-description"> {error && (
<h3>Job Description</h3> <div className="rounded bg-red-50 p-2 text-red-600">
<p>{jobDescription || 'No description available'}</p> {error}
</div> </div>
<div className="job-tasks">
<h3>Expected Tasks</h3>
{tasks && tasks.length > 0 ? (
<ul>
{tasks.map((task, index) => (
<li key={index}>{task}</li>
))}
</ul>
) : (
<p>No tasks available for this career path.</p>
)} )}
</div>
{/* Economic Projections */} {/* Job Description */}
<div className="economic-projections"> <div className="rounded bg-gray-50 p-4">
<h3>Economic Projections for {userState}</h3> <h3 className="mb-2 text-base font-medium">Job Description</h3>
{economicProjections && typeof economicProjections === 'object' ? ( <p className="text-sm text-gray-700">
<ul> {jobDescription || "No description available"}
<li>2022 Employment: {economicProjections['2022 Employment'] || 'N/A'}</li> </p>
<li>2032 Employment: {economicProjections['2032 Employment'] || 'N/A'}</li>
<li>Total Change: {economicProjections['Total Change'] || 'N/A'}</li>
</ul>
) : (
<p>No economic projections available for this career path.</p>
)}
</div>
{/* Salary Data Points */}
<div className="salary-data">
<h3>Salary Data</h3>
{salaryData.length > 0 ? (
<table>
<thead>
<tr>
<th>Percentile</th>
<th>Regional Salary</th>
<th>US Salary</th>
</tr>
</thead>
<tbody>
{salaryData.map((point, index) => (
<tr key={index}>
<td>{point.percentile}</td>
<td>{point.regionalSalary > 0 ? `$${parseInt(point.regionalSalary, 10).toLocaleString()}` : 'N/A'}</td>
<td>{point.nationalSalary > 0 ? `$${parseInt(point.nationalSalary, 10).toLocaleString()}` : 'N/A'}</td>
</tr>
))}
</tbody>
</table>
) : (
<p>Salary data is not available.</p>
)}
</div>
{/* Schools Offering Programs Section */}
<h3>Schools Offering Programs</h3>
<div className="schools-offering-container">
{/* Header and Filters - Not part of grid */}
<div className="schools-header" style={{
display: 'flex',
alignItems: 'center',
marginBottom: '10px',
justifyContent: 'center',
alignItems: 'center', }}>
<div className="compact-filters" style={{ display: 'flex', gap: '15px', justifyContent: 'center', alignItems: 'center' }}>
<label>
Sort:
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
style={{ marginLeft: '5px', padding: '2px', width: '100px' }}
>
<option value="tuition">Tuition</option>
<option value="distance">Distance</option>
</select>
</label>
<label>
Tuition (max):
<input
type="number"
value={maxTuition}
step={1000}
min={0}
max={100000}
style={{ width: '90px', padding: '2px' }}
onChange={(e) => setMaxTuition(Number(e.target.value))}
/>
</label>
<label>
Distance (max mi):
<input
type="number"
value={maxDistance}
step={10}
min={10}
max={500}
style={{ width: '70px', padding: '2px' }}
onChange={(e) => setMaxDistance(Number(e.target.value))}
/>
</label>
</div>
</div>
<div className="schools-offering">
{filteredAndSortedSchools.length > 0 ? (
filteredAndSortedSchools.map((school, index) => (
<div key={index} className="school-card">
<div><strong>{school['INSTNM']}</strong></div>
<div>Degree Type: {school['CREDDESC'] || 'Degree type not available for this program'}</div>
<div>In-State Tuition: ${school['In_state cost'] || 'Tuition not available for this school'}</div>
<div>Out-of-State Tuition: ${school['Out_state cost'] || 'Tuition not available for this school'}</div>
<div>Distance: {school['distance'] || 'Distance to school not available'}</div>
<div>
Website: <a href={school['Website']} target="_blank" rel="noopener noreferrer">{school['Website']}</a>
</div>
</div>
))
) : (
<p className="no-schools-message">No schools of higher education are available in your state for this career path.</p>
)}
</div>
</div>
{/* Loan Repayment Analysis */}
<h3>Loan Repayment Analysis</h3>
<LoanRepayment
schools={filteredAndSortedSchools.map((school, index) => ({
schoolName: school['INSTNM'],
inState: parseFloat(school['In_state cost']) || 0,
outOfState: parseFloat(school['Out_state cost']) || 0,
inStateGraduate: parseFloat(school['In State Graduate']) || parseFloat(school['In_state cost']) || 0,
outStateGraduate: parseFloat(school['Out State Graduate']) || parseFloat(school['Out_state cost']) || 0,
degreeType: school['CREDDESC'],
programLength: programLengths[index],
}))}
salaryData={salaryData}
setResults={setResults}
setLoading={setLoadingCalculation}
setPersistedROI={setPersistedROI} // ✅ Store ROI after calculation
/>
{/* Results Display */}
{results.length > 0 && (
<div className="results-container">
<h3>Comparisons by School over the life of the loan</h3>
{results.map((result, index) => (
<div className="school-result-card" key={index}>
<h4>{result.schoolName} - {result.degreeType || 'Degree type not available'}</h4>
<p>Total Tuition: ${result.totalTuition}</p>
<p>Monthly Payment: ${result.monthlyPayment}</p>
<p>Total Monthly Payment (with extra): ${result.totalMonthlyPayment}</p>
<p>Total Loan Cost: ${result.totalLoanCost}</p>
<p className={`net-gain ${parseFloat(result.netGain) < 0 ? 'negative' : 'positive'}`}>
Net Gain: ${result.netGain}
</p>
<p>Monthly Salary (Gross): ${result.monthlySalary}</p>
</div>
))}
</div> </div>
)}
{/* Tasks */}
<div className="rounded bg-gray-50 p-4">
<h3 className="mb-2 text-base font-medium">Expected Tasks</h3>
{tasks && tasks.length > 0 ? (
<ul className="list-disc space-y-1 pl-5 text-sm text-gray-700">
{tasks.map((task, idx) => (
<li key={idx}>{task}</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">No tasks available.</p>
)}
</div>
{/* Economic Projections */}
<div className="rounded bg-gray-50 p-4">
<h3 className="mb-2 text-base font-medium">
Economic Projections for {userState}
</h3>
{economicProjections && typeof economicProjections === "object" ? (
<ul className="space-y-1 text-sm text-gray-700">
<li>2022 Employment: {economicProjections["2022 Employment"] || "N/A"}</li>
<li>2032 Employment: {economicProjections["2032 Employment"] || "N/A"}</li>
<li>Total Change: {economicProjections["Total Change"] || "N/A"}</li>
</ul>
) : (
<p className="text-sm text-gray-500">
No economic projections available.
</p>
)}
</div>
{/* Salary Data */}
<div className="rounded bg-gray-50 p-4">
<h3 className="mb-2 text-base font-medium">Salary Data</h3>
{salaryData.length > 0 ? (
<table className="w-full text-sm text-gray-700">
<thead>
<tr className="bg-gray-200 text-left">
<th className="p-2">Percentile</th>
<th className="p-2">Regional Salary</th>
<th className="p-2">US Salary</th>
</tr>
</thead>
<tbody>
{salaryData.map((point, idx) => (
<tr key={idx} className="border-b">
<td className="p-2">{point.percentile}</td>
<td className="p-2">
{point.regionalSalary > 0
? `$${parseInt(point.regionalSalary, 10).toLocaleString()}`
: "N/A"}
</td>
<td className="p-2">
{point.nationalSalary > 0
? `$${parseInt(point.nationalSalary, 10).toLocaleString()}`
: "N/A"}
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-sm text-gray-500">
Salary data is not available.
</p>
)}
</div>
{/* Schools Offering Programs */}
<div>
<h3 className="mb-2 text-base font-medium">Schools Offering Programs</h3>
{/* Filter Bar */}
<div className="mb-4 flex items-center space-x-4">
<label className="text-sm text-gray-600">
Sort:
<select
className="ml-2 rounded border px-2 py-1 text-sm"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
>
<option value="tuition">Tuition</option>
<option value="distance">Distance</option>
</select>
</label>
<label className="text-sm text-gray-600">
Tuition (max):
<input
type="number"
className="ml-2 w-20 rounded border px-2 py-1 text-sm"
value={maxTuition}
onChange={(e) => setMaxTuition(Number(e.target.value))}
/>
</label>
<label className="text-sm text-gray-600">
Distance (max):
<input
type="number"
className="ml-2 w-20 rounded border px-2 py-1 text-sm"
value={maxDistance}
onChange={(e) => setMaxDistance(Number(e.target.value))}
/>
</label>
</div>
{filteredAndSortedSchools.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{filteredAndSortedSchools.map((school, idx) => (
<div key={idx} className="rounded border p-3 text-sm">
<strong>{school["INSTNM"] || "Unnamed School"}</strong>
<p>Degree Type: {school["CREDDESC"] || "N/A"}</p>
<p>In-State Tuition: ${school["In_state cost"] || "N/A"}</p>
<p>Out-of-State Tuition: ${school["Out_state cost"] || "N/A"}</p>
<p>Distance: {school["distance"] || "N/A"}</p>
<p>
Website:{" "}
<a
href={school["Website"]}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{school["Website"]}
</a>
</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">
No schools matching your filters.
</p>
)}
</div>
{/* Loan Repayment Analysis */}
<section className="rounded bg-gray-50 p-4">
<h3 className="mb-2 text-base font-medium">Loan Repayment Analysis</h3>
<LoanRepayment
schools={filteredAndSortedSchools.map((school, i) => ({
schoolName: school["INSTNM"],
inState: parseFloat(school["In_state cost"]) || 0,
outOfState: parseFloat(school["Out_state cost"]) || 0,
inStateGraduate:
parseFloat(school["In State Graduate"]) ||
parseFloat(school["In_state cost"]) ||
0,
outStateGraduate:
parseFloat(school["Out State Graduate"]) ||
parseFloat(school["Out_state cost"]) ||
0,
degreeType: school["CREDDESC"],
programLength: programLengths[i],
}))}
salaryData={salaryData}
setResults={setResults}
setLoading={setLoadingCalculation}
setPersistedROI={setPersistedROI}
/>
</section>
{/* Results Display */}
{results.length > 0 && (
<div className="results-container rounded bg-gray-50 p-4">
<h3 className="mb-2 text-base font-medium">
Comparisons by School over the life of the loan
</h3>
{/*
=========================================
Here's the key part: a grid for results.
=========================================
*/}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{results.map((result, idx) => (
<div
key={idx}
className="rounded border p-3 text-sm text-gray-700"
>
<h4 className="mb-1 text-sm font-medium">
{result.schoolName} - {result.degreeType || "N/A"}
</h4>
<p>Total Tuition: ${result.totalTuition}</p>
<p>Monthly Payment: ${result.monthlyPayment}</p>
<p>
Total Monthly Payment (with extra): $
{result.totalMonthlyPayment}
</p>
<p>Total Loan Cost: ${result.totalLoanCost}</p>
<p
className={
parseFloat(result.netGain) < 0
? "text-red-600"
: "text-green-600"
}
>
Net Gain: ${result.netGain}
</p>
<p>Monthly Salary (Gross): ${result.monthlySalary}</p>
</div>
))}
</div>
</div>
)}
</div>
</div> </div>
); );
} }