dev1/src/components/MilestoneEditModal.js
Josh 8449dc2a91
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Cleanup, all technical fixes prior to prod creation
2025-08-03 18:44:36 +00:00

514 lines
23 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}
/* --------------------------------------------------------- *
* Newmilestone 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, actionoriented label (max 60chars)." />
</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="12 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">Onetime</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">Onetime</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>
);
}