471 lines
16 KiB
JavaScript
471 lines
16 KiB
JavaScript
import React, { useState, useEffect } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { ClipLoader } from "react-spinners";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import LoanRepayment from "./LoanRepayment.js";
|
|
import "./PopoutPanel.css"; // You can keep or remove depending on your needs
|
|
|
|
function PopoutPanel({
|
|
isVisible,
|
|
data = {},
|
|
userState = "N/A",
|
|
loading = false,
|
|
error = null,
|
|
closePanel,
|
|
updateChatbotContext,
|
|
}) {
|
|
// Original local states
|
|
const [isCalculated, setIsCalculated] = useState(false);
|
|
const [results, setResults] = useState([]);
|
|
const [loadingCalculation, setLoadingCalculation] = useState(false);
|
|
const [persistedROI, setPersistedROI] = useState({});
|
|
const [programLengths, setProgramLengths] = useState([]);
|
|
const [sortBy, setSortBy] = useState("tuition");
|
|
const [maxTuition, setMaxTuition] = useState(50000);
|
|
const [maxDistance, setMaxDistance] = useState(200);
|
|
|
|
const token = localStorage.getItem("token");
|
|
const navigate = useNavigate();
|
|
|
|
// Destructure your data
|
|
const {
|
|
jobDescription = null,
|
|
tasks = null,
|
|
title = "Career Details",
|
|
economicProjections = {},
|
|
salaryData = [],
|
|
schools = [],
|
|
} = data || {};
|
|
|
|
// Clear results if sorting or filters change
|
|
useEffect(() => {
|
|
setResults([]);
|
|
setIsCalculated(false);
|
|
}, [sortBy, maxTuition, maxDistance]);
|
|
|
|
// Derive program lengths from school CREDDESC
|
|
useEffect(() => {
|
|
setProgramLengths(
|
|
schools.map((school) => getProgramLength(school["CREDDESC"]))
|
|
);
|
|
}, [schools]);
|
|
|
|
// Update chatbot context if data is present
|
|
useEffect(() => {
|
|
if (data && Object.keys(data).length > 0) {
|
|
updateChatbotContext({
|
|
careerDetails: data,
|
|
schools,
|
|
salaryData,
|
|
economicProjections,
|
|
results,
|
|
persistedROI,
|
|
});
|
|
}
|
|
}, [
|
|
data,
|
|
schools,
|
|
salaryData,
|
|
economicProjections,
|
|
results,
|
|
persistedROI,
|
|
updateChatbotContext,
|
|
]);
|
|
|
|
// If panel isn't visible, don't render
|
|
if (!isVisible) return null;
|
|
|
|
// If the panel or the loan calc is loading, show a spinner
|
|
if (loading || loadingCalculation) {
|
|
return (
|
|
<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">
|
|
<div className="p-4">
|
|
<button
|
|
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>
|
|
);
|
|
}
|
|
|
|
// Original helper
|
|
function getProgramLength(degreeType) {
|
|
if (degreeType?.includes("Associate")) return 2;
|
|
if (degreeType?.includes("Bachelor")) return 4;
|
|
if (degreeType?.includes("Master")) return 6;
|
|
if (degreeType?.includes("Doctoral") || degreeType?.includes("First Professional"))
|
|
return 8;
|
|
if (degreeType?.includes("Certificate")) return 1;
|
|
return 4;
|
|
}
|
|
|
|
// Original close logic
|
|
function handleClosePanel() {
|
|
setResults([]);
|
|
setIsCalculated(false);
|
|
closePanel();
|
|
}
|
|
|
|
async function handlePlanMyPath() {
|
|
if (!token) {
|
|
alert("You need to be logged in to create a career path.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 1) Fetch existing career profiles (a.k.a. "careerPaths")
|
|
const allPathsResponse = await fetch(
|
|
`${process.env.REACT_APP_API_URL}/premium/career-profile/all`,
|
|
{
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!allPathsResponse.ok) {
|
|
throw new Error(`HTTP error ${allPathsResponse.status}`);
|
|
}
|
|
|
|
// The server returns { careerPaths: [...] }
|
|
const { careerPaths } = await allPathsResponse.json();
|
|
|
|
// 2) Check if there's already a career path with the same name
|
|
const match = careerPaths.find((cp) => cp.career_name === data.title);
|
|
|
|
if (match) {
|
|
// If a path already exists for this career, confirm with the user
|
|
const decision = window.confirm(
|
|
`A career path (scenario) for "${data.title}" already exists.\n\n` +
|
|
`Click OK to RELOAD the existing path.\nClick Cancel to CREATE a new one.`
|
|
);
|
|
if (decision) {
|
|
// Reload existing path → go to Paywall
|
|
navigate("/paywall", {
|
|
state: {
|
|
selectedCareer: {
|
|
career_path_id: match.id, // 'id' is the primary key from the DB
|
|
career_name: data.title,
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 3) Otherwise, create a new career profile using POST /premium/career-profile
|
|
const newResponse = await fetch(
|
|
`${process.env.REACT_APP_API_URL}/premium/career-profile`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
// The server expects at least career_name
|
|
career_name: data.title,
|
|
// Optionally pass scenario_title, start_date, etc.
|
|
}),
|
|
}
|
|
);
|
|
|
|
if (!newResponse.ok) {
|
|
throw new Error("Failed to create new career path.");
|
|
}
|
|
|
|
// The server returns something like { message: 'Career profile upserted.', career_path_id: 'xxx-xxx' }
|
|
const result = await newResponse.json();
|
|
const newlyCreatedId = result?.career_path_id;
|
|
|
|
// 4) Navigate to /paywall, passing the newly created career_path_id
|
|
navigate("/paywall", {
|
|
state: {
|
|
selectedCareer: {
|
|
career_path_id: newlyCreatedId,
|
|
career_name: data.title,
|
|
},
|
|
},
|
|
});
|
|
} catch (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 (
|
|
<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 */}
|
|
<div className="flex items-center justify-between border-b p-4">
|
|
<button
|
|
className="rounded bg-red-100 px-2 py-1 text-sm text-red-600 hover:bg-red-200"
|
|
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
|
|
</button>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
|
{/* Title */}
|
|
<h2 className="text-xl font-semibold">{title}</h2>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="rounded bg-red-50 p-2 text-red-600">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Job Description */}
|
|
<div className="rounded bg-gray-50 p-4">
|
|
<h3 className="mb-2 text-base font-medium">Job Description</h3>
|
|
<p className="text-sm text-gray-700">
|
|
{jobDescription || "No description available"}
|
|
</p>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
export default PopoutPanel;
|