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 */
}
/* Chat Messages */ display: flex; /* For minimized mode */
.chat-messages { flex-direction: column;
max-height: 300px; /* Limit height for scrolling */ transition: all 0.3s ease; /* Smooth transition for minimize/maximize */
overflow-y: auto; /* Enable vertical scrolling */ }
margin-bottom: 10px; /* Space below the messages */
padding-right: 10px; /* Prevent text from touching the edge */
}
/* Individual Message */ /* Chat Messages */
.message { .chat-messages {
margin: 5px 0; /* Spacing between messages */ max-height: 300px; /* Limit height for scrolling */
padding: 8px 10px; /* Inner padding for readability */ overflow-y: auto; /* Enable vertical scrolling */
border-radius: 6px; /* Rounded message boxes */ margin-bottom: 10px; /* Space below the messages */
font-size: 14px; /* Readable font size */ padding-right: 10px; /* Prevent text from touching the edge */
line-height: 1.4; /* Comfortable line spacing */ }
}
/* User Message */ /* Individual Message */
.message.user { .message {
align-self: flex-end; /* Align user messages to the right */ margin: 5px 0; /* Spacing between messages */
background-color: #007bff; /* Blue background for user */ padding: 8px 10px; /* Inner padding for readability */
color: #ffffff; /* White text for contrast */ border-radius: 6px; /* Rounded message boxes */
} font-size: 14px; /* Readable font size */
line-height: 1.4; /* Comfortable line spacing */
}
/* Bot Message */ /* User Message */
.message.bot { .message.user {
align-self: flex-start; /* Align bot messages to the left */ align-self: flex-end; /* Align user messages to the right */
background-color: #f1f1f1; /* Light gray background for bot */ background-color: #007bff; /* Blue background for user */
color: #333333; /* Dark text for readability */ color: #ffffff; /* White text for contrast */
} }
/* Loading Indicator */ /* Bot Message */
.message.bot.typing { .message.bot {
font-style: italic; /* Italic text to indicate typing */ align-self: flex-start; /* Align bot messages to the left */
color: #666666; /* Subtle color */ background-color: #f1f1f1; /* Light gray background for bot */
} color: #333333; /* Dark text for readability */
}
/* Chat Input Form */ /* Loading Indicator */
.chat-input-form { .message.bot.typing {
display: flex; /* Arrange input and button side by side */ font-style: italic; /* Italic text to indicate typing */
gap: 5px; /* Space between input and button */ color: #666666; /* Subtle color */
align-items: center; /* Align input and button vertically */ }
}
/* Input Field */ /* Chat Input Form */
.chat-input-form input { .chat-input-form {
flex: 1; /* Take up remaining space */ display: flex; /* Arrange input and button side by side */
padding: 10px; /* Padding inside input */ gap: 5px; /* Space between input and button */
border: 1px solid #ccc; /* Light gray border */ align-items: center; /* Align input and button vertically */
border-radius: 5px; /* Rounded corners */ }
font-size: 14px; /* Font size */
}
/* Input Focus */ /* Input Field */
.chat-input-form input:focus { .chat-input-form input {
outline: none; /* Remove blue outline */ flex: 1; /* Take up remaining space */
border-color: #007bff; /* Blue border on focus */ padding: 10px; /* Padding inside input */
box-shadow: 0 0 5px rgba(0, 123, 255, 0.5); /* Glow effect */ border: 1px solid #ccc; /* Light gray border */
} border-radius: 5px; /* Rounded corners */
font-size: 14px; /* Font size */
}
/* Send Button */ /* Input Focus */
.chat-input-form button { .chat-input-form input:focus {
background-color: #007bff; /* Blue background */ outline: none; /* Remove blue outline */
color: #ffffff; /* White text */ border-color: #007bff; /* Blue border on focus */
border: none; /* No border */ box-shadow: 0 0 5px rgba(0, 123, 255, 0.5); /* Glow effect */
padding: 10px 15px; /* Padding inside button */ }
border-radius: 5px; /* Rounded corners */
cursor: pointer; /* Pointer cursor on hover */
font-size: 14px; /* Font size */
}
/* Send Button Hover */ /* Send Button */
.chat-input-form button:hover { .chat-input-form button {
background-color: #0056b3; /* Darker blue on hover */ background-color: #007bff; /* Blue background */
} color: #ffffff; /* White text */
border: none; /* No border */
padding: 10px 15px; /* Padding inside button */
border-radius: 5px; /* Rounded corners */
cursor: pointer; /* Pointer cursor on hover */
font-size: 14px; /* Font size */
}
/* Send Button Disabled */ /* Send Button Hover */
.chat-input-form button:disabled { .chat-input-form button:hover {
background-color: #cccccc; /* Gray background when disabled */ background-color: #0056b3; /* Darker blue on hover */
cursor: not-allowed; /* Indicate disabled state */ }
}
/* Scrollbar Styling for Chat Messages */ /* Send Button Disabled */
.chat-messages::-webkit-scrollbar { .chat-input-form button:disabled {
width: 8px; /* Width of scrollbar */ background-color: #cccccc; /* Gray background when disabled */
} cursor: not-allowed; /* Indicate disabled state */
}
.chat-messages::-webkit-scrollbar-thumb { /* Scrollbar Styling for Chat Messages */
background: #cccccc; /* Gray scrollbar thumb */ .chat-messages::-webkit-scrollbar {
border-radius: 4px; /* Rounded scrollbar */ width: 8px; /* Width of scrollbar */
} }
.chat-messages::-webkit-scrollbar-thumb:hover { .chat-messages::-webkit-scrollbar-thumb {
background: #aaaaaa; /* Darker gray on hover */ background: #cccccc; /* Gray scrollbar thumb */
} 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,26 +82,46 @@ 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>
); );
}; };

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,102 +59,90 @@ 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
function handleClosePanel() {
setResults([]);
setIsCalculated(false);
closePanel();
}
/** 🔹 Apply Sorting & Filtering Directly at Render Time **/ // Original PlanMyPath logic
const filteredAndSortedSchools = [...schools] async function handlePlanMyPath() {
.filter(school => {
const inStateCost = parseFloat(school['In_state cost']);
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 () => {
const token = localStorage.getItem('token');
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;
} }
@ -158,216 +150,297 @@ function PopoutPanel({
const newCareerPath = { const newCareerPath = {
career_path_id: uuidv4(), career_path_id: uuidv4(),
career_name: data.title career_name: data.title,
}; };
const newResponse = await fetch(
`${process.env.REACT_APP_API_URL}/premium/planned-path`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(newCareerPath),
}
);
if (!newResponse.ok) throw new Error("Failed to create new career path.");
const newResponse = await fetch(`${process.env.REACT_APP_API_URL}/premium/planned-path`, { navigate("/milestone-tracker", {
method: 'POST', state: {
headers: { selectedCareer: {
'Authorization': `Bearer ${token}`, career_path_id: newCareerPath.career_path_id,
'Content-Type': 'application/json', 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>
); );
} }