dev1/src/components/MilestoneTimeline.js

741 lines
24 KiB
JavaScript

// src/components/MilestoneTimeline.js
import React, { useEffect, useState, useCallback } from 'react';
const today = new Date();
export default function MilestoneTimeline({
careerPathId,
authFetch,
activeView,
setActiveView,
onMilestoneUpdated
}) {
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
// "new or edit" milestone form data
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 tasks
const [showTaskForm, setShowTaskForm] = useState(null);
const [newTask, setNewTask] = useState({ title: '', description: '', due_date: '' });
// The copy wizard
const [scenarios, setScenarios] = useState([]);
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
// ------------------------------------------------------------------
// 1) HELPER FUNCTIONS (defined above usage)
// ------------------------------------------------------------------
// Insert a new blank impact
function addNewImpact() {
setNewMilestone((prev) => ({
...prev,
impacts: [
...prev.impacts,
{
impact_type: 'ONE_TIME',
direction: 'subtract',
amount: 0,
start_date: '',
end_date: ''
}
]
}));
}
// Remove an impact from newMilestone.impacts
function removeImpact(idx) {
setNewMilestone((prev) => {
const newImpacts = [...prev.impacts];
const removed = newImpacts[idx];
if (removed && removed.id) {
// queue for DB DELETE
setImpactsToDelete((old) => [...old, removed.id]);
}
newImpacts.splice(idx, 1);
return { ...prev, impacts: newImpacts };
});
}
// Update a specific impact property
function updateImpact(idx, field, value) {
setNewMilestone((prev) => {
const newImpacts = [...prev.impacts];
newImpacts[idx] = { ...newImpacts[idx], [field]: value };
return { ...prev, impacts: newImpacts };
});
}
// ------------------------------------------------------------------
// 2) fetchMilestones => local state
// ------------------------------------------------------------------
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 field in response:', data);
return;
}
const categorized = { Career: [], Financial: [] };
data.milestones.forEach((m) => {
if (categorized[m.milestone_type]) {
categorized[m.milestone_type].push(m);
} 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 scenarios for 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,
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 (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());
}
}
}
}
// Optionally re-fetch or update local
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) Add Task
// ------------------------------------------------------------------
async function addTask(milestoneId) {
try {
const taskPayload = {
milestone_id: milestoneId,
title: newTask.title,
description: newTask.description,
due_date: newTask.due_date
};
console.log('Creating new task:', taskPayload);
const res = await authFetch('/api/premium/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskPayload)
});
if (!res.ok) {
const errorData = await res.json();
console.error('Failed to create task:', errorData);
alert(errorData.error || 'Error creating task');
return;
}
const createdTask = await res.json();
console.log('Task created:', createdTask);
// Re-fetch so the timeline shows the new task
await fetchMilestones();
setNewTask({ title: '', description: '', due_date: '' });
setShowTaskForm(null);
} catch (err) {
console.error('Error adding task:', err);
}
}
// ------------------------------------------------------------------
// 7) Copy Wizard => now with brute force refresh
// ------------------------------------------------------------------
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');
// Brute force page refresh
window.location.reload();
onClose();
} catch (err) {
console.error('Error copying milestone:', err);
}
}
return (
<div className="modal-backdrop">
<div className="modal-container">
<h3>Copy Milestone to Other Scenarios</h3>
<p>Milestone: <strong>{milestone.title}</strong></p>
{scenarios.map((s) => (
<div key={s.id}>
<label>
<input
type="checkbox"
checked={selectedScenarios.includes(s.id)}
onChange={() => toggleScenario(s.id)}
/>
{s.career_name}
</label>
</div>
))}
<div style={{ marginTop: '1rem' }}>
<button onClick={onClose} style={{ marginRight: '0.5rem' }}>Cancel</button>
<button onClick={handleCopy}>Copy</button>
</div>
</div>
</div>
);
}
// ------------------------------------------------------------------
// 8) handleDelete => also brute force refresh
// ------------------------------------------------------------------
async function handleDeleteMilestone(m) {
if (m.is_universal === 1) {
const userChoice = window.confirm(
'This milestone is universal. OK => remove from ALL scenarios, Cancel => remove only 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);
}
// done => brute force
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 single milestone:', delRes.status);
return;
}
} catch (err) {
console.error('Error removing milestone from scenario:', err);
}
}
// ------------------------------------------------------------------
// 9) Render the timeline
// ------------------------------------------------------------------
const allMilestonesCombined = [...milestones.Career, ...milestones.Financial];
const lastDate = allMilestonesCombined.reduce((latest, m) => {
const d = new Date(m.date);
return d > latest ? d : latest;
}, today);
function calcPosition(dateString) {
const start = today.getTime();
const end = lastDate.getTime();
const dateVal = new Date(dateString).getTime();
if (end === start) return 0;
const ratio = (dateVal - start) / (end - start);
return Math.min(Math.max(ratio * 100, 0), 100);
}
return (
<div className="milestone-timeline">
<div className="view-selector">
{['Career', 'Financial'].map((view) => (
<button
key={view}
className={activeView === view ? 'active' : ''}
onClick={() => setActiveView(view)}
>
{view}
</button>
))}
</div>
<button
onClick={() => {
if (showForm) {
// Cancel form
setShowForm(false);
setEditingMilestone(null);
setNewMilestone({
title: '',
description: '',
date: '',
progress: 0,
newSalary: '',
impacts: [],
isUniversal: 0
});
setImpactsToDelete([]);
} else {
setShowForm(true);
}
}}
>
{showForm ? 'Cancel' : '+ New Milestone'}
</button>
{showForm && (
<div className="form">
{/* Title / Desc / Date / Progress */}
<input
type="text"
placeholder="Title"
value={newMilestone.title}
onChange={(e) => setNewMilestone({ ...newMilestone, title: e.target.value })}
/>
<input
type="text"
placeholder="Description"
value={newMilestone.description}
onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })}
/>
<input
type="date"
placeholder="Milestone Date"
value={newMilestone.date}
onChange={(e) =>
setNewMilestone((prev) => ({ ...prev, date: e.target.value }))
}
/>
<input
type="number"
placeholder="Progress (%)"
value={newMilestone.progress === 0 ? '' : newMilestone.progress}
onChange={(e) => {
const val = e.target.value === '' ? 0 : parseInt(e.target.value, 10);
setNewMilestone((prev) => ({ ...prev, progress: val }));
}}
/>
{/* If Financial => newSalary + impacts */}
{activeView === 'Financial' && (
<div>
<input
type="number"
placeholder="Full New Salary (e.g., 70000)"
value={newMilestone.newSalary}
onChange={(e) => setNewMilestone({ ...newMilestone, newSalary: e.target.value })}
/>
<p>Enter the full new salary after the milestone occurs.</p>
<div className="impacts-section border p-2 mt-3">
<h4>Financial Impacts</h4>
{newMilestone.impacts.map((imp, idx) => (
<div key={idx} className="impact-item border p-2 my-2">
{imp.id && <p className="text-xs text-gray-500">Impact ID: {imp.id}</p>}
<div>
<label>Type: </label>
<select
value={imp.impact_type}
onChange={(e) => updateImpact(idx, 'impact_type', e.target.value)}
>
<option value="ONE_TIME">One-Time</option>
<option value="MONTHLY">Monthly</option>
</select>
</div>
<div>
<label>Direction: </label>
<select
value={imp.direction}
onChange={(e) => updateImpact(idx, 'direction', e.target.value)}
>
<option value="add">Add (Income)</option>
<option value="subtract">Subtract (Expense)</option>
</select>
</div>
<div>
<label>Amount: </label>
<input
type="number"
value={imp.amount}
onChange={(e) => updateImpact(idx, 'amount', e.target.value)}
/>
</div>
<div>
<label>Start Date:</label>
<input
type="date"
value={imp.start_date || ''}
onChange={(e) => updateImpact(idx, 'start_date', e.target.value)}
/>
</div>
{imp.impact_type === 'MONTHLY' && (
<div>
<label>End Date (blank if indefinite): </label>
<input
type="date"
value={imp.end_date || ''}
onChange={(e) => updateImpact(idx, 'end_date', e.target.value || '')}
/>
</div>
)}
<button
className="text-red-500 mt-2"
onClick={() => removeImpact(idx)}
>
Remove Impact
</button>
</div>
))}
<button onClick={addNewImpact} className="bg-gray-200 px-2 py-1 mt-2">
+ Add Impact
</button>
</div>
</div>
)}
{/* universal checkbox */}
<div style={{ marginTop: '1rem' }}>
<label>
<input
type="checkbox"
checked={!!newMilestone.isUniversal}
onChange={(e) =>
setNewMilestone((prev) => ({
...prev,
isUniversal: e.target.checked ? 1 : 0
}))
}
/>
{' '}Apply this milestone to all scenarios?
</label>
</div>
<button onClick={saveMilestone} style={{ marginTop: '1rem' }}>
{editingMilestone ? 'Update' : 'Add'} Milestone
</button>
</div>
)}
{/* Actual timeline */}
<div className="milestone-timeline-container">
<div className="milestone-timeline-line" />
{milestones[activeView].map((m) => {
const leftPos = calcPosition(m.date);
return (
<div
key={m.id}
className="milestone-timeline-post"
style={{ left: `${leftPos}%` }}
>
<div
className="milestone-timeline-dot"
onClick={() => handleEditMilestone(m)}
/>
<div className="milestone-content">
<div className="title">{m.title}</div>
{m.description && <p>{m.description}</p>}
<div className="progress-bar">
<div className="progress" style={{ width: `${m.progress}%` }} />
</div>
<div className="date">{m.date}</div>
{m.tasks && m.tasks.length > 0 && (
<ul>
{m.tasks.map((t) => (
<li key={t.id}>
<strong>{t.title}</strong>
{t.description ? ` - ${t.description}` : ''}
{t.due_date ? ` (Due: ${t.due_date})` : ''}
</li>
))}
</ul>
)}
<button
onClick={() => {
setShowTaskForm(showTaskForm === m.id ? null : m.id);
setNewTask({ title: '', description: '', due_date: '' });
}}
>
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
</button>
<div style={{ marginTop: '0.5rem' }}>
<button onClick={() => handleEditMilestone(m)}>Edit</button>
<button
style={{ marginLeft: '0.5rem' }}
onClick={() => setCopyWizardMilestone(m)}
>
Copy
</button>
<button
style={{ marginLeft: '0.5rem', color: 'red' }}
onClick={() => handleDeleteMilestone(m)}
>
Delete
</button>
</div>
{showTaskForm === m.id && (
<div className="task-form" style={{ marginTop: '0.5rem' }}>
<input
type="text"
placeholder="Task Title"
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
/>
<input
type="text"
placeholder="Task Description"
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
/>
<input
type="date"
value={newTask.due_date}
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
/>
<button onClick={() => addTask(m.id)}>Save Task</button>
</div>
)}
</div>
</div>
);
})}
</div>
{copyWizardMilestone && (
<CopyMilestoneWizard
milestone={copyWizardMilestone}
scenarios={scenarios}
onClose={() => setCopyWizardMilestone(null)}
authFetch={authFetch}
/>
)}
</div>
);
}