209 lines
7.6 KiB
JavaScript
209 lines
7.6 KiB
JavaScript
// src/components/RetirementPlanner.js
|
||
import React, { useEffect, useState, useCallback } from 'react';
|
||
import authFetch from '../utils/authFetch.js';
|
||
import ScenarioContainer from './ScenarioContainer.js';
|
||
import { Button } from './ui/button.js';
|
||
import RetirementChatBar from './RetirementChatBar.js';
|
||
import ScenarioDiffDrawer from './ScenarioDiffDrawer.js';
|
||
|
||
/* ------------------------------------------------------------------
|
||
* tiny class‑name helper
|
||
* ---------------------------------------------------------------- */
|
||
const cn = (...cls) => cls.filter(Boolean).join(' ');
|
||
|
||
/* ------------------------------------------------------------------
|
||
* responsive helper – “mobile” = < 768px
|
||
* ---------------------------------------------------------------- */
|
||
function useIsMobile () {
|
||
const [mobile, setMobile] = useState(() => window.innerWidth < 768);
|
||
useEffect(() => {
|
||
const handler = () => setMobile(window.innerWidth < 768);
|
||
window.addEventListener('resize', handler);
|
||
return () => window.removeEventListener('resize', handler);
|
||
}, []);
|
||
return mobile;
|
||
}
|
||
|
||
/* ==================================================================
|
||
* RetirementPlanner
|
||
* ================================================================= */
|
||
export default function RetirementPlanner () {
|
||
/* ---------------------------- state ----------------------------- */
|
||
const [loading, setLoading ] = useState(false);
|
||
const [error, setError ] = useState(null);
|
||
const [financialProfile, setFinancialProfile] = useState(null);
|
||
const [scenarios, setScenarios] = useState([]);
|
||
|
||
const [selectedScenario, setSelectedScenario] = useState(null);
|
||
const [chatOpen, setChatOpen] = useState(false); // slide‑in flag
|
||
const [diff, setDiff] = useState(null);
|
||
const [simYearsMap, setSimYearsMap] = useState({});
|
||
const isMobile = useIsMobile();
|
||
|
||
/* ----------------------- data loading -------------------------- */
|
||
const loadAll = useCallback(async () => {
|
||
try {
|
||
setLoading(true); setError(null);
|
||
/* financial profile ------------------------------------------------ */
|
||
const finRes = await authFetch('/api/premium/financial-profile');
|
||
if (!finRes.ok) throw new Error(`Financial profile error (${finRes.status})`);
|
||
const finJson = await finRes.json();
|
||
|
||
/* scenarios -------------------------------------------------------- */
|
||
const scRes = await authFetch('/api/premium/career-profile/all');
|
||
if (!scRes.ok) throw new Error(`Scenario error (${scRes.status})`);
|
||
const scJson = await scRes.json();
|
||
|
||
setFinancialProfile(finJson);
|
||
setScenarios(scJson.careerProfiles || []);
|
||
} catch (e) {
|
||
console.error('RetirementPlanner → loadAll', e);
|
||
setError(e.message || 'Failed to load');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { loadAll(); }, [loadAll]);
|
||
|
||
/* ------------------ scenario CRUD helpers ---------------------- */
|
||
async function handleAddScenario () {
|
||
try {
|
||
const body = {
|
||
career_name : `New Scenario ${new Date().toLocaleDateString()}`,
|
||
status : 'planned',
|
||
start_date : new Date().toISOString().slice(0, 10),
|
||
college_enrollment_status : 'not_enrolled',
|
||
currently_working : 'no'
|
||
};
|
||
const r = await authFetch('/api/premium/career-profile', {
|
||
method : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body : JSON.stringify(body)
|
||
});
|
||
if (!r.ok) throw new Error(r.status);
|
||
await loadAll();
|
||
} catch (e) {
|
||
alert(`Add scenario failed (${e.message})`);
|
||
}
|
||
}
|
||
|
||
async function handleRemoveScenario (id) {
|
||
if (!window.confirm('Delete this scenario?')) return;
|
||
const r = await authFetch(`/api/premium/career-profile/${id}`, { method: 'DELETE' });
|
||
if (!r.ok) return alert(`Delete error (${r.status})`);
|
||
await loadAll();
|
||
}
|
||
|
||
async function handleCloneScenario (src) {
|
||
/* bring over the original long clone implementation here or import
|
||
from a helper if you already abstracted it. Leaving a stub so
|
||
the UI compiles. */
|
||
alert('Clone scenario not wired yet');
|
||
}
|
||
|
||
/* ------------------ chat patch helper -------------------------- */
|
||
const applyPatch = (id, patch) => {
|
||
setScenarios(prev => {
|
||
const base = prev.find(s => s.id === id);
|
||
const next = prev.map(s => (s.id === id ? { ...s, ...patch } : s));
|
||
setDiff({ base, patch });
|
||
return next;
|
||
});
|
||
};
|
||
|
||
/* --------------------------- guards ---------------------------- */
|
||
if (loading) return <p className="p-6">Loading scenarios…</p>;
|
||
if (error) return <p className="p-6 text-red-600">{error}</p>;
|
||
|
||
/* ----------------------- render body --------------------------- */
|
||
const visibleTwo = scenarios.slice(0, 2);
|
||
const baselineId = visibleTwo[0]?.id;
|
||
const baselineYears = simYearsMap[baselineId] ?? null; // renamed
|
||
|
||
return (
|
||
<div className="flex flex-col md:flex-row h-full relative">
|
||
{/* ================= MAIN COLUMN =========================== */}
|
||
<main className="flex-1 p-4 overflow-y-auto">
|
||
{/* desktop add */}
|
||
{!isMobile && (
|
||
<Button onClick={handleAddScenario} className="mb-4">
|
||
+ Add Scenario
|
||
</Button>
|
||
)}
|
||
|
||
<div className="mx-auto max-w-screen-lg grid gap-6 md:grid-cols-2">
|
||
{visibleTwo.map(sc => (
|
||
<ScenarioContainer
|
||
key={sc.id}
|
||
scenario={sc}
|
||
financialProfile={financialProfile}
|
||
baselineYears={baselineYears}
|
||
onClone={handleCloneScenario}
|
||
onRemove={handleRemoveScenario}
|
||
onSelect={() => { setSelectedScenario(sc); if (isMobile) setChatOpen(true); }}
|
||
onSimDone={(id, yrs) => {
|
||
setSimYearsMap(prev => ({ ...prev, [id]: yrs }));
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</main>
|
||
|
||
{/* ================= CHAT RAIL ============================ */}
|
||
<aside
|
||
className={cn(
|
||
'fixed md:static top-0 right-0 h-full bg-white border-l shadow-lg',
|
||
'transition-transform duration-300',
|
||
'w-11/12 max-w-xs md:w-[340px] z-30',
|
||
chatOpen ? 'translate-x-0' : 'translate-x-full md:translate-x-0'
|
||
)}
|
||
>
|
||
{selectedScenario ? (
|
||
<RetirementChatBar
|
||
scenario={selectedScenario}
|
||
financialProfile={financialProfile}
|
||
onScenarioPatch={applyPatch}
|
||
/>
|
||
) : (
|
||
<div className="h-full flex items-center justify-center text-gray-400 text-sm p-4">
|
||
Select a scenario to chat ☝️
|
||
</div>
|
||
)}
|
||
</aside>
|
||
|
||
{/* ================= MOBILE FABS ========================== */}
|
||
{isMobile && (
|
||
<>
|
||
{/* chat toggle */}
|
||
<button
|
||
onClick={() => setChatOpen(o => !o)}
|
||
className="fixed bottom-20 right-4 rounded-full bg-blue-600 p-3 text-white shadow-md z-40"
|
||
aria-label="Toggle chat"
|
||
>
|
||
💬
|
||
</button>
|
||
|
||
{/* add scenario */}
|
||
<button
|
||
onClick={handleAddScenario}
|
||
className="fixed bottom-4 right-4 rounded-full bg-green-600 p-4 text-white text-xl shadow-md z-40"
|
||
aria-label="Add scenario"
|
||
>
|
||
+
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{/* ================= DIFF DRAWER ========================== */}
|
||
{diff && (
|
||
<ScenarioDiffDrawer
|
||
base={diff.base}
|
||
patch={diff.patch}
|
||
onClose={() => setDiff(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|