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('');
const [impacts, setImpacts] = useState([]);
// We'll store an array of impacts. Each impact is { impact_type, direction, amount, start_month, end_month } /* ────────────── init / reset ────────────── */
const [impacts, setImpacts] = useState([]);
// On open, if editing, fill in existing fields
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 {
// fetch from backend if needed
// e.g. GET /api/premium/milestones/:id/impacts
}
} else { } else {
// Creating a new milestone setTitle(''); setDescription(''); setImpacts([]);
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',
direction: 'subtract', frequency : 'ONE_TIME',
amount: 0, direction : 'subtract',
start_month: 0, amount : 0,
end_month: null start_date : '', // ISO yyyymmdd
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>
<input <label className="block text-sm font-semibold">Start date</label>
type="number"
value={impact.start_month}
onChange={(e) =>
handleImpactChange(i, 'start_month', e.target.value)
}
className="border px-2 py-1 w-full"
/>
</div>
{/* End Month (for MONTHLY, can be null/blank if indefinite) */}
{impact.impact_type === 'MONTHLY' && (
<div className="mt-2">
<label className="block font-semibold">End Month (optional)</label>
<input <input
type="number" type="date"
value={impact.end_month || ''} value={imp.start_date}
onChange={(e) => onChange={e => updateImpact(i, 'start_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>
)}
{imp.frequency === 'MONTHLY' && (
<div>
<label className="block text-sm font-semibold">
End date (optional)
</label>
<input
type="date"
value={imp.end_date || ''}
onChange={e => updateImpact(i, 'end_date', e.target.value)}
className="border px-2 py-1 w-full"
/>
</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;

File diff suppressed because it is too large Load Diff

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')) {
// ─── salary changes affect GROSS income ───
if (type.startsWith('SALARY')) {
// SALARY = already-monthly | SALARY_ANNUAL = annual → divide by 12
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.