Fixed MilestoneEditModal and FinancialProjectionService impact signs.

This commit is contained in:
Josh 2025-07-18 17:01:32 +00:00
parent 15d28ce2e8
commit 5ad377b50e
6 changed files with 664 additions and 751 deletions

View File

@ -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." }]);

View File

@ -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}

View File

@ -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 yyyymmdd
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">FinancialImpacts</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">Onetime</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;

View File

@ -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) : '');
/**
* 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({ 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 = () => { * Newmilestone 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, actionoriented label (max 60chars)." />
</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="12 sentences on what success looks like."/>
</label>
<textarea
rows={2}
className="input resize-none"
value={d.description||''}
onChange={e=>setDraft(p=>({...p,[m.id]:{...p[m.id],description:e.target.value}}))}
/>
</div>
</div>
{/* impacts */} {/* 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">Onetime</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>
); );
})} })}
{/* addnew 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">Onetime</option>
<option value="MONTHLY">Monthly</option>
</select>
</div>
{imp.impact_type!=='salary' && (
<div>
<label className="label-xs">Direction</label>
<select
className="input"
value={imp.direction} 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;

View File

@ -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;
} }
}); });

Binary file not shown.