dev1/src/components/MilestoneEditModal.js

606 lines
22 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.

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";
/**
* Fullscreen 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>
);
})}
{/* addnew 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>
);
}