Fixed MilestoneEditModal and FinancialProjectionService impact signs.
This commit is contained in:
parent
15d28ce2e8
commit
5ad377b50e
@ -240,8 +240,9 @@ I'm here to support you with personalized coaching. What would you like to focus
|
|||||||
setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]);
|
setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]);
|
||||||
|
|
||||||
if (riskData && onAiRiskFetched) onAiRiskFetched(riskData);
|
if (riskData && onAiRiskFetched) onAiRiskFetched(riskData);
|
||||||
if (createdMilestones.length && onMilestonesCreated)
|
if (createdMilestones.length && typeof onMilestonesCreated === 'function') {
|
||||||
onMilestonesCreated(createdMilestones.length);
|
onMilestonesCreated(); // no arg needed – just refetch
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setMessages((prev) => [...prev, { role: "assistant", content: "Sorry, something went wrong." }]);
|
setMessages((prev) => [...prev, { role: "assistant", content: "Sorry, something went wrong." }]);
|
||||||
|
@ -37,6 +37,7 @@ import parseAIJson from "../utils/parseAIJson.js"; // your shared parser
|
|||||||
import InfoTooltip from "./ui/infoTooltip.js";
|
import InfoTooltip from "./ui/infoTooltip.js";
|
||||||
import differenceInMonths from 'date-fns/differenceInMonths';
|
import differenceInMonths from 'date-fns/differenceInMonths';
|
||||||
|
|
||||||
|
|
||||||
import "../styles/legacy/MilestoneTimeline.legacy.css";
|
import "../styles/legacy/MilestoneTimeline.legacy.css";
|
||||||
|
|
||||||
// --------------
|
// --------------
|
||||||
@ -1295,6 +1296,14 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
} // single rebuild
|
} // single rebuild
|
||||||
}, [financialProfile, scenarioRow, careerProfileId]); // ← NOTICE: no buildProjection here
|
}, [financialProfile, scenarioRow, careerProfileId]); // ← NOTICE: no buildProjection here
|
||||||
|
|
||||||
|
const handleMilestonesCreated = useCallback(
|
||||||
|
(count = 0) => {
|
||||||
|
// optional toast
|
||||||
|
if (count) console.log(`💾 ${count} milestone(s) saved – refreshing list…`);
|
||||||
|
fetchMilestones();
|
||||||
|
},
|
||||||
|
[fetchMilestones]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-4">
|
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-4">
|
||||||
@ -1524,7 +1533,10 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
|
|
||||||
{/* Milestones – stacked list under chart */}
|
{/* Milestones – stacked list under chart */}
|
||||||
<div className="mt-4 bg-white p-4 rounded shadow">
|
<div className="mt-4 bg-white p-4 rounded shadow">
|
||||||
<h4 className="text-lg font-semibold mb-2">Milestones</h4>
|
<h4 className="text-lg font-semibold mb-2">
|
||||||
|
Milestones
|
||||||
|
<InfoTooltip message="Milestones are career or life events—promotions, relocations, degree completions, etc.—that may change your income or spending. They feed directly into the financial projection if they have a financial impact." />
|
||||||
|
</h4>
|
||||||
<MilestonePanel
|
<MilestonePanel
|
||||||
groups={milestoneGroups}
|
groups={milestoneGroups}
|
||||||
onEdit={onEditMilestone}
|
onEdit={onEditMilestone}
|
||||||
|
@ -2,263 +2,257 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import authFetch from '../utils/authFetch.js';
|
import authFetch from '../utils/authFetch.js';
|
||||||
|
|
||||||
const MilestoneAddModal = ({
|
/* ─────────────────────────────────────────────────────────
|
||||||
|
CONSTANTS
|
||||||
|
───────────────────────────────────────────────────────── */
|
||||||
|
const IMPACT_TYPES = ['salary', 'cost', 'tuition', 'note'];
|
||||||
|
const FREQ_OPTIONS = ['ONE_TIME', 'MONTHLY'];
|
||||||
|
|
||||||
|
export default function MilestoneAddModal({
|
||||||
show,
|
show,
|
||||||
onClose,
|
onClose,
|
||||||
defaultScenarioId,
|
scenarioId, // active scenario UUID
|
||||||
scenarioId, // which scenario this milestone applies to
|
editMilestone = null // pass full row when editing
|
||||||
editMilestone, // if editing an existing milestone, pass its data
|
}) {
|
||||||
}) => {
|
/* ────────────── state ────────────── */
|
||||||
// Basic milestone fields
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
// We'll store an array of impacts. Each impact is { impact_type, direction, amount, start_month, end_month }
|
|
||||||
const [impacts, setImpacts] = useState([]);
|
const [impacts, setImpacts] = useState([]);
|
||||||
|
|
||||||
// On open, if editing, fill in existing fields
|
/* ────────────── init / reset ────────────── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) return; // if modal is hidden, do nothing
|
if (!show) return;
|
||||||
|
|
||||||
if (editMilestone) {
|
if (editMilestone) {
|
||||||
setTitle(editMilestone.title || '');
|
setTitle(editMilestone.title || '');
|
||||||
setDescription(editMilestone.description || '');
|
setDescription(editMilestone.description || '');
|
||||||
// If editing, you might fetch existing impacts from the server or they could be passed in
|
setImpacts(editMilestone.impacts || []);
|
||||||
if (editMilestone.impacts) {
|
|
||||||
setImpacts(editMilestone.impacts);
|
|
||||||
} else {
|
} else {
|
||||||
// fetch from backend if needed
|
setTitle(''); setDescription(''); setImpacts([]);
|
||||||
// e.g. GET /api/premium/milestones/:id/impacts
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Creating a new milestone
|
|
||||||
setTitle('');
|
|
||||||
setDescription('');
|
|
||||||
setImpacts([]);
|
|
||||||
}
|
}
|
||||||
}, [show, editMilestone]);
|
}, [show, editMilestone]);
|
||||||
|
|
||||||
// Handler: add a new blank impact
|
/* ────────────── helpers ────────────── */
|
||||||
const handleAddImpact = () => {
|
const addImpactRow = () =>
|
||||||
setImpacts((prev) => [
|
setImpacts(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
impact_type: 'ONE_TIME',
|
impact_type : 'cost',
|
||||||
|
frequency : 'ONE_TIME',
|
||||||
direction : 'subtract',
|
direction : 'subtract',
|
||||||
amount : 0,
|
amount : 0,
|
||||||
start_month: 0,
|
start_date : '', // ISO yyyy‑mm‑dd
|
||||||
end_month: null
|
end_date : '' // blank ⇒ indefinite
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
};
|
|
||||||
|
|
||||||
// Handler: update a single impact in the array
|
const updateImpact = (idx, field, value) =>
|
||||||
const handleImpactChange = (index, field, value) => {
|
setImpacts(prev => {
|
||||||
setImpacts((prev) => {
|
const copy = [...prev];
|
||||||
const updated = [...prev];
|
copy[idx] = { ...copy[idx], [field]: value };
|
||||||
updated[index] = { ...updated[index], [field]: value };
|
return copy;
|
||||||
return updated;
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
// Handler: remove an impact row
|
const removeImpact = idx =>
|
||||||
const handleRemoveImpact = (index) => {
|
setImpacts(prev => prev.filter((_, i) => i !== idx));
|
||||||
setImpacts((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handler: Save everything to the server
|
/* ────────────── save ────────────── */
|
||||||
const handleSave = async () => {
|
async function handleSave() {
|
||||||
try {
|
try {
|
||||||
let milestoneId;
|
/* 1️⃣ create OR update the milestone row */
|
||||||
if (editMilestone) {
|
let milestoneId = editMilestone?.id;
|
||||||
// 1) Update existing milestone
|
if (milestoneId) {
|
||||||
milestoneId = editMilestone.id;
|
|
||||||
await authFetch(`api/premium/milestones/${milestoneId}`, {
|
await authFetch(`api/premium/milestones/${milestoneId}`, {
|
||||||
method : 'PUT',
|
method : 'PUT',
|
||||||
headers: { 'Content-Type':'application/json' },
|
headers: { 'Content-Type':'application/json' },
|
||||||
body: JSON.stringify({
|
body : JSON.stringify({ title, description })
|
||||||
title,
|
|
||||||
description,
|
|
||||||
scenario_id: scenarioId,
|
|
||||||
// Possibly other fields
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
// Then handle impacts below...
|
|
||||||
} else {
|
} else {
|
||||||
// 1) Create new milestone
|
|
||||||
const res = await authFetch('api/premium/milestones', {
|
const res = await authFetch('api/premium/milestones', {
|
||||||
method : 'POST',
|
method : 'POST',
|
||||||
headers: { 'Content-Type':'application/json' },
|
headers: { 'Content-Type':'application/json' },
|
||||||
body : JSON.stringify({
|
body : JSON.stringify({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
scenario_id: scenarioId
|
career_profile_id: scenarioId
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to create milestone');
|
if (!res.ok) throw new Error('Milestone create failed');
|
||||||
const created = await res.json();
|
const json = await res.json();
|
||||||
milestoneId = created.id; // assuming the response returns { id: newMilestoneId }
|
milestoneId = json.id ?? json[0]?.id; // array OR obj
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) For the impacts, we can do a batch approach or individual calls
|
/* 2️⃣ upsert each impact (one call per row) */
|
||||||
// For simplicity, let's do multiple POST calls
|
for (const imp of impacts) {
|
||||||
for (const impact of impacts) {
|
const body = {
|
||||||
// If editing, you might do a PUT if the impact already has an id
|
milestone_id : milestoneId,
|
||||||
|
impact_type : imp.impact_type,
|
||||||
|
frequency : imp.frequency, // ONE_TIME / MONTHLY
|
||||||
|
direction : imp.direction,
|
||||||
|
amount : parseFloat(imp.amount) || 0,
|
||||||
|
start_date : imp.start_date || null,
|
||||||
|
end_date : imp.frequency === 'MONTHLY' && imp.end_date
|
||||||
|
? imp.end_date
|
||||||
|
: null
|
||||||
|
};
|
||||||
await authFetch('api/premium/milestone-impacts', {
|
await authFetch('api/premium/milestone-impacts', {
|
||||||
method : 'POST',
|
method : 'POST',
|
||||||
headers: { 'Content-Type':'application/json' },
|
headers: { 'Content-Type':'application/json' },
|
||||||
body: JSON.stringify({
|
body : JSON.stringify(body)
|
||||||
milestone_id: milestoneId,
|
|
||||||
impact_type: impact.impact_type,
|
|
||||||
direction: impact.direction,
|
|
||||||
amount: parseFloat(impact.amount) || 0,
|
|
||||||
start_month: parseInt(impact.start_month, 10) || 0,
|
|
||||||
end_month: impact.end_month !== null
|
|
||||||
? parseInt(impact.end_month, 10)
|
|
||||||
: null,
|
|
||||||
created_at: new Date().toISOString().slice(0, 10),
|
|
||||||
updated_at: new Date().toISOString().slice(0, 10)
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Done, close modal
|
onClose(true); // ← parent will refetch
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save milestone + impacts:', err);
|
console.error('Save failed:', err);
|
||||||
// Show some UI error if needed
|
alert('Sorry, something went wrong – please try again.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
/* ────────────── UI ────────────── */
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop">
|
<div className="modal-backdrop">
|
||||||
<div className="modal-container">
|
<div className="modal-container w-full max-w-lg">
|
||||||
<h2 className="text-xl font-bold mb-2">
|
<h2 className="text-xl font-bold mb-2">
|
||||||
{editMilestone ? 'Edit Milestone' : 'Add Milestone'}
|
{editMilestone ? 'Edit Milestone' : 'Add Milestone'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="mb-3">
|
{/* basic fields */}
|
||||||
<label className="block font-semibold">Title</label>
|
<label className="block font-semibold mt-2">Title</label>
|
||||||
<input
|
<input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={e => setTitle(e.target.value)}
|
||||||
className="border w-full px-2 py-1"
|
className="border w-full px-2 py-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
<label className="block font-semibold mt-4">Description</label>
|
||||||
<label className="block font-semibold">Description</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
className="border w-full px-2 py-1"
|
className="border w-full px-2 py-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Impacts Section */}
|
{/* impacts */}
|
||||||
<h3 className="text-lg font-semibold mt-4">Financial Impacts</h3>
|
<h3 className="text-lg font-semibold mt-6">Financial Impacts</h3>
|
||||||
{impacts.map((impact, i) => (
|
|
||||||
<div key={i} className="border rounded p-2 my-2">
|
{impacts.map((imp, i) => (
|
||||||
<div className="flex items-center justify-between">
|
<div key={i} className="border rounded p-3 mt-4 space-y-2">
|
||||||
<p>Impact #{i + 1}</p>
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium">Impact #{i + 1}</span>
|
||||||
<button
|
<button
|
||||||
className="text-red-500"
|
className="text-red-600 text-sm"
|
||||||
onClick={() => handleRemoveImpact(i)}
|
onClick={() => removeImpact(i)}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Impact Type */}
|
{/* type */}
|
||||||
<div className="mt-2">
|
<div>
|
||||||
<label className="block font-semibold">Type</label>
|
<label className="block text-sm font-semibold">Type</label>
|
||||||
<select
|
<select
|
||||||
value={impact.impact_type}
|
value={imp.impact_type}
|
||||||
onChange={(e) =>
|
onChange={e => updateImpact(i, 'impact_type', e.target.value)}
|
||||||
handleImpactChange(i, 'impact_type', e.target.value)
|
className="border px-2 py-1 w-full"
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<option value="ONE_TIME">One-Time</option>
|
{IMPACT_TYPES.map(t => (
|
||||||
<option value="MONTHLY">Monthly</option>
|
<option key={t} value={t}>
|
||||||
|
{t === 'salary' ? 'Salary change'
|
||||||
|
: t === 'cost' ? 'Cost / expense'
|
||||||
|
: t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Direction */}
|
{/* frequency */}
|
||||||
<div className="mt-2">
|
<div>
|
||||||
<label className="block font-semibold">Direction</label>
|
<label className="block text-sm font-semibold">Frequency</label>
|
||||||
<select
|
<select
|
||||||
value={impact.direction}
|
value={imp.frequency}
|
||||||
onChange={(e) =>
|
onChange={e => updateImpact(i, 'frequency', e.target.value)}
|
||||||
handleImpactChange(i, 'direction', e.target.value)
|
className="border px-2 py-1 w-full"
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<option value="add">Add (Income)</option>
|
<option value="ONE_TIME">One‑time</option>
|
||||||
<option value="subtract">Subtract (Expense)</option>
|
<option value="MONTHLY">Monthly (recurring)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Amount */}
|
{/* direction */}
|
||||||
<div className="mt-2">
|
<div>
|
||||||
<label className="block font-semibold">Amount</label>
|
<label className="block text-sm font-semibold">Direction</label>
|
||||||
|
<select
|
||||||
|
value={imp.direction}
|
||||||
|
onChange={e => updateImpact(i, 'direction', e.target.value)}
|
||||||
|
className="border px-2 py-1 w-full"
|
||||||
|
>
|
||||||
|
<option value="add">Add (income)</option>
|
||||||
|
<option value="subtract">Subtract (expense)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* amount */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold">Amount ($)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={impact.amount}
|
value={imp.amount}
|
||||||
onChange={(e) =>
|
onChange={e => updateImpact(i, 'amount', e.target.value)}
|
||||||
handleImpactChange(i, 'amount', e.target.value)
|
|
||||||
}
|
|
||||||
className="border px-2 py-1 w-full"
|
className="border px-2 py-1 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Start Month */}
|
{/* dates */}
|
||||||
<div className="mt-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<label className="block font-semibold">Start Month</label>
|
<div>
|
||||||
|
<label className="block text-sm font-semibold">Start date</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="date"
|
||||||
value={impact.start_month}
|
value={imp.start_date}
|
||||||
onChange={(e) =>
|
onChange={e => updateImpact(i, 'start_date', e.target.value)}
|
||||||
handleImpactChange(i, 'start_month', e.target.value)
|
|
||||||
}
|
|
||||||
className="border px-2 py-1 w-full"
|
className="border px-2 py-1 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* End Month (for MONTHLY, can be null/blank if indefinite) */}
|
{imp.frequency === 'MONTHLY' && (
|
||||||
{impact.impact_type === 'MONTHLY' && (
|
<div>
|
||||||
<div className="mt-2">
|
<label className="block text-sm font-semibold">
|
||||||
<label className="block font-semibold">End Month (optional)</label>
|
End date (optional)
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="date"
|
||||||
value={impact.end_month || ''}
|
value={imp.end_date || ''}
|
||||||
onChange={(e) =>
|
onChange={e => updateImpact(i, 'end_date', e.target.value)}
|
||||||
handleImpactChange(i, 'end_month', e.target.value || null)
|
|
||||||
}
|
|
||||||
className="border px-2 py-1 w-full"
|
className="border px-2 py-1 w-full"
|
||||||
placeholder="Leave blank for indefinite"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<button onClick={handleAddImpact} className="bg-gray-200 px-3 py-1 my-2">
|
<button
|
||||||
+ Add Impact
|
onClick={addImpactRow}
|
||||||
|
className="bg-gray-200 px-4 py-1 rounded mt-4"
|
||||||
|
>
|
||||||
|
+ Add impact
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Modal Actions */}
|
{/* actions */}
|
||||||
<div className="flex justify-end mt-4">
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
<button className="mr-2" onClick={onClose}>
|
<button onClick={() => onClose(false)} className="px-4 py-2">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={handleSave}>
|
<button
|
||||||
Save Milestone
|
onClick={handleSave}
|
||||||
|
className="bg-blue-600 text-white px-5 py-2 rounded"
|
||||||
|
>
|
||||||
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default MilestoneAddModal;
|
|
||||||
|
@ -1,19 +1,13 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
// src/components/MilestoneEditModal.js
|
||||||
import { Button } from "./ui/button.js";
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import authFetch from "../utils/authFetch.js";
|
import { Button } from './ui/button.js';
|
||||||
import MilestoneCopyWizard from "./MilestoneCopyWizard.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) : '');
|
||||||
|
|
||||||
/**
|
|
||||||
* 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({
|
export default function MilestoneEditModal({
|
||||||
careerProfileId,
|
careerProfileId,
|
||||||
milestones: incomingMils = [],
|
milestones: incomingMils = [],
|
||||||
@ -21,596 +15,506 @@ export default function MilestoneEditModal({
|
|||||||
fetchMilestones,
|
fetchMilestones,
|
||||||
onClose
|
onClose
|
||||||
}) {
|
}) {
|
||||||
/* ────────────────────────────────
|
/* ───────────────── state */
|
||||||
Local state mirrors ScenarioContainer
|
|
||||||
*/
|
|
||||||
const [milestones, setMilestones] = useState(incomingMils);
|
const [milestones, setMilestones] = useState(incomingMils);
|
||||||
const [editingMilestoneId, setEditingMilestoneId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [newMilestoneMap, setNewMilestoneMap] = useState({});
|
const [draft, setDraft] = useState({}); // id → {…fields}
|
||||||
const [addingNewMilestone, setAddingNewMilestone] = useState(false);
|
const [originalImpactIdsMap, setOriginalImpactIdsMap] = useState({});
|
||||||
const [newMilestoneData, setNewMilestoneData] = useState({
|
const [addingNew, setAddingNew] = useState(false);
|
||||||
title: "",
|
const [newMilestone, setNewMilestone] = useState({
|
||||||
description: "",
|
title:'', description:'', date:'', progress:0, newSalary:'',
|
||||||
date: "",
|
impacts:[], isUniversal:0
|
||||||
progress: 0,
|
|
||||||
newSalary: "",
|
|
||||||
impacts: [],
|
|
||||||
isUniversal: 0
|
|
||||||
});
|
});
|
||||||
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
const [MilestoneCopyWizard, setMilestoneCopyWizard] = useState(null);
|
||||||
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
|
const [isSavingNew , setIsSavingNew ] = useState(false);
|
||||||
|
|
||||||
function toSqlDate(val) {
|
/* keep list in sync with prop */
|
||||||
if (!val) return ''; // null | undefined | '' | 0
|
useEffect(()=> setMilestones(incomingMils), [incomingMils]);
|
||||||
return String(val).slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* keep milestones in sync with prop */
|
/* --------------------------------------------------------- *
|
||||||
useEffect(() => {
|
* Load impacts for one milestone then open its accordion
|
||||||
setMilestones(incomingMils);
|
* --------------------------------------------------------- */
|
||||||
}, [incomingMils]);
|
const openEditor = useCallback(async (m) => {
|
||||||
|
|
||||||
/* ────────────────────────────────
|
setEditingId(m.id);
|
||||||
Inline-edit helpers
|
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=>({
|
||||||
// 1️⃣ fetch impacts + open editor ── moved **up** so the next effect
|
id:i.id,
|
||||||
// can safely reference it in its dependency array
|
impact_type : i.impact_type||'ONE_TIME',
|
||||||
const loadMilestoneImpacts = useCallback(async (m) => {
|
direction : i.direction||'subtract',
|
||||||
try {
|
amount : i.amount||0,
|
||||||
const res = await authFetch(
|
start_date : toSqlDate(i.start_date),
|
||||||
`/api/premium/milestone-impacts?milestone_id=${m.id}`
|
end_date : toSqlDate(i.end_date)
|
||||||
);
|
|
||||||
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
|
setDraft(d => ({ ...d,
|
||||||
setNewMilestoneMap(prev => ({
|
|
||||||
...prev,
|
|
||||||
[m.id]: {
|
[m.id]: {
|
||||||
title : m.title||'',
|
title : m.title||'',
|
||||||
description : m.description||'',
|
description : m.description||'',
|
||||||
date : toSqlDate(m.date) || '',
|
date : toSqlDate(m.date),
|
||||||
progress : m.progress||0,
|
progress : m.progress||0,
|
||||||
newSalary : m.new_salary||'',
|
newSalary : m.new_salary||'',
|
||||||
impacts,
|
impacts : imps,
|
||||||
isUniversal : m.is_universal?1:0
|
isUniversal : m.is_universal?1:0
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
setOriginalImpactIdsMap(p => ({ ...p, [m.id]: imps.map(i=>i.id)}));
|
||||||
|
}, [editingId]);
|
||||||
|
|
||||||
// snapshot of original impact IDs
|
const handleAccordionClick = (m) => {
|
||||||
setOriginalImpactIdsMap(prev => ({
|
if (editingId === m.id) {
|
||||||
...prev,
|
setEditingId(null); // just close
|
||||||
[m.id]: impacts.map(i => i.id)
|
} else {
|
||||||
}));
|
openEditor(m); // open + fetch
|
||||||
|
|
||||||
setEditingMilestoneId(m.id); // open accordion
|
|
||||||
} catch (err) {
|
|
||||||
console.error('loadImpacts', err);
|
|
||||||
}
|
}
|
||||||
}, []); // ← useCallback deps (none)
|
|
||||||
|
|
||||||
// NOW the effect that calls it; declared **after** the callback
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedMilestone) {
|
|
||||||
loadMilestoneImpacts(selectedMilestone);
|
|
||||||
}
|
|
||||||
}, [selectedMilestone, loadMilestoneImpacts]);
|
|
||||||
|
|
||||||
const [originalImpactIdsMap, setOriginalImpactIdsMap] = useState({});
|
|
||||||
|
|
||||||
|
|
||||||
/* 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 */
|
/* open editor automatically when parent passed selectedMilestone */
|
||||||
const updateInlineImpact = (mid, idx, field, value) => {
|
useEffect(()=>{ if(selectedMilestone) openEditor(selectedMilestone)},[selectedMilestone,openEditor]);
|
||||||
setNewMilestoneMap(prev => {
|
|
||||||
const m = prev[mid];
|
/* --------------------------------------------------------- *
|
||||||
if (!m) return prev;
|
* Handlers – shared small helpers
|
||||||
const impacts = [...m.impacts];
|
* --------------------------------------------------------- */
|
||||||
impacts[idx] = { ...impacts[idx], [field]: value };
|
const updateImpact = (mid, idx, field, value) =>
|
||||||
return { ...prev, [mid]: { ...m, impacts } };
|
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 }};
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
/* 4️⃣ add an empty impact row */
|
const addImpactRow = (mid) =>
|
||||||
const addInlineImpact = (mid) => {
|
setDraft(p=>{
|
||||||
setNewMilestoneMap(prev => {
|
const d=p[mid]; if(!d) return p;
|
||||||
const m = prev[mid];
|
const blank = { impact_type:'ONE_TIME', direction:'subtract', amount:0, start_date:'', end_date:'' };
|
||||||
if (!m) return prev;
|
return { ...p, [mid]: { ...d, impacts:[...d.impacts, blank]}};
|
||||||
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 removeImpactRow = (mid,idx)=>
|
||||||
const removeInlineImpact = (mid, idx) => {
|
setDraft(p=>{
|
||||||
setNewMilestoneMap(prev => {
|
const d=p[mid]; if(!d) return p;
|
||||||
const m = prev[mid];
|
const c=[...d.impacts]; c.splice(idx,1);
|
||||||
if (!m) return prev;
|
return {...p,[mid]:{...d,impacts:c}};
|
||||||
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) => {
|
* Persist edits (UPDATE / create / delete diff)
|
||||||
const data = newMilestoneMap[m.id];
|
* --------------------------------------------------------- */
|
||||||
if (!data) return;
|
async function saveMilestone(m){
|
||||||
|
if(isSavingEdit) return; // guard
|
||||||
|
const d = draft[m.id]; if(!d) return;
|
||||||
|
setIsSavingEdit(true);
|
||||||
|
|
||||||
/* --- update the milestone header --- */
|
/* header */
|
||||||
const payload = {
|
const payload = {
|
||||||
milestone_type:'Financial',
|
milestone_type:'Financial',
|
||||||
title : data.title,
|
title:d.title, description:d.description, date:toSqlDate(d.date),
|
||||||
description : data.description,
|
career_profile_id:careerProfileId, progress:d.progress,
|
||||||
date : toSqlDate(data.date),
|
status:d.progress>=100?'completed':'planned',
|
||||||
career_profile_id : careerProfileId,
|
new_salary:d.newSalary?parseFloat(d.newSalary):null,
|
||||||
progress : data.progress,
|
is_universal:d.isUniversal
|
||||||
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}`,{
|
const res = await authFetch(`/api/premium/milestones/${m.id}`,{
|
||||||
method : 'PUT',
|
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body : JSON.stringify(payload)
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if(!res.ok){ alert('Save failed'); return;}
|
||||||
const saved = await res.json();
|
const saved = await res.json();
|
||||||
|
|
||||||
/* --- figure out what changed ---------------------------------- */
|
/* impacts diff */
|
||||||
const originalIds = originalImpactIdsMap[m.id]||[];
|
const originalIds = originalImpactIdsMap[m.id]||[];
|
||||||
const currentIds = (data.impacts || []).map(i => i.id).filter(Boolean);
|
const currentIds = d.impacts.map(i=>i.id).filter(Boolean);
|
||||||
const toDelete = originalIds.filter(id => !currentIds.includes(id));
|
/* deletions */
|
||||||
|
for(const id of originalIds.filter(x=>!currentIds.includes(x))){
|
||||||
/* --- deletions first --- */
|
await authFetch(`/api/premium/milestone-impacts/${id}`,{method:'DELETE'});
|
||||||
for (const delId of toDelete) {
|
|
||||||
await authFetch(`/api/premium/milestone-impacts/${delId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
/* upserts */
|
||||||
/* --- creates / updates --- */
|
for(const imp of d.impacts){
|
||||||
for (const imp of data.impacts) {
|
const body = {
|
||||||
const impPayload = {
|
|
||||||
milestone_id:saved.id,
|
milestone_id:saved.id,
|
||||||
impact_type:imp.impact_type,
|
impact_type:imp.impact_type,
|
||||||
direction : imp.impact_type === "salary" ? "add" : imp.direction,
|
direction:imp.impact_type==='salary'?'add':imp.direction,
|
||||||
amount:parseFloat(imp.amount)||0,
|
amount:parseFloat(imp.amount)||0,
|
||||||
start_date : toSqlDate(imp.start_date) || null,
|
start_date:imp.start_date||null,
|
||||||
end_date : toSqlDate(imp.end_date) || null
|
end_date:imp.end_date||null
|
||||||
};
|
};
|
||||||
|
|
||||||
if(imp.id){
|
if(imp.id){
|
||||||
await authFetch(`/api/premium/milestone-impacts/${imp.id}`,{
|
await authFetch(`/api/premium/milestone-impacts/${imp.id}`,{
|
||||||
method : 'PUT',
|
method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body : JSON.stringify(impPayload)
|
|
||||||
});
|
|
||||||
}else{
|
}else{
|
||||||
await authFetch('/api/premium/milestone-impacts',{
|
await authFetch('/api/premium/milestone-impacts',{
|
||||||
method : 'POST',
|
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body : JSON.stringify(impPayload)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- refresh + close --- */
|
|
||||||
await fetchMilestones();
|
await fetchMilestones();
|
||||||
setEditingMilestoneId(null);
|
setEditingId(null);
|
||||||
|
setIsSavingEdit(false);
|
||||||
} catch (err) {
|
onClose(true);
|
||||||
alert('Failed to save milestone');
|
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
/* ───────────── misc helpers the JSX still calls ───────────── */
|
async function deleteMilestone(m){
|
||||||
|
if(!window.confirm(`Delete “${m.title}”?`)) return;
|
||||||
/* A) delete one milestone row altogether */
|
await authFetch(`/api/premium/milestones/${m.id}`,{method:'DELETE'});
|
||||||
const deleteMilestone = async (milestone) => {
|
await fetchMilestones();
|
||||||
if (!window.confirm(`Delete “${milestone.title}” ?`)) return;
|
onClose(true);
|
||||||
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 = () => {
|
* New‑milestone helpers (create flow)
|
||||||
setNewMilestoneData(prev => ({
|
* --------------------------------------------------------- */
|
||||||
...prev,
|
const addBlankImpactToNew = ()=> setNewMilestone(n=>({
|
||||||
impacts: [
|
...n, impacts:[...n.impacts,{impact_type:'ONE_TIME',direction:'subtract',amount:0,start_date:'',end_date:''}]
|
||||||
...prev.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};
|
||||||
/* C) create an entirely new milestone + its impacts */
|
});
|
||||||
const saveNewMilestone = async () => {
|
const removeNewImpact = (idx)=> setNewMilestone(n=>{
|
||||||
if (!newMilestoneData.title.trim() || !newMilestoneData.date.trim()) {
|
const c=[...n.impacts]; c.splice(idx,1); return {...n,impacts:c};
|
||||||
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 */
|
async function saveNew(){
|
||||||
for (const imp of newMilestoneData.impacts) {
|
if(isSavingNew) return;
|
||||||
const impPayload = {
|
if(!newMilestone.title.trim()||!newMilestone.date.trim()){
|
||||||
milestone_id : created.id,
|
alert('Need title & date'); return;
|
||||||
impact_type : imp.impact_type,
|
}
|
||||||
direction : imp.impact_type === "salary" ? "add" : imp.direction,
|
setIsSavingNew(true);
|
||||||
amount : parseFloat(imp.amount) || 0,
|
const hdr = { title:newMilestone.title, description:newMilestone.description,
|
||||||
start_date : toSqlDate(imp.start_date) || null,
|
date:toSqlDate(newMilestone.date), career_profile_id:careerProfileId,
|
||||||
end_date : toSqlDate(imp.end_date) || null
|
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',{
|
await authFetch('/api/premium/milestone-impacts',{
|
||||||
method : 'POST',
|
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body : JSON.stringify(impPayload)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
await fetchMilestones();
|
||||||
await fetchMilestones(); // refresh list
|
setAddingNew(false);
|
||||||
setAddingNewMilestone(false); // collapse the new-mile form
|
|
||||||
onClose(true);
|
onClose(true);
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to save milestone');
|
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
/* ────────────────────────────────
|
/* ══════════════════════════════════════════════════════════════ */
|
||||||
Render
|
/* RENDER */
|
||||||
*/
|
/* ══════════════════════════════════════════════════════════════ */
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-[9999] flex items-start justify-center overflow-y-auto bg-black/40">
|
||||||
style={{
|
<div className="bg-white w-full max-w-3xl mx-4 my-10 rounded-md shadow-lg ring-1 ring-gray-300">
|
||||||
position: "fixed",
|
{/* header */}
|
||||||
inset: 0,
|
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||||
background: "rgba(0,0,0,0.4)",
|
<div>
|
||||||
zIndex: 9999,
|
<h2 className="text-lg font-semibold">Milestones</h2>
|
||||||
display: "flex",
|
<p className="text-xs text-gray-500">
|
||||||
alignItems: "flex-start",
|
Track important events and their financial impact on this scenario.
|
||||||
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> {toSqlDate(m.date)}
|
|
||||||
</p>
|
</p>
|
||||||
<p>Progress: {m.progress}%</p>
|
</div>
|
||||||
|
<Button variant="ghost" onClick={() => onClose(false)}>✕</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* inline form */}
|
{/* body */}
|
||||||
{hasEditOpen && (
|
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto">
|
||||||
<div style={{ border: "1px solid #aaa", marginTop: "1rem", padding: "0.5rem" }}>
|
{/* 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
|
<input
|
||||||
type="text"
|
className="input"
|
||||||
placeholder="Title"
|
value={d.title||''}
|
||||||
value={data.title}
|
onChange={e=>setDraft(p=>({...p,[m.id]:{...p[m.id],title:e.target.value}}))}
|
||||||
style={{ display: "block", marginBottom: "0.5rem" }}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewMilestoneMap((p) => ({
|
|
||||||
...p,
|
|
||||||
[m.id]: { ...p[m.id], title: e.target.value }
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<textarea
|
</div>
|
||||||
placeholder="Description"
|
<div className="space-y-1">
|
||||||
value={data.description}
|
<label className="text-sm font-medium">Date</label>
|
||||||
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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={data.date || ""}
|
className="input"
|
||||||
style={{ display: "block", marginBottom: "0.5rem" }}
|
value={d.date||''}
|
||||||
onChange={(e) =>
|
onChange={e=>setDraft(p=>({...p,[m.id]:{...p[m.id],date:e.target.value}}))}
|
||||||
setNewMilestoneMap((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 */}
|
{/* impacts */}
|
||||||
<div style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}>
|
<div>
|
||||||
<h6>Financial Impacts</h6>
|
<div className="flex items-center justify-between">
|
||||||
{data.impacts?.map((imp, idx) => (
|
<h4 className="font-medium text-sm">Financial impacts</h4>
|
||||||
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
|
<Button size="xs" onClick={()=>addImpactRow(m.id)}>+ Add impact</Button>
|
||||||
<label>Type:</label>
|
</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
|
<select
|
||||||
|
className="input"
|
||||||
value={imp.impact_type}
|
value={imp.impact_type}
|
||||||
onChange={(e) => updateInlineImpact(m.id, idx, "impact_type", e.target.value)}
|
onChange={e=>updateImpact(m.id,idx,'impact_type',e.target.value)}>
|
||||||
>
|
|
||||||
<option value="salary">Salary (annual)</option>
|
<option value="salary">Salary (annual)</option>
|
||||||
<option value="ONE_TIME">One-Time</option>
|
<option value="ONE_TIME">One‑time</option>
|
||||||
<option value="MONTHLY">Monthly</option>
|
<option value="MONTHLY">Monthly</option>
|
||||||
</select>
|
</select>
|
||||||
<label>Direction:</label>
|
</div>
|
||||||
{imp.impact_type !== "salary" && (
|
{/* direction – hide for salary */}
|
||||||
|
{imp.impact_type!=='salary' && (
|
||||||
|
<div>
|
||||||
|
<label className="label-xs">Direction</label>
|
||||||
<select
|
<select
|
||||||
|
className="input"
|
||||||
value={imp.direction}
|
value={imp.direction}
|
||||||
onChange={(e) => {
|
onChange={e=>updateImpact(m.id,idx,'direction',e.target.value)}>
|
||||||
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="add">Add</option>
|
||||||
<option value="subtract">Subtract</option>
|
<option value="subtract">Subtract</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* amount */}
|
||||||
<label>Amount:</label>
|
<div>
|
||||||
|
<label className="label-xs">Amount</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
className="input"
|
||||||
value={imp.amount}
|
value={imp.amount}
|
||||||
onChange={(e) => updateInlineImpact(m.id, idx, "amount", e.target.value)}
|
onChange={e=>updateImpact(m.id,idx,'amount',e.target.value)}
|
||||||
/>
|
/>
|
||||||
<label>Start:</label>
|
</div>
|
||||||
|
{/* dates */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="label-xs">Start</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={imp.start_date || ""}
|
className="input"
|
||||||
onChange={(e) => updateInlineImpact(m.id, idx, "start_date", e.target.value)}
|
value={imp.start_date}
|
||||||
|
onChange={e=>updateImpact(m.id,idx,'start_date',e.target.value)}
|
||||||
/>
|
/>
|
||||||
{imp.impact_type === "MONTHLY" && (
|
</div>
|
||||||
<>
|
{imp.impact_type==='MONTHLY' && (
|
||||||
<label>End:</label>
|
<div>
|
||||||
|
<label className="label-xs">End</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={imp.end_date || ""}
|
className="input"
|
||||||
onChange={(e) => updateInlineImpact(m.id, idx, "end_date", e.target.value)}
|
value={imp.end_date}
|
||||||
|
onChange={e=>updateImpact(m.id,idx,'end_date',e.target.value)}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => removeInlineImpact(m.id, idx)} style={{ marginLeft: "0.5rem", color: "red" }}>
|
</div>
|
||||||
Remove
|
{/* remove */}
|
||||||
|
<Button
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-600"
|
||||||
|
onClick={()=>removeImpactRow(m.id,idx)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button onClick={() => addInlineImpact(m.id)}>+ Financial Impact</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => saveInlineMilestone(m)}>Save</Button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* add‑new toggle */}
|
{/* NEW milestone accordion */}
|
||||||
<Button onClick={() => setAddingNewMilestone((p) => !p)}>
|
<details className="border rounded-md" open={addingNew}>
|
||||||
{addingNewMilestone ? "Cancel New Milestone" : "Add Milestone"}
|
<summary
|
||||||
</Button>
|
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>
|
||||||
|
|
||||||
{addingNewMilestone && (
|
{addingNew && (
|
||||||
<div style={{ border: "1px solid #aaa", padding: "0.5rem", marginTop: "0.5rem" }}>
|
<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
|
<input
|
||||||
type="text"
|
className="input"
|
||||||
placeholder="Title"
|
value={newMilestone.title}
|
||||||
value={newMilestoneData.title}
|
onChange={e=>setNewMilestone(n=>({...n,title:e.target.value}))}
|
||||||
style={{ display: "block", marginBottom: "0.5rem" }}
|
|
||||||
onChange={(e) => setNewMilestoneData((p) => ({ ...p, title: e.target.value }))}
|
|
||||||
/>
|
/>
|
||||||
<textarea
|
</div>
|
||||||
placeholder="Description"
|
<div className="space-y-1">
|
||||||
value={newMilestoneData.description}
|
<label className="label-xs">Date</label>
|
||||||
style={{ display: "block", width: "100%", marginBottom: "0.5rem" }}
|
|
||||||
onChange={(e) => setNewMilestoneData((p) => ({ ...p, description: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<label>Date:</label>
|
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={newMilestoneData.date || ""}
|
className="input"
|
||||||
style={{ display: "block", marginBottom: "0.5rem" }}
|
value={newMilestone.date}
|
||||||
onChange={(e) => setNewMilestoneData((p) => ({ ...p, date: e.target.value }))}
|
onChange={e=>setNewMilestone(n=>({...n,date:e.target.value}))}
|
||||||
/>
|
/>
|
||||||
<div style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}>
|
</div>
|
||||||
<h6>Impacts</h6>
|
<div className="md:col-span-2 space-y-1">
|
||||||
{newMilestoneData.impacts.map((imp, idx) => (
|
<label className="label-xs">Description</label>
|
||||||
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
|
<textarea
|
||||||
{/* Direction – show only when NOT salary */}
|
rows={2}
|
||||||
{imp.impact_type !== "salary" && (
|
className="input resize-none"
|
||||||
<>
|
value={newMilestone.description}
|
||||||
<label>Add or Subtract?</label>
|
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
|
<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}
|
value={imp.direction}
|
||||||
onChange={(e) => {
|
onChange={e=>updateNewImpact(idx,'direction',e.target.value)}>
|
||||||
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="add">Add</option>
|
||||||
<option value="subtract">Subtract</option>
|
<option value="subtract">Subtract</option>
|
||||||
</select>
|
</select>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label>Amount:</label>
|
<div>
|
||||||
|
<label className="label-xs">Amount</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
className="input"
|
||||||
value={imp.amount}
|
value={imp.amount}
|
||||||
onChange={(e) => {
|
onChange={e=>updateNewImpact(idx,'amount',e.target.value)}
|
||||||
const val = e.target.value;
|
|
||||||
setNewMilestoneData((prev) => {
|
|
||||||
const copy = [...prev.impacts];
|
|
||||||
copy[idx] = { ...copy[idx], amount: val };
|
|
||||||
return { ...prev, impacts: copy };
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<label>Start:</label>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={imp.start_date || ""}
|
className="input"
|
||||||
onChange={(e) => {
|
value={imp.start_date}
|
||||||
const val = e.target.value;
|
onChange={e=>updateNewImpact(idx,'start_date',e.target.value)}
|
||||||
setNewMilestoneData((prev) => {
|
|
||||||
const copy = [...prev.impacts];
|
|
||||||
copy[idx] = { ...copy[idx], start_date: val };
|
|
||||||
return { ...prev, impacts: copy };
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{imp.impact_type === "MONTHLY" && (
|
{imp.impact_type==='MONTHLY' && (
|
||||||
<>
|
|
||||||
<label>End:</label>
|
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={imp.end_date || ""}
|
className="input"
|
||||||
onChange={(e) => {
|
value={imp.end_date}
|
||||||
const val = e.target.value;
|
onChange={e=>updateNewImpact(idx,'end_date',e.target.value)}
|
||||||
setNewMilestoneData((prev) => {
|
|
||||||
const copy = [...prev.impacts];
|
|
||||||
copy[idx] = { ...copy[idx], end_date: val };
|
|
||||||
return { ...prev, impacts: copy };
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
size="icon-xs"
|
||||||
setNewMilestoneData((prev) => {
|
variant="ghost"
|
||||||
const cpy = [...prev.impacts];
|
className="text-red-600"
|
||||||
cpy.splice(idx, 1);
|
onClick={()=>removeNewImpact(idx)}
|
||||||
return { ...prev, impacts: cpy };
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
style={{ color: "red", marginLeft: "0.5rem" }}
|
|
||||||
>
|
>
|
||||||
Remove
|
✕
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button onClick={addNewImpactToNewMilestone}>+ Financial Impact</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={saveNewMilestone}>Add Milestone</Button>
|
</div>
|
||||||
|
{/* save row */}
|
||||||
|
<div className="flex justify-end border-t pt-4">
|
||||||
|
<Button disabled={isSavingNew} onClick={saveNew}>
|
||||||
|
{isSavingNew ? 'Saving…' : 'Save milestone'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Copy Wizard */}
|
{/* footer */}
|
||||||
{copyWizardMilestone && (
|
<div className="px-6 py-4 border-t text-right">
|
||||||
|
<Button variant="secondary" onClick={()=>onClose(false)}>Close</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* COPY wizard */}
|
||||||
|
{MilestoneCopyWizard && (
|
||||||
<MilestoneCopyWizard
|
<MilestoneCopyWizard
|
||||||
milestone={copyWizardMilestone}
|
milestone={MilestoneCopyWizard}
|
||||||
onClose={(didCopy) => {
|
onClose={(didCopy)=>{setMilestoneCopyWizard(null); if(didCopy) fetchMilestones();}}
|
||||||
setCopyWizardMilestone(null);
|
|
||||||
if (didCopy) fetchMilestones();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: "1rem", textAlign: "right" }}>
|
|
||||||
<Button onClick={() => onClose(false)}>Close</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------- tiny utility styles (or swap for Tailwind) ---- */
|
||||||
|
const inputBase = 'border rounded-md w-full px-2 py-1 text-sm';
|
||||||
|
const labelBase = 'block text-xs font-medium text-gray-600';
|
||||||
|
export const input = inputBase; // export so you can reuse
|
||||||
|
export const label = labelBase;
|
||||||
|
@ -461,15 +461,17 @@ milestoneImpacts.forEach((rawImpact) => {
|
|||||||
if (!isActiveThisMonth) return; // skip to next impact
|
if (!isActiveThisMonth) return; // skip to next impact
|
||||||
|
|
||||||
/* ---------- 3. Apply the impact ---------- */
|
/* ---------- 3. Apply the impact ---------- */
|
||||||
const sign = direction === 'add' ? 1 : -1;
|
|
||||||
|
|
||||||
if (type.startsWith('SALARY')) {
|
if (type.startsWith('SALARY')) {
|
||||||
// SALARY = already-monthly | SALARY_ANNUAL = annual → divide by 12
|
// ─── salary changes affect GROSS income ───
|
||||||
const monthlyDelta = type.endsWith('ANNUAL') ? amount / 12 : amount;
|
const monthlyDelta = type.endsWith('ANNUAL') ? amount / 12 : amount;
|
||||||
salaryAdjustThisMonth += sign * monthlyDelta;
|
const salarySign = direction === 'add' ? 1 : -1; // unchanged
|
||||||
|
salaryAdjustThisMonth += salarySign * monthlyDelta;
|
||||||
} else {
|
} else {
|
||||||
// MONTHLY or ONE_TIME expenses / windfalls
|
// ─── everything else is an expense or windfall ───
|
||||||
extraImpactsThisMonth += sign * amount;
|
// “Add” ⇒ money coming *in* ⇒ LOWER expenses
|
||||||
|
// “Subtract” ⇒ money going *out* ⇒ HIGHER expenses
|
||||||
|
const expenseSign = direction === 'add' ? -1 : 1;
|
||||||
|
extraImpactsThisMonth += expenseSign * amount;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user