606 lines
22 KiB
JavaScript
606 lines
22 KiB
JavaScript
import React, { useState, useEffect, useCallback } from "react";
|
||
import { Button } from "./ui/button.js";
|
||
import authFetch from "../utils/authFetch.js";
|
||
import parseFloatOrZero from "../utils/ParseFloatorZero.js";
|
||
import MilestoneCopyWizard from "./MilestoneCopyWizard.js";
|
||
|
||
/**
|
||
* Full‑screen overlay for creating / editing milestones + impacts + tasks.
|
||
* Extracted from ScenarioContainer so it can be shared with CareerRoadmap.
|
||
*
|
||
* Props
|
||
* ──────────────────────────────────────────────────────────────────────
|
||
* careerProfileId – number (required)
|
||
* milestones – array of milestone objects to edit
|
||
* fetchMilestones – async fn to refresh parent after a save/delete
|
||
* onClose(bool) – close overlay. param = true if data changed
|
||
*/
|
||
export default function MilestoneEditModal({
|
||
careerProfileId,
|
||
milestones: incomingMils = [],
|
||
fetchMilestones,
|
||
onClose
|
||
}) {
|
||
/* ────────────────────────────────
|
||
Local state mirrors ScenarioContainer
|
||
*/
|
||
const [milestones, setMilestones] = useState(incomingMils);
|
||
const [editingMilestoneId, setEditingMilestoneId] = useState(null);
|
||
const [newMilestoneMap, setNewMilestoneMap] = useState({});
|
||
const [addingNewMilestone, setAddingNewMilestone] = useState(false);
|
||
const [newMilestoneData, setNewMilestoneData] = useState({
|
||
title: "",
|
||
description: "",
|
||
date: "",
|
||
progress: 0,
|
||
newSalary: "",
|
||
impacts: [],
|
||
isUniversal: 0
|
||
});
|
||
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
||
|
||
function toSqlDate(str = '') {
|
||
// Handles '', null, undefined gracefully
|
||
return str.slice(0, 10); // "YYYY-MM-DD"
|
||
}
|
||
|
||
/* keep milestones in sync with prop */
|
||
useEffect(() => {
|
||
setMilestones(incomingMils);
|
||
}, [incomingMils]);
|
||
|
||
/* ────────────────────────────────
|
||
Inline-edit helpers
|
||
──────────────────────────────────*/
|
||
const [originalImpactIdsMap, setOriginalImpactIdsMap] = useState({}); // snapshot per milestone
|
||
|
||
/* 1️⃣ fetch impacts + open editor */
|
||
const loadMilestoneImpacts = useCallback(async (m) => {
|
||
try {
|
||
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
|
||
if (!res.ok) throw new Error('impact fetch failed');
|
||
const json = await res.json();
|
||
|
||
const impacts = (json.impacts || []).map(imp => ({
|
||
id : imp.id,
|
||
impact_type : imp.impact_type || 'ONE_TIME',
|
||
direction : imp.direction || 'subtract',
|
||
amount : imp.amount || 0,
|
||
start_date : toSqlDate(imp.start_date) || '',
|
||
end_date : toSqlDate(imp.end_date) || ''
|
||
}));
|
||
|
||
/* editable copy for the form */
|
||
setNewMilestoneMap(prev => ({
|
||
...prev,
|
||
[m.id]: {
|
||
title : m.title || '',
|
||
description : m.description || '',
|
||
date : toSqlDate(m.date) || '',
|
||
progress : m.progress || 0,
|
||
newSalary : m.new_salary || '',
|
||
impacts,
|
||
isUniversal : m.is_universal ? 1 : 0
|
||
}
|
||
}));
|
||
|
||
/* snapshot the IDs that existed when editing started */
|
||
setOriginalImpactIdsMap(prev => ({
|
||
...prev,
|
||
[m.id]: impacts.map(i => i.id) // array of strings
|
||
}));
|
||
|
||
setEditingMilestoneId(m.id); // open the accordion
|
||
} catch (err) {
|
||
console.error('loadImpacts', err);
|
||
}
|
||
}, []);
|
||
|
||
/* 2️⃣ toggle open / close */
|
||
const handleEditMilestoneInline = (milestone) => {
|
||
setEditingMilestoneId((curr) =>
|
||
curr === milestone.id ? null : milestone.id
|
||
);
|
||
if (editingMilestoneId !== milestone.id) loadMilestoneImpacts(milestone);
|
||
};
|
||
|
||
/* 3️⃣ generic field updater for one impact row */
|
||
const updateInlineImpact = (mid, idx, field, value) => {
|
||
setNewMilestoneMap(prev => {
|
||
const m = prev[mid];
|
||
if (!m) return prev;
|
||
const impacts = [...m.impacts];
|
||
impacts[idx] = { ...impacts[idx], [field]: value };
|
||
return { ...prev, [mid]: { ...m, impacts } };
|
||
});
|
||
};
|
||
|
||
/* 4️⃣ add an empty impact row */
|
||
const addInlineImpact = (mid) => {
|
||
setNewMilestoneMap(prev => {
|
||
const m = prev[mid];
|
||
if (!m) return prev;
|
||
return {
|
||
...prev,
|
||
[mid]: {
|
||
...m,
|
||
impacts: [
|
||
...m.impacts,
|
||
{
|
||
impact_type : 'ONE_TIME',
|
||
direction : 'subtract',
|
||
amount : 0,
|
||
start_date : '',
|
||
end_date : ''
|
||
}
|
||
]
|
||
}
|
||
};
|
||
});
|
||
};
|
||
|
||
/* 5️⃣ remove one impact row (local only – diff happens on save) */
|
||
const removeInlineImpact = (mid, idx) => {
|
||
setNewMilestoneMap(prev => {
|
||
const m = prev[mid];
|
||
if (!m) return prev;
|
||
const clone = [...m.impacts];
|
||
clone.splice(idx, 1);
|
||
return { ...prev, [mid]: { ...m, impacts: clone } };
|
||
});
|
||
};
|
||
|
||
/* 6️⃣ persist the edits – PUT milestone, diff impacts */
|
||
const saveInlineMilestone = async (m) => {
|
||
const data = newMilestoneMap[m.id];
|
||
if (!data) return;
|
||
|
||
/* --- update the milestone header --- */
|
||
const payload = {
|
||
milestone_type : 'Financial',
|
||
title : data.title,
|
||
description : data.description,
|
||
date : toSqlDate(data.date),
|
||
career_profile_id : careerProfileId,
|
||
progress : data.progress,
|
||
status : data.progress >= 100 ? 'completed' : 'planned',
|
||
new_salary : data.newSalary ? parseFloat(data.newSalary) : null,
|
||
is_universal : data.isUniversal || 0
|
||
};
|
||
|
||
try {
|
||
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();
|
||
|
||
/* --- figure out what changed ---------------------------------- */
|
||
const originalIds = originalImpactIdsMap[m.id] || [];
|
||
const currentIds = (data.impacts || []).map(i => i.id).filter(Boolean);
|
||
const toDelete = originalIds.filter(id => !currentIds.includes(id));
|
||
|
||
/* --- deletions first --- */
|
||
for (const delId of toDelete) {
|
||
await authFetch(`/api/premium/milestone-impacts/${delId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
}
|
||
|
||
/* --- creates / updates --- */
|
||
for (const imp of data.impacts) {
|
||
const impPayload = {
|
||
milestone_id : saved.id,
|
||
impact_type : imp.impact_type,
|
||
direction : imp.direction,
|
||
amount : parseFloat(imp.amount) || 0,
|
||
start_date : toSqlDate(imp.start_date) || null,
|
||
end_date : toSqlDate(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(impPayload)
|
||
});
|
||
} else {
|
||
await authFetch('/api/premium/milestone-impacts', {
|
||
method : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body : JSON.stringify(impPayload)
|
||
});
|
||
}
|
||
}
|
||
|
||
/* --- refresh + close --- */
|
||
await fetchMilestones();
|
||
setEditingMilestoneId(null);
|
||
|
||
} catch (err) {
|
||
alert('Failed to save milestone');
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
/* ───────────── misc helpers the JSX still calls ───────────── */
|
||
|
||
/* A) delete one milestone row altogether */
|
||
const deleteMilestone = async (milestone) => {
|
||
if (!window.confirm(`Delete “${milestone.title}” ?`)) return;
|
||
try {
|
||
const res = await authFetch(
|
||
`/api/premium/milestones/${milestone.id}`,
|
||
{ method: 'DELETE' }
|
||
);
|
||
if (!res.ok) throw new Error(await res.text());
|
||
await fetchMilestones(); // refresh parent list
|
||
onClose(true); // bubble up that something changed
|
||
} catch (err) {
|
||
alert('Failed to delete milestone');
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
/* B) add a blank impact row while creating a brand-new milestone */
|
||
const addNewImpactToNewMilestone = () => {
|
||
setNewMilestoneData(prev => ({
|
||
...prev,
|
||
impacts: [
|
||
...prev.impacts,
|
||
{
|
||
impact_type : 'ONE_TIME',
|
||
direction : 'subtract',
|
||
amount : 0,
|
||
start_date : '',
|
||
end_date : ''
|
||
}
|
||
]
|
||
}));
|
||
};
|
||
|
||
/* C) create an entirely new milestone + its impacts */
|
||
const saveNewMilestone = async () => {
|
||
if (!newMilestoneData.title.trim() || !newMilestoneData.date.trim()) {
|
||
alert('Need title and date'); return;
|
||
}
|
||
|
||
const payload = {
|
||
title : newMilestoneData.title,
|
||
description : newMilestoneData.description,
|
||
date : toSqlDate(newMilestoneData.date),
|
||
career_profile_id: careerProfileId,
|
||
progress : newMilestoneData.progress,
|
||
status : newMilestoneData.progress >= 100 ? 'completed' : 'planned',
|
||
is_universal : newMilestoneData.isUniversal || 0
|
||
};
|
||
|
||
try {
|
||
const res = await authFetch('/api/premium/milestone', {
|
||
method : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body : JSON.stringify(payload)
|
||
});
|
||
if (!res.ok) throw new Error(await res.text());
|
||
const created =
|
||
Array.isArray(await res.json()) ? (await res.json())[0] : await res.json();
|
||
|
||
/* impacts for the new milestone */
|
||
for (const imp of newMilestoneData.impacts) {
|
||
const impPayload = {
|
||
milestone_id : created.id,
|
||
impact_type : imp.impact_type,
|
||
direction : imp.direction,
|
||
amount : parseFloat(imp.amount) || 0,
|
||
start_date : toSqlDate(imp.start_date) || null,
|
||
end_date : toSqlDate(imp.end_date) || null
|
||
};
|
||
await authFetch('/api/premium/milestone-impacts', {
|
||
method : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body : JSON.stringify(impPayload)
|
||
});
|
||
}
|
||
|
||
await fetchMilestones(); // refresh list
|
||
setAddingNewMilestone(false); // collapse the new-mile form
|
||
onClose(true);
|
||
} catch (err) {
|
||
alert('Failed to save milestone');
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
/* ────────────────────────────────
|
||
Render
|
||
*/
|
||
return (
|
||
<div
|
||
style={{
|
||
position: "fixed",
|
||
inset: 0,
|
||
background: "rgba(0,0,0,0.4)",
|
||
zIndex: 9999,
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
justifyContent: "center",
|
||
overflowY: "auto"
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
background: "#fff",
|
||
width: "800px",
|
||
padding: "1rem",
|
||
margin: "2rem auto",
|
||
borderRadius: "4px"
|
||
}}
|
||
>
|
||
<h3>Edit Milestones</h3>
|
||
|
||
{milestones.map((m) => {
|
||
const hasEditOpen = editingMilestoneId === m.id;
|
||
const data = newMilestoneMap[m.id] || {};
|
||
|
||
return (
|
||
<div key={m.id} style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "1rem" }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||
<h5 style={{ margin: 0 }}>{m.title}</h5>
|
||
<Button onClick={() => handleEditMilestoneInline(m)}>
|
||
{hasEditOpen ? "Cancel" : "Edit"}
|
||
</Button>
|
||
<Button
|
||
style={{ marginLeft: "0.5rem", color: "black", backgroundColor: "red" }}
|
||
onClick={() => deleteMilestone(m)}
|
||
>
|
||
Delete
|
||
</Button>
|
||
</div>
|
||
<p>{m.description}</p>
|
||
<p>
|
||
<strong>Date:</strong> {m.date}
|
||
</p>
|
||
<p>Progress: {m.progress}%</p>
|
||
|
||
{/* inline form */}
|
||
{hasEditOpen && (
|
||
<div style={{ border: "1px solid #aaa", marginTop: "1rem", padding: "0.5rem" }}>
|
||
<input
|
||
type="text"
|
||
placeholder="Title"
|
||
value={data.title}
|
||
style={{ display: "block", marginBottom: "0.5rem" }}
|
||
onChange={(e) =>
|
||
setNewMilestoneMap((p) => ({
|
||
...p,
|
||
[m.id]: { ...p[m.id], title: e.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
<textarea
|
||
placeholder="Description"
|
||
value={data.description}
|
||
style={{ display: "block", width: "100%", marginBottom: "0.5rem" }}
|
||
onChange={(e) =>
|
||
setNewMilestoneMap((p) => ({
|
||
...p,
|
||
[m.id]: { ...p[m.id], description: e.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
<label>Date:</label>
|
||
<input
|
||
type="date"
|
||
value={data.date || ""}
|
||
style={{ display: "block", marginBottom: "0.5rem" }}
|
||
onChange={(e) =>
|
||
setNewMilestoneMap((p) => ({
|
||
...p,
|
||
[m.id]: { ...p[m.id], date: e.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
{/* impacts */}
|
||
<div style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}>
|
||
<h6>Financial Impacts</h6>
|
||
{data.impacts?.map((imp, idx) => (
|
||
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
|
||
<label>Type:</label>
|
||
<select
|
||
value={imp.impact_type}
|
||
onChange={(e) => updateInlineImpact(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>
|
||
<label>Direction:</label>
|
||
<select
|
||
value={imp.direction}
|
||
onChange={(e) => updateInlineImpact(m.id, idx, "direction", e.target.value)}
|
||
>
|
||
<option value="add">Add</option>
|
||
<option value="subtract">Subtract</option>
|
||
</select>
|
||
<label>Amount:</label>
|
||
<input
|
||
type="number"
|
||
value={imp.amount}
|
||
onChange={(e) => updateInlineImpact(m.id, idx, "amount", e.target.value)}
|
||
/>
|
||
<label>Start:</label>
|
||
<input
|
||
type="date"
|
||
value={imp.start_date || ""}
|
||
onChange={(e) => updateInlineImpact(m.id, idx, "start_date", e.target.value)}
|
||
/>
|
||
{imp.impact_type === "MONTHLY" && (
|
||
<>
|
||
<label>End:</label>
|
||
<input
|
||
type="date"
|
||
value={imp.end_date || ""}
|
||
onChange={(e) => updateInlineImpact(m.id, idx, "end_date", e.target.value)}
|
||
/>
|
||
</>
|
||
)}
|
||
<Button onClick={() => removeInlineImpact(m.id, idx)} style={{ marginLeft: "0.5rem", color: "red" }}>
|
||
Remove
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button onClick={() => addInlineImpact(m.id)}>+ Financial Impact</Button>
|
||
</div>
|
||
<Button onClick={() => saveInlineMilestone(m)}>Save</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* add‑new toggle */}
|
||
<Button onClick={() => setAddingNewMilestone((p) => !p)}>
|
||
{addingNewMilestone ? "Cancel New Milestone" : "Add Milestone"}
|
||
</Button>
|
||
|
||
{addingNewMilestone && (
|
||
<div style={{ border: "1px solid #aaa", padding: "0.5rem", marginTop: "0.5rem" }}>
|
||
<input
|
||
type="text"
|
||
placeholder="Title"
|
||
value={newMilestoneData.title}
|
||
style={{ display: "block", marginBottom: "0.5rem" }}
|
||
onChange={(e) => setNewMilestoneData((p) => ({ ...p, title: e.target.value }))}
|
||
/>
|
||
<textarea
|
||
placeholder="Description"
|
||
value={newMilestoneData.description}
|
||
style={{ display: "block", width: "100%", marginBottom: "0.5rem" }}
|
||
onChange={(e) => setNewMilestoneData((p) => ({ ...p, description: e.target.value }))}
|
||
/>
|
||
<label>Date:</label>
|
||
<input
|
||
type="date"
|
||
value={newMilestoneData.date || ""}
|
||
style={{ display: "block", marginBottom: "0.5rem" }}
|
||
onChange={(e) => setNewMilestoneData((p) => ({ ...p, date: e.target.value }))}
|
||
/>
|
||
<div style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}>
|
||
<h6>Impacts</h6>
|
||
{newMilestoneData.impacts.map((imp, idx) => (
|
||
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
|
||
<label>Type:</label>
|
||
<select
|
||
value={imp.impact_type}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setNewMilestoneData((prev) => {
|
||
const copy = [...prev.impacts];
|
||
copy[idx] = { ...copy[idx], impact_type: val };
|
||
return { ...prev, impacts: copy };
|
||
});
|
||
}}
|
||
>
|
||
<option value="salary">Salary (annual)</option>
|
||
<option value="ONE_TIME">One-Time</option>
|
||
<option value="MONTHLY">Monthly</option>
|
||
</select>
|
||
<label>Direction:</label>
|
||
<select
|
||
value={imp.direction}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setNewMilestoneData((prev) => {
|
||
const copy = [...prev.impacts];
|
||
copy[idx] = { ...copy[idx], direction: val };
|
||
return { ...prev, impacts: copy };
|
||
});
|
||
}}
|
||
>
|
||
<option value="add">Add</option>
|
||
<option value="subtract">Subtract</option>
|
||
</select>
|
||
<label>Amount:</label>
|
||
<input
|
||
type="number"
|
||
value={imp.amount}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setNewMilestoneData((prev) => {
|
||
const copy = [...prev.impacts];
|
||
copy[idx] = { ...copy[idx], amount: val };
|
||
return { ...prev, impacts: copy };
|
||
});
|
||
}}
|
||
/>
|
||
<label>Start:</label>
|
||
<input
|
||
type="date"
|
||
value={imp.start_date || ""}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setNewMilestoneData((prev) => {
|
||
const copy = [...prev.impacts];
|
||
copy[idx] = { ...copy[idx], start_date: val };
|
||
return { ...prev, impacts: copy };
|
||
});
|
||
}}
|
||
/>
|
||
{imp.impact_type === "MONTHLY" && (
|
||
<>
|
||
<label>End:</label>
|
||
<input
|
||
type="date"
|
||
value={imp.end_date || ""}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setNewMilestoneData((prev) => {
|
||
const copy = [...prev.impacts];
|
||
copy[idx] = { ...copy[idx], end_date: val };
|
||
return { ...prev, impacts: copy };
|
||
});
|
||
}}
|
||
/>
|
||
</>
|
||
)}
|
||
<Button
|
||
onClick={() => {
|
||
setNewMilestoneData((prev) => {
|
||
const cpy = [...prev.impacts];
|
||
cpy.splice(idx, 1);
|
||
return { ...prev, impacts: cpy };
|
||
});
|
||
}}
|
||
style={{ color: "red", marginLeft: "0.5rem" }}
|
||
>
|
||
Remove
|
||
</Button>
|
||
</div>
|
||
))}
|
||
<Button onClick={addNewImpactToNewMilestone}>+ Financial Impact</Button>
|
||
</div>
|
||
<Button onClick={saveNewMilestone}>Add Milestone</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Copy Wizard */}
|
||
{copyWizardMilestone && (
|
||
<MilestoneCopyWizard
|
||
milestone={copyWizardMilestone}
|
||
onClose={(didCopy) => {
|
||
setCopyWizardMilestone(null);
|
||
if (didCopy) fetchMilestones();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<div style={{ marginTop: "1rem", textAlign: "right" }}>
|
||
<Button onClick={() => onClose(false)}>Close</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|