514 lines
23 KiB
JavaScript
514 lines
23 KiB
JavaScript
// 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';
|
||
|
||
/* Helper ---------------------------------------------------- */
|
||
const toSqlDate = (v) => (v ? String(v).slice(0, 10) : '');
|
||
|
||
export default function MilestoneEditModal({
|
||
careerProfileId,
|
||
milestones: incomingMils = [],
|
||
milestone: selectedMilestone,
|
||
fetchMilestones,
|
||
onClose
|
||
}) {
|
||
/* ───────────────── state */
|
||
const [milestones, setMilestones] = useState(incomingMils);
|
||
const [editingId, setEditingId] = useState(null);
|
||
const [draft, setDraft] = useState({}); // id → {…fields}
|
||
const [originalImpactIdsMap, setOriginalImpactIdsMap] = useState({});
|
||
const [addingNew, setAddingNew] = useState(false);
|
||
const [newMilestone, setNewMilestone] = useState({
|
||
title:'', description:'', date:'', progress:0, newSalary:'',
|
||
impacts:[], isUniversal:0
|
||
});
|
||
const [MilestoneCopyWizard, setMilestoneCopyWizard] = useState(null);
|
||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||
const [isSavingNew , setIsSavingNew ] = useState(false);
|
||
|
||
/* keep list in sync with prop */
|
||
useEffect(()=> setMilestones(incomingMils), [incomingMils]);
|
||
|
||
/* --------------------------------------------------------- *
|
||
* Load impacts for one milestone then open its accordion
|
||
* --------------------------------------------------------- */
|
||
const openEditor = useCallback(async (m) => {
|
||
|
||
setEditingId(m.id);
|
||
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
|
||
const json = res.ok ? await res.json() : { impacts:[] };
|
||
const imps = (json.impacts||[]).map(i=>({
|
||
id:i.id,
|
||
impact_type : i.impact_type||'ONE_TIME',
|
||
direction : i.direction||'subtract',
|
||
amount : i.amount||0,
|
||
start_date : toSqlDate(i.start_date),
|
||
end_date : toSqlDate(i.end_date)
|
||
}));
|
||
|
||
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,
|
||
isUniversal : m.is_universal?1:0
|
||
}
|
||
}));
|
||
setOriginalImpactIdsMap(p => ({ ...p, [m.id]: imps.map(i=>i.id)}));
|
||
}, [editingId]);
|
||
|
||
const handleAccordionClick = (m) => {
|
||
if (editingId === m.id) {
|
||
setEditingId(null); // just close
|
||
} else {
|
||
openEditor(m); // open + fetch
|
||
}
|
||
};
|
||
|
||
/* open editor automatically when parent passed selectedMilestone */
|
||
useEffect(()=>{ if(selectedMilestone) openEditor(selectedMilestone)},[selectedMilestone,openEditor]);
|
||
|
||
/* --------------------------------------------------------- *
|
||
* Handlers – shared small 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}};
|
||
});
|
||
|
||
/* --------------------------------------------------------- *
|
||
* Persist edits (UPDATE / create / delete diff)
|
||
* --------------------------------------------------------- */
|
||
async function saveMilestone(m){
|
||
if(isSavingEdit) return; // guard
|
||
const d = draft[m.id]; if(!d) return;
|
||
setIsSavingEdit(true);
|
||
|
||
/* header */
|
||
const payload = {
|
||
milestone_type:'Financial',
|
||
title:d.title, description:d.description, date:toSqlDate(d.date),
|
||
career_profile_id:careerProfileId, progress:d.progress,
|
||
status:d.progress>=100?'completed':'planned',
|
||
new_salary:d.newSalary?parseFloat(d.newSalary):null,
|
||
is_universal:d.isUniversal
|
||
};
|
||
const res = await authFetch(`/api/premium/milestones/${m.id}`,{
|
||
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
|
||
});
|
||
if(!res.ok){ alert('Save failed'); return;}
|
||
const saved = await res.json();
|
||
|
||
/* impacts diff */
|
||
const originalIds = originalImpactIdsMap[m.id]||[];
|
||
const currentIds = d.impacts.map(i=>i.id).filter(Boolean);
|
||
/* deletions */
|
||
for(const id of originalIds.filter(x=>!currentIds.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:imp.start_date||null,
|
||
end_date:imp.end_date||null
|
||
};
|
||
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)});
|
||
}
|
||
}
|
||
await fetchMilestones();
|
||
setEditingId(null);
|
||
setIsSavingEdit(false);
|
||
onClose(true);
|
||
}
|
||
|
||
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};
|
||
});
|
||
|
||
async function saveNew(){
|
||
if(isSavingNew) return;
|
||
if(!newMilestone.title.trim()||!newMilestone.date.trim()){
|
||
alert('Need title & date'); return;
|
||
}
|
||
setIsSavingNew(true);
|
||
const hdr = { title:newMilestone.title, description:newMilestone.description,
|
||
date:toSqlDate(newMilestone.date), career_profile_id:careerProfileId,
|
||
progress:newMilestone.progress, status:newMilestone.progress>=100?'completed':'planned',
|
||
is_universal:newMilestone.isUniversal };
|
||
const res = await authFetch('/api/premium/milestone',{method:'POST',
|
||
headers:{'Content-Type':'application/json'},body:JSON.stringify(hdr)});
|
||
const created = Array.isArray(await res.json())? (await res.json())[0]:await res.json();
|
||
for(const imp of newMilestone.impacts){
|
||
const body = {
|
||
milestone_id:created.id, impact_type:imp.impact_type,
|
||
direction:imp.impact_type==='salary'?'add':imp.direction,
|
||
amount:parseFloat(imp.amount)||0, start_date:imp.start_date||null, end_date:imp.end_date||null
|
||
};
|
||
await authFetch('/api/premium/milestone-impacts',{
|
||
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||
}
|
||
await fetchMilestones();
|
||
setAddingNew(false);
|
||
onClose(true);
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════ */
|
||
/* RENDER */
|
||
/* ══════════════════════════════════════════════════════════════ */
|
||
return (
|
||
<div className="fixed inset-0 z-[9999] flex items-start justify-center overflow-y-auto bg-black/40">
|
||
<div className="bg-white w-full max-w-3xl mx-4 my-10 rounded-md shadow-lg ring-1 ring-gray-300">
|
||
{/* header */}
|
||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||
<div>
|
||
<h2 className="text-lg font-semibold">Milestones</h2>
|
||
<p className="text-xs text-gray-500">
|
||
Track important events and their financial impact on this scenario.
|
||
</p>
|
||
</div>
|
||
<Button variant="ghost" onClick={() => onClose(false)}>✕</Button>
|
||
</div>
|
||
|
||
{/* body */}
|
||
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto">
|
||
{/* EXISTING */}
|
||
{milestones.map(m=>{
|
||
const open = editingId===m.id;
|
||
const d = draft[m.id]||{};
|
||
return (
|
||
<div key={m.id} className="border rounded-md">
|
||
{/* accordion header */}
|
||
<button
|
||
className="w-full flex justify-between items-center px-4 py-2 bg-gray-50 hover:bg-gray-100 text-left"
|
||
onClick={()=>handleAccordionClick(m)}>
|
||
<span className="font-medium">{m.title}</span>
|
||
<span className="text-sm text-gray-500">
|
||
{toSqlDate(m.date)} ▸ {open?'Hide':'Edit'}
|
||
</span>
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="px-4 py-4 grid gap-4 bg-white">
|
||
{/* ------------- fields */}
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium flex items-center gap-1">
|
||
Title <InfoTooltip message="Short, action‑oriented label (max 60 chars)." />
|
||
</label>
|
||
<input
|
||
className="input"
|
||
value={d.title||''}
|
||
onChange={e=>setDraft(p=>({...p,[m.id]:{...p[m.id],title:e.target.value}}))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium">Date</label>
|
||
<input
|
||
type="date"
|
||
className="input"
|
||
value={d.date||''}
|
||
onChange={e=>setDraft(p=>({...p,[m.id]:{...p[m.id],date:e.target.value}}))}
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-2 space-y-1">
|
||
<label className="text-sm font-medium flex items-center gap-1">
|
||
Description <InfoTooltip message="1‑2 sentences on what success looks like."/>
|
||
</label>
|
||
<textarea
|
||
rows={2}
|
||
className="input resize-none"
|
||
value={d.description||''}
|
||
onChange={e=>setDraft(p=>({...p,[m.id]:{...p[m.id],description:e.target.value}}))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* impacts */}
|
||
<div>
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="font-medium text-sm">Financial impacts</h4>
|
||
<Button size="xs" onClick={()=>addImpactRow(m.id)}>+ Add impact</Button>
|
||
</div>
|
||
<p className="text-xs text-gray-500 mb-2">
|
||
Use <em>salary</em> for annual income changes; <em>monthly</em> for recurring amounts.
|
||
</p>
|
||
|
||
<div className="space-y-3">
|
||
{d.impacts?.map((imp,idx)=>(
|
||
<div key={idx} className="grid gap-2 md:grid-cols-[150px_120px_1fr_auto] items-end">
|
||
{/* type */}
|
||
<div>
|
||
<label className="label-xs">Type</label>
|
||
<select
|
||
className="input"
|
||
value={imp.impact_type}
|
||
onChange={e=>updateImpact(m.id,idx,'impact_type',e.target.value)}>
|
||
<option value="salary">Salary (annual)</option>
|
||
<option value="ONE_TIME">One‑time</option>
|
||
<option value="MONTHLY">Monthly</option>
|
||
</select>
|
||
</div>
|
||
{/* direction – hide for salary */}
|
||
{imp.impact_type!=='salary' && (
|
||
<div>
|
||
<label className="label-xs">Direction</label>
|
||
<select
|
||
className="input"
|
||
value={imp.direction}
|
||
onChange={e=>updateImpact(m.id,idx,'direction',e.target.value)}>
|
||
<option value="add">Add</option>
|
||
<option value="subtract">Subtract</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
{/* amount */}
|
||
<div>
|
||
<label className="label-xs">Amount</label>
|
||
<input
|
||
type="number"
|
||
className="input"
|
||
value={imp.amount}
|
||
onChange={e=>updateImpact(m.id,idx,'amount',e.target.value)}
|
||
/>
|
||
</div>
|
||
{/* dates */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="label-xs">Start</label>
|
||
<input
|
||
type="date"
|
||
className="input"
|
||
value={imp.start_date}
|
||
onChange={e=>updateImpact(m.id,idx,'start_date',e.target.value)}
|
||
/>
|
||
</div>
|
||
{imp.impact_type==='MONTHLY' && (
|
||
<div>
|
||
<label className="label-xs">End</label>
|
||
<input
|
||
type="date"
|
||
className="input"
|
||
value={imp.end_date}
|
||
onChange={e=>updateImpact(m.id,idx,'end_date',e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* remove */}
|
||
<Button
|
||
size="icon-xs"
|
||
variant="ghost"
|
||
className="text-red-600"
|
||
onClick={()=>removeImpactRow(m.id,idx)}
|
||
>
|
||
✕
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* footer buttons */}
|
||
<div className="flex justify-between pt-4 border-t">
|
||
<div className="space-x-2">
|
||
<Button variant="destructive" onClick={()=>deleteMilestone(m)}>
|
||
Delete milestone
|
||
</Button>
|
||
<Button variant="secondary" onClick={()=>setMilestoneCopyWizard(m)}>
|
||
Copy to other scenarios
|
||
</Button>
|
||
</div>
|
||
<Button disabled={isSavingEdit} onClick={()=>saveMilestone(m)}>
|
||
{isSavingEdit ? 'Saving…' : 'Save'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* NEW milestone accordion */}
|
||
<details className="border rounded-md" open={addingNew}>
|
||
<summary
|
||
className="cursor-pointer px-4 py-2 bg-gray-50 hover:bg-gray-100 text-sm font-medium flex justify-between items-center"
|
||
onClick={(e)=>{e.preventDefault();setAddingNew(p=>!p);}}
|
||
>
|
||
Add new milestone
|
||
<span>{addingNew?'–':'+'}</span>
|
||
</summary>
|
||
|
||
{addingNew && (
|
||
<div className="px-4 py-4 space-y-4 bg-white">
|
||
{/* fields */}
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<div className="space-y-1">
|
||
<label className="label-xs">Title</label>
|
||
<input
|
||
className="input"
|
||
value={newMilestone.title}
|
||
onChange={e=>setNewMilestone(n=>({...n,title:e.target.value}))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="label-xs">Date</label>
|
||
<input
|
||
type="date"
|
||
className="input"
|
||
value={newMilestone.date}
|
||
onChange={e=>setNewMilestone(n=>({...n,date:e.target.value}))}
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-2 space-y-1">
|
||
<label className="label-xs">Description</label>
|
||
<textarea
|
||
rows={2}
|
||
className="input resize-none"
|
||
value={newMilestone.description}
|
||
onChange={e=>setNewMilestone(n=>({...n,description:e.target.value}))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{/* impacts */}
|
||
<div>
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="font-medium text-sm">Financial impacts</h4>
|
||
<Button size="xs" onClick={addBlankImpactToNew}>+ Add impact</Button>
|
||
</div>
|
||
<div className="space-y-3 mt-2">
|
||
{newMilestone.impacts.map((imp,idx)=>(
|
||
<div key={idx} className="grid md:grid-cols-[150px_120px_1fr_auto] gap-2 items-end">
|
||
<div>
|
||
<label className="label-xs">Type</label>
|
||
<select
|
||
className="input"
|
||
value={imp.impact_type}
|
||
onChange={e=>updateNewImpact(idx,'impact_type',e.target.value)}>
|
||
<option value="salary">Salary (annual)</option>
|
||
<option value="ONE_TIME">One‑time</option>
|
||
<option value="MONTHLY">Monthly</option>
|
||
</select>
|
||
</div>
|
||
{imp.impact_type!=='salary' && (
|
||
<div>
|
||
<label className="label-xs">Direction</label>
|
||
<select
|
||
className="input"
|
||
value={imp.direction}
|
||
onChange={e=>updateNewImpact(idx,'direction',e.target.value)}>
|
||
<option value="add">Add</option>
|
||
<option value="subtract">Subtract</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<label className="label-xs">Amount</label>
|
||
<input
|
||
type="number"
|
||
className="input"
|
||
value={imp.amount}
|
||
onChange={e=>updateNewImpact(idx,'amount',e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<input
|
||
type="date"
|
||
className="input"
|
||
value={imp.start_date}
|
||
onChange={e=>updateNewImpact(idx,'start_date',e.target.value)}
|
||
/>
|
||
{imp.impact_type==='MONTHLY' && (
|
||
<input
|
||
type="date"
|
||
className="input"
|
||
value={imp.end_date}
|
||
onChange={e=>updateNewImpact(idx,'end_date',e.target.value)}
|
||
/>
|
||
)}
|
||
</div>
|
||
<Button
|
||
size="icon-xs"
|
||
variant="ghost"
|
||
className="text-red-600"
|
||
onClick={()=>removeNewImpact(idx)}
|
||
>
|
||
✕
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{/* save row */}
|
||
<div className="flex justify-end border-t pt-4">
|
||
<Button disabled={isSavingNew} onClick={saveNew}>
|
||
{isSavingNew ? 'Saving…' : 'Save milestone'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</details>
|
||
</div>
|
||
|
||
{/* footer */}
|
||
<div className="px-6 py-4 border-t text-right">
|
||
<Button variant="secondary" onClick={()=>onClose(false)}>Close</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* COPY wizard */}
|
||
{MilestoneCopyWizard && (
|
||
<MilestoneCopyWizard
|
||
milestone={MilestoneCopyWizard}
|
||
onClose={(didCopy)=>{setMilestoneCopyWizard(null); if(didCopy) fetchMilestones();}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
} |