// src/components/MilestoneTimeline.js import React, { useEffect, useState, useCallback } from 'react'; import { Button } from './ui/button.js'; /** * Renders a simple vertical list of milestones for the given careerPathId. * Also includes Task CRUD (create/edit/delete) for each milestone, * plus a small "copy milestone" wizard, "financial impacts" form, etc. */ export default function MilestoneTimeline({ careerPathId, authFetch, activeView, // 'Career' or 'Financial' setActiveView, // optional, if you need to switch between views onMilestoneUpdated // callback after saving/deleting a milestone }) { const [milestones, setMilestones] = useState({ Career: [], Financial: [] }); // For CREATE/EDIT milestone const [newMilestone, setNewMilestone] = useState({ title: '', description: '', date: '', progress: 0, newSalary: '', impacts: [], isUniversal: 0 }); const [impactsToDelete, setImpactsToDelete] = useState([]); const [showForm, setShowForm] = useState(false); const [editingMilestone, setEditingMilestone] = useState(null); // For CREATE/EDIT tasks const [showTaskForm, setShowTaskForm] = useState(null); // which milestone ID is showing the form const [newTask, setNewTask] = useState({ id: null, title: '', description: '', due_date: '' }); // For the "Copy to other scenarios" wizard const [scenarios, setScenarios] = useState([]); const [copyWizardMilestone, setCopyWizardMilestone] = useState(null); // ------------------------------------------------------------------ // 1) Financial Impacts sub-form helpers // ------------------------------------------------------------------ function addNewImpact() { setNewMilestone((prev) => ({ ...prev, impacts: [ ...prev.impacts, { impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' } ] })); } function removeImpact(idx) { setNewMilestone((prev) => { const newImpacts = [...prev.impacts]; const removed = newImpacts[idx]; if (removed && removed.id) { setImpactsToDelete((old) => [...old, removed.id]); } newImpacts.splice(idx, 1); return { ...prev, impacts: newImpacts }; }); } function updateImpact(idx, field, value) { setNewMilestone((prev) => { const newImpacts = [...prev.impacts]; newImpacts[idx] = { ...newImpacts[idx], [field]: value }; return { ...prev, impacts: newImpacts }; }); } // ------------------------------------------------------------------ // 2) Fetch milestones => store in "milestones[Career]" / "milestones[Financial]" // ------------------------------------------------------------------ const fetchMilestones = useCallback(async () => { if (!careerPathId) return; try { const res = await authFetch(`/api/premium/milestones?careerPathId=${careerPathId}`); if (!res.ok) { console.error('Failed to fetch milestones. Status:', res.status); return; } const data = await res.json(); if (!data.milestones) { console.warn('No milestones in response:', data); return; } // Separate them by type const categorized = { Career: [], Financial: [] }; data.milestones.forEach((m) => { if (categorized[m.milestone_type]) { categorized[m.milestone_type].push(m); } else { // If there's a random type, log or store somewhere else console.warn(`Unknown milestone type: ${m.milestone_type}`); } }); setMilestones(categorized); } catch (err) { console.error('Failed to fetch milestones:', err); } }, [careerPathId, authFetch]); useEffect(() => { fetchMilestones(); }, [fetchMilestones]); // ------------------------------------------------------------------ // 3) Load all scenarios for the copy wizard // ------------------------------------------------------------------ useEffect(() => { async function loadScenarios() { try { const res = await authFetch('/api/premium/career-profile/all'); if (res.ok) { const data = await res.json(); setScenarios(data.careerPaths || []); } } catch (err) { console.error('Error loading scenarios for copy wizard:', err); } } loadScenarios(); }, [authFetch]); // ------------------------------------------------------------------ // 4) Edit Milestone => fetch impacts // ------------------------------------------------------------------ async function handleEditMilestone(m) { try { setImpactsToDelete([]); const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`); if (!res.ok) { console.error('Failed to fetch milestone impacts. Status:', res.status); return; } const data = await res.json(); const fetchedImpacts = data.impacts || []; setNewMilestone({ title: m.title || '', description: m.description || '', date: m.date || '', progress: m.progress || 0, newSalary: m.new_salary || '', impacts: fetchedImpacts.map((imp) => ({ id: imp.id, impact_type: imp.impact_type || 'ONE_TIME', direction: imp.direction || 'subtract', amount: imp.amount || 0, start_date: imp.start_date || '', end_date: imp.end_date || '' })), isUniversal: m.is_universal ? 1 : 0 }); setEditingMilestone(m); setShowForm(true); } catch (err) { console.error('Error in handleEditMilestone:', err); } } // ------------------------------------------------------------------ // 5) Save (create/update) => handle impacts // ------------------------------------------------------------------ async function saveMilestone() { if (!activeView) return; const url = editingMilestone ? `/api/premium/milestones/${editingMilestone.id}` : `/api/premium/milestone`; const method = editingMilestone ? 'PUT' : 'POST'; const payload = { milestone_type: activeView, // 'Career' or 'Financial' title: newMilestone.title, description: newMilestone.description, date: newMilestone.date, career_path_id: careerPathId, progress: newMilestone.progress, status: newMilestone.progress >= 100 ? 'completed' : 'planned', new_salary: activeView === 'Financial' && newMilestone.newSalary ? parseFloat(newMilestone.newSalary) : null, is_universal: newMilestone.isUniversal || 0 }; try { const res = await authFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) { const errData = await res.json(); console.error('Failed to save milestone:', errData); alert(errData.error || 'Error saving milestone'); return; } const savedMilestone = await res.json(); console.log('Milestone saved/updated:', savedMilestone); // If it's a "Financial" milestone => handle impacts if (activeView === 'Financial') { // 1) Delete old impacts for (const impactId of impactsToDelete) { if (impactId) { const delRes = await authFetch(`/api/premium/milestone-impacts/${impactId}`, { method: 'DELETE' }); if (!delRes.ok) { console.error('Failed deleting old impact', impactId, await delRes.text()); } } } // 2) Insert/Update new impacts for (let i = 0; i < newMilestone.impacts.length; i++) { const imp = newMilestone.impacts[i]; if (imp.id) { // existing => PUT const putPayload = { milestone_id: savedMilestone.id, impact_type: imp.impact_type, direction: imp.direction, amount: parseFloat(imp.amount) || 0, start_date: imp.start_date || null, end_date: imp.end_date || null }; const impRes = await authFetch(`/api/premium/milestone-impacts/${imp.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(putPayload) }); if (!impRes.ok) { const errImp = await impRes.json(); console.error('Failed updating impact:', errImp); } } else { // new => POST const postPayload = { milestone_id: savedMilestone.id, impact_type: imp.impact_type, direction: imp.direction, amount: parseFloat(imp.amount) || 0, start_date: imp.start_date || null, end_date: imp.end_date || null }; const impRes = await authFetch('/api/premium/milestone-impacts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(postPayload) }); if (!impRes.ok) { console.error('Failed creating new impact:', await impRes.text()); } } } } // Re-fetch milestones await fetchMilestones(); // reset form setShowForm(false); setEditingMilestone(null); setNewMilestone({ title: '', description: '', date: '', progress: 0, newSalary: '', impacts: [], isUniversal: 0 }); setImpactsToDelete([]); if (onMilestoneUpdated) { onMilestoneUpdated(); } } catch (err) { console.error('Error saving milestone:', err); } } // ------------------------------------------------------------------ // 6) TASK CRUD // ------------------------------------------------------------------ // A) “Add Task” button => sets newTask for a new item function handleAddTask(milestoneId) { setShowTaskForm(milestoneId); setNewTask({ id: null, title: '', description: '', due_date: '' }); } // B) “Edit Task” => fill newTask with the existing fields function handleEditTask(milestoneId, task) { setShowTaskForm(milestoneId); setNewTask({ id: task.id, title: task.title, description: task.description || '', due_date: task.due_date || '' }); } // C) Save (create or update) task async function saveTask(milestoneId) { if (!newTask.title.trim()) { alert('Task needs a title'); return; } const payload = { milestone_id: milestoneId, title: newTask.title, description: newTask.description, due_date: newTask.due_date }; let url = '/api/premium/tasks'; let method = 'POST'; if (newTask.id) { // existing => PUT url = `/api/premium/tasks/${newTask.id}`; method = 'PUT'; } try { const res = await authFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) { const errData = await res.json().catch(() => ({})); console.error('Failed to save task:', errData); alert(errData.error || 'Error saving task'); return; } // re-fetch await fetchMilestones(); // reset setShowTaskForm(null); setNewTask({ id: null, title: '', description: '', due_date: '' }); } catch (err) { console.error('Error saving task:', err); } } // D) Delete an existing task async function deleteTask(taskId) { if (!taskId) return; try { const res = await authFetch(`/api/premium/tasks/${taskId}`, { method: 'DELETE' }); if (!res.ok) { const errData = await res.json().catch(() => ({})); console.error('Failed to delete task:', errData); alert(errData.error || 'Error deleting task'); return; } await fetchMilestones(); } catch (err) { console.error('Error deleting task:', err); } } // ------------------------------------------------------------------ // 7) Copy Wizard for universal/cross-scenario // ------------------------------------------------------------------ function CopyMilestoneWizard({ milestone, scenarios, onClose, authFetch }) { const [selectedScenarios, setSelectedScenarios] = useState([]); if (!milestone) return null; function toggleScenario(scenarioId) { setSelectedScenarios((prev) => prev.includes(scenarioId) ? prev.filter((id) => id !== scenarioId) : [...prev, scenarioId] ); } async function handleCopy() { try { const res = await authFetch('/api/premium/milestone/copy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ milestoneId: milestone.id, scenarioIds: selectedScenarios }) }); if (!res.ok) throw new Error('Failed to copy milestone'); window.location.reload(); onClose(); } catch (err) { console.error('Error copying milestone:', err); } } return (

Copy Milestone to Other Scenarios

Milestone: {milestone.title}

{scenarios.map((s) => (
))}
); } // ------------------------------------------------------------------ // 8) Delete Milestone // ------------------------------------------------------------------ async function handleDeleteMilestone(m) { if (m.is_universal === 1) { const userChoice = window.confirm( 'This milestone is universal. OK => remove from ALL scenarios, Cancel => only remove from this scenario.' ); if (userChoice) { // delete from all try { const delAll = await authFetch(`/api/premium/milestones/${m.id}/all`, { method: 'DELETE' }); if (!delAll.ok) { console.error('Failed removing universal from all. Status:', delAll.status); return; } } catch (err) { console.error('Error deleting universal milestone from all:', err); } } else { // remove from single scenario await deleteSingleMilestone(m); return; } } else { // normal => single scenario await deleteSingleMilestone(m); } window.location.reload(); } async function deleteSingleMilestone(m) { try { const delRes = await authFetch(`/api/premium/milestones/${m.id}`, { method: 'DELETE' }); if (!delRes.ok) { console.error('Failed to delete milestone:', delRes.status); } } catch (err) { console.error('Error removing milestone from scenario:', err); } } // ------------------------------------------------------------------ // 9) Render // ------------------------------------------------------------------ // Combine "Career" + "Financial" if you want them in a single list: return (
{/* “+ New Milestone” toggles the same form as before */} {/* If showForm => the create/edit milestone sub-form */} {showForm && (

{editingMilestone ? 'Edit Milestone' : 'New Milestone'}

setNewMilestone({ ...newMilestone, title: e.target.value })} /> setNewMilestone({ ...newMilestone, description: e.target.value })} /> setNewMilestone({ ...newMilestone, date: e.target.value })} /> setNewMilestone((prev) => ({ ...prev, progress: parseInt(e.target.value || '0', 10) })) } /> {/* If “Financial” => show impacts */} {activeView === 'Financial' && (
Financial Impacts
{newMilestone.impacts.map((imp, idx) => (
{imp.id &&

ID: {imp.id}

}
updateImpact(idx, 'amount', e.target.value)} />
updateImpact(idx, 'start_date', e.target.value)} />
{imp.impact_type === 'MONTHLY' && (
updateImpact(idx, 'end_date', e.target.value)} />
)}
))}
)}
)} {/* Render the (Career + Financial) milestones in a simple vertical list */} {Object.keys(milestones).map((typeKey) => milestones[typeKey].map((m) => { const tasks = m.tasks || []; return (
{m.title}
{m.description &&

{m.description}

}

Date: {m.date} — Progress: {m.progress}%

{/* tasks list */} {tasks.length > 0 && ( )} {/* Add or edit a task */} {/* If this is the milestone whose tasks we're editing => show the form */} {showTaskForm === m.id && (
{newTask.id ? 'Edit Task' : 'New Task'}
setNewTask((prev) => ({ ...prev, title: e.target.value })) } /> setNewTask((prev) => ({ ...prev, description: e.target.value })) } /> setNewTask((prev) => ({ ...prev, due_date: e.target.value })) } />
)}
); }) )} {/* Copy wizard if open */} {copyWizardMilestone && ( setCopyWizardMilestone(null)} authFetch={authFetch} /> )}
); }