// src/components/MilestoneEditModal.js import React, { useState, useEffect, useCallback } from 'react'; import { Button } from './ui/button.js'; import InfoTooltip from './ui/infoTooltip.js'; import authFetch from '../utils/authFetch.js'; import MilestoneCopyWizard from './MilestoneCopyWizard.js'; /* Helpers ---------------------------------------------------- */ const toSqlDate = (v) => (v ? String(v).slice(0, 10) : ''); const toDateOrNull = (v) => (v ? String(v).slice(0, 10) : null); export default function MilestoneEditModal({ careerProfileId, milestones: incomingMils = [], milestone: selectedMilestone, fetchMilestones, onClose, }) { /* ───────────────── state */ const [milestones, setMilestones] = useState(incomingMils); const [editingId, setEditingId] = useState(null); // draft maps milestoneId -> {title, description, date, progress, newSalary, impacts[], tasks[], isUniversal} const [draft, setDraft] = useState({}); // Track original IDs so we can detect deletions on save const [originalImpactIdsMap, setOriginalImpactIdsMap] = useState({}); const [originalTaskIdsMap, setOriginalTaskIdsMap] = useState({}); const [addingNew, setAddingNew] = useState(false); const [newMilestone, setNewMilestone] = useState({ title: '', description: '', date: '', progress: 0, newSalary: '', impacts: [], tasks: [], isUniversal: 0, }); const [copyWizardMilestone, setCopyWizardMilestone] = useState(null); const [isSavingEdit, setIsSavingEdit] = useState(false); const [isSavingNew, setIsSavingNew] = useState(false); /* keep list in sync with prop */ useEffect(() => setMilestones(incomingMils), [incomingMils]); /* --------------------------------------------------------- * * Load impacts + tasks for one milestone then open it * --------------------------------------------------------- */ const openEditor = useCallback( async (m) => { setEditingId(m.id); // Fetch impacts const resImp = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`); const jsonImp = resImp.ok ? await resImp.json() : { impacts: [] }; const imps = (jsonImp.impacts || []).map((i) => ({ id: i.id, impact_type: i.impact_type || 'ONE_TIME', // 'salary' | 'ONE_TIME' | 'MONTHLY' direction: i.impact_type === 'salary' ? 'add' : (i.direction || 'subtract'), amount: i.amount || 0, start_date: toSqlDate(i.start_date), end_date: toSqlDate(i.end_date), })); // Fetch tasks const resTasks = await authFetch(`/api/premium/tasks?milestone_id=${m.id}`); const jsonTasks = resTasks.ok ? await resTasks.json() : { tasks: [] }; const tasks = (jsonTasks.tasks || []).map((t) => ({ id: t.id, title: t.title || '', description: t.description || '', due_date: toSqlDate(t.due_date), status: t.status || 'not_started', // 'not_started' | 'in_progress' | 'completed' })); setDraft((d) => ({ ...d, [m.id]: { title: m.title || '', description: m.description || '', date: toSqlDate(m.date), progress: m.progress || 0, newSalary: m.new_salary || '', impacts: imps, tasks, isUniversal: m.is_universal ? 1 : 0, }, })); setOriginalImpactIdsMap((p) => ({ ...p, [m.id]: imps.map((i) => i.id).filter(Boolean) })); setOriginalTaskIdsMap((p) => ({ ...p, [m.id]: tasks.map((t) => t.id).filter(Boolean) })); }, [] ); const handleAccordionClick = (m) => { if (editingId === m.id) { setEditingId(null); } else { openEditor(m); } }; // auto-open when a specific milestone is passed in useEffect(() => { if (selectedMilestone) openEditor(selectedMilestone); }, [selectedMilestone, openEditor]); /* --------------------------------------------------------- * * Impact helpers * --------------------------------------------------------- */ const updateImpact = (mid, idx, field, value) => setDraft((p) => { const d = p[mid]; if (!d) return p; const copy = [...d.impacts]; copy[idx] = { ...copy[idx], [field]: value }; return { ...p, [mid]: { ...d, impacts: copy } }; }); const addImpactRow = (mid) => setDraft((p) => { const d = p[mid]; if (!d) return p; const blank = { impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' }; return { ...p, [mid]: { ...d, impacts: [...d.impacts, blank] } }; }); const removeImpactRow = (mid, idx) => setDraft((p) => { const d = p[mid]; if (!d) return p; const c = [...d.impacts]; c.splice(idx, 1); return { ...p, [mid]: { ...d, impacts: c } }; }); /* --------------------------------------------------------- * * Task helpers * --------------------------------------------------------- */ const addTaskRow = (mid) => setDraft((p) => { const d = p[mid]; if (!d) return p; const blank = { id: null, title: '', description: '', due_date: '', status: 'not_started' }; return { ...p, [mid]: { ...d, tasks: [...(d.tasks || []), blank] } }; }); const updateTask = (mid, idx, field, value) => setDraft((p) => { const d = p[mid]; if (!d) return p; const copy = [...(d.tasks || [])]; copy[idx] = { ...copy[idx], [field]: value }; return { ...p, [mid]: { ...d, tasks: copy } }; }); const removeTaskRow = (mid, idx) => setDraft((p) => { const d = p[mid]; if (!d) return p; const copy = [...(d.tasks || [])]; copy.splice(idx, 1); return { ...p, [mid]: { ...d, tasks: copy } }; }); /* --------------------------------------------------------- * * Persist edits (UPDATE / create / delete diff) * --------------------------------------------------------- */ async function saveMilestone(m) { if (isSavingEdit) return; const d = draft[m.id]; if (!d) return; setIsSavingEdit(true); try { /* header */ const payload = { milestone_type: 'Financial', title: d.title, description: d.description, date: toDateOrNull(d.date), career_profile_id: careerProfileId, progress: d.progress || 0, status: (d.progress || 0) >= 100 ? 'completed' : 'planned', new_salary: d.newSalary ? parseFloat(d.newSalary) : null, is_universal: d.isUniversal ? 1 : 0, }; const res = await authFetch(`/api/premium/milestones/${m.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error(await res.text()); const saved = await res.json(); /* impacts diff */ const originalImpIds = originalImpactIdsMap[m.id] || []; const currentImpIds = (d.impacts || []).map((i) => i.id).filter(Boolean); // deletions for (const id of originalImpIds.filter((x) => !currentImpIds.includes(x))) { await authFetch(`/api/premium/milestone-impacts/${id}`, { method: 'DELETE' }); } // upserts for (const imp of d.impacts || []) { const body = { milestone_id: saved.id, impact_type: imp.impact_type, direction: imp.impact_type === 'salary' ? 'add' : imp.direction, amount: parseFloat(imp.amount) || 0, start_date: toDateOrNull(imp.start_date), end_date: toDateOrNull(imp.end_date), }; if (imp.id) { await authFetch(`/api/premium/milestone-impacts/${imp.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } else { await authFetch('/api/premium/milestone-impacts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } } /* tasks diff */ const originalTaskIds = originalTaskIdsMap[m.id] || []; const currentTaskIds = (d.tasks || []).map((t) => t.id).filter(Boolean); // deletions for (const id of originalTaskIds.filter((x) => !currentTaskIds.includes(x))) { await authFetch(`/api/premium/tasks/${id}`, { method: 'DELETE' }); } // upserts for (const t of d.tasks || []) { const body = { milestone_id: saved.id, title: t.title || '', description: t.description || '', due_date: toDateOrNull(t.due_date), status: t.status || 'not_started', }; if (t.id) { await authFetch(`/api/premium/tasks/${t.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } else { await authFetch('/api/premium/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } } await fetchMilestones(); setEditingId(null); onClose(true); } catch (err) { console.error('saveMilestone:', err); alert(err.message || 'Save failed'); } finally { setIsSavingEdit(false); } } async function deleteMilestone(m) { if (!window.confirm(`Delete “${m.title}”?`)) return; await authFetch(`/api/premium/milestones/${m.id}`, { method: 'DELETE' }); await fetchMilestones(); onClose(true); } /* --------------------------------------------------------- * * New-milestone helpers (create flow) * --------------------------------------------------------- */ const addBlankImpactToNew = () => setNewMilestone((n) => ({ ...n, impacts: [...n.impacts, { impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' }], })); const updateNewImpact = (idx, field, val) => setNewMilestone((n) => { const c = [...n.impacts]; c[idx] = { ...c[idx], [field]: val }; return { ...n, impacts: c }; }); const removeNewImpact = (idx) => setNewMilestone((n) => { const c = [...n.impacts]; c.splice(idx, 1); return { ...n, impacts: c }; }); const addBlankTaskToNew = () => setNewMilestone((n) => ({ ...n, tasks: [...n.tasks, { title: '', description: '', due_date: '', status: 'not_started' }], })); const updateNewTask = (idx, field, val) => setNewMilestone((n) => { const c = [...n.tasks]; c[idx] = { ...c[idx], [field]: val }; return { ...n, tasks: c }; }); const removeNewTask = (idx) => setNewMilestone((n) => { const c = [...n.tasks]; c.splice(idx, 1); return { ...n, tasks: c }; }); async function saveNew() { if (isSavingNew) return; if (!newMilestone.title.trim() || !newMilestone.date.trim()) { alert('Need title & date'); return; } setIsSavingNew(true); try { const hdr = { title: newMilestone.title, description: newMilestone.description, date: toDateOrNull(newMilestone.date), career_profile_id: careerProfileId, progress: newMilestone.progress || 0, status: (newMilestone.progress || 0) >= 100 ? 'completed' : 'planned', is_universal: newMilestone.isUniversal ? 1 : 0, }; const res = await authFetch('/api/premium/milestone', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(hdr), }); if (!res.ok) throw new Error(await res.text()); const createdJson = await res.json(); const created = Array.isArray(createdJson) ? createdJson[0] : createdJson; if (!created || !created.id) throw new Error('Milestone create failed — no id returned'); // Save impacts for (const imp of newMilestone.impacts) { if (!imp) continue; const hasAnyField = imp.amount || imp.start_date || imp.end_date || imp.impact_type; if (!hasAnyField) continue; const ibody = { milestone_id: created.id, impact_type: imp.impact_type, direction: imp.impact_type === 'salary' ? 'add' : imp.direction, amount: parseFloat(imp.amount) || 0, start_date: toDateOrNull(imp.start_date), end_date: toDateOrNull(imp.end_date), }; const ir = await authFetch('/api/premium/milestone-impacts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ibody), }); if (!ir.ok) throw new Error(await ir.text()); } // Save tasks for (const t of newMilestone.tasks) { if (!t) continue; const hasAnyField = t.title || t.description || t.due_date; if (!hasAnyField) continue; const tbody = { milestone_id: created.id, title: t.title || '', description: t.description || '', due_date: toDateOrNull(t.due_date), status: t.status || 'not_started', }; const tr = await authFetch('/api/premium/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(tbody), }); if (!tr.ok) throw new Error(await tr.text()); } await fetchMilestones(); setAddingNew(false); onClose(true); } catch (err) { console.error('saveNew:', err); alert(err.message || 'Failed to save milestone'); } finally { setIsSavingNew(false); } } /* ══════════════════════════════════════════════════════════════ */ /* RENDER */ /* ══════════════════════════════════════════════════════════════ */ return (
Track important events and their financial impact on this scenario.
Use salary for annual income changes; monthly for recurring amounts.
Break this milestone into concrete steps.