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 }]);
|
||||
|
||||
if (riskData && onAiRiskFetched) onAiRiskFetched(riskData);
|
||||
if (createdMilestones.length && onMilestonesCreated)
|
||||
onMilestonesCreated(createdMilestones.length);
|
||||
if (createdMilestones.length && typeof onMilestonesCreated === 'function') {
|
||||
onMilestonesCreated(); // no arg needed – just refetch
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
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 differenceInMonths from 'date-fns/differenceInMonths';
|
||||
|
||||
|
||||
import "../styles/legacy/MilestoneTimeline.legacy.css";
|
||||
|
||||
// --------------
|
||||
@ -1295,6 +1296,14 @@ const fetchMilestones = useCallback(async () => {
|
||||
} // single rebuild
|
||||
}, [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 (
|
||||
<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 */}
|
||||
<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
|
||||
groups={milestoneGroups}
|
||||
onEdit={onEditMilestone}
|
||||
|
@ -2,263 +2,257 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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,
|
||||
onClose,
|
||||
defaultScenarioId,
|
||||
scenarioId, // which scenario this milestone applies to
|
||||
editMilestone, // if editing an existing milestone, pass its data
|
||||
}) => {
|
||||
// Basic milestone fields
|
||||
const [title, setTitle] = useState('');
|
||||
scenarioId, // active scenario UUID
|
||||
editMilestone = null // pass full row when editing
|
||||
}) {
|
||||
/* ────────────── state ────────────── */
|
||||
const [title, setTitle] = 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 }
|
||||
const [impacts, setImpacts] = useState([]);
|
||||
|
||||
// On open, if editing, fill in existing fields
|
||||
/* ────────────── init / reset ────────────── */
|
||||
useEffect(() => {
|
||||
if (!show) return; // if modal is hidden, do nothing
|
||||
if (!show) return;
|
||||
|
||||
if (editMilestone) {
|
||||
setTitle(editMilestone.title || '');
|
||||
setTitle(editMilestone.title || '');
|
||||
setDescription(editMilestone.description || '');
|
||||
// If editing, you might fetch existing impacts from the server or they could be passed in
|
||||
if (editMilestone.impacts) {
|
||||
setImpacts(editMilestone.impacts);
|
||||
} else {
|
||||
// fetch from backend if needed
|
||||
// e.g. GET /api/premium/milestones/:id/impacts
|
||||
}
|
||||
setImpacts(editMilestone.impacts || []);
|
||||
} else {
|
||||
// Creating a new milestone
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setImpacts([]);
|
||||
setTitle(''); setDescription(''); setImpacts([]);
|
||||
}
|
||||
}, [show, editMilestone]);
|
||||
|
||||
// Handler: add a new blank impact
|
||||
const handleAddImpact = () => {
|
||||
setImpacts((prev) => [
|
||||
/* ────────────── helpers ────────────── */
|
||||
const addImpactRow = () =>
|
||||
setImpacts(prev => [
|
||||
...prev,
|
||||
{
|
||||
impact_type: 'ONE_TIME',
|
||||
direction: 'subtract',
|
||||
amount: 0,
|
||||
start_month: 0,
|
||||
end_month: null
|
||||
impact_type : 'cost',
|
||||
frequency : 'ONE_TIME',
|
||||
direction : 'subtract',
|
||||
amount : 0,
|
||||
start_date : '', // ISO yyyy‑mm‑dd
|
||||
end_date : '' // blank ⇒ indefinite
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
// Handler: update a single impact in the array
|
||||
const handleImpactChange = (index, field, value) => {
|
||||
setImpacts((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
return updated;
|
||||
const updateImpact = (idx, field, value) =>
|
||||
setImpacts(prev => {
|
||||
const copy = [...prev];
|
||||
copy[idx] = { ...copy[idx], [field]: value };
|
||||
return copy;
|
||||
});
|
||||
};
|
||||
|
||||
// Handler: remove an impact row
|
||||
const handleRemoveImpact = (index) => {
|
||||
setImpacts((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
const removeImpact = idx =>
|
||||
setImpacts(prev => prev.filter((_, i) => i !== idx));
|
||||
|
||||
// Handler: Save everything to the server
|
||||
const handleSave = async () => {
|
||||
/* ────────────── save ────────────── */
|
||||
async function handleSave() {
|
||||
try {
|
||||
let milestoneId;
|
||||
if (editMilestone) {
|
||||
// 1) Update existing milestone
|
||||
milestoneId = editMilestone.id;
|
||||
/* 1️⃣ create OR update the milestone row */
|
||||
let milestoneId = editMilestone?.id;
|
||||
if (milestoneId) {
|
||||
await authFetch(`api/premium/milestones/${milestoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
scenario_id: scenarioId,
|
||||
// Possibly other fields
|
||||
})
|
||||
method : 'PUT',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body : JSON.stringify({ title, description })
|
||||
});
|
||||
// Then handle impacts below...
|
||||
} else {
|
||||
// 1) Create new milestone
|
||||
const res = await authFetch('api/premium/milestones', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body : JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
scenario_id: scenarioId
|
||||
career_profile_id: scenarioId
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create milestone');
|
||||
const created = await res.json();
|
||||
milestoneId = created.id; // assuming the response returns { id: newMilestoneId }
|
||||
if (!res.ok) throw new Error('Milestone create failed');
|
||||
const json = await res.json();
|
||||
milestoneId = json.id ?? json[0]?.id; // array OR obj
|
||||
}
|
||||
|
||||
// 2) For the impacts, we can do a batch approach or individual calls
|
||||
// For simplicity, let's do multiple POST calls
|
||||
for (const impact of impacts) {
|
||||
// If editing, you might do a PUT if the impact already has an id
|
||||
/* 2️⃣ upsert each impact (one call per row) */
|
||||
for (const imp of impacts) {
|
||||
const body = {
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
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)
|
||||
})
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body : JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
// Done, close modal
|
||||
onClose();
|
||||
onClose(true); // ← parent will refetch
|
||||
} catch (err) {
|
||||
console.error('Failed to save milestone + impacts:', err);
|
||||
// Show some UI error if needed
|
||||
console.error('Save failed:', err);
|
||||
alert('Sorry, something went wrong – please try again.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ────────────── UI ────────────── */
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{editMilestone ? 'Edit Milestone' : 'Add Milestone'}
|
||||
</h2>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="block font-semibold">Title</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="border w-full px-2 py-1"
|
||||
/>
|
||||
</div>
|
||||
{/* basic fields */}
|
||||
<label className="block font-semibold mt-2">Title</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="border w-full px-2 py-1"
|
||||
/>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="block font-semibold">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="border w-full px-2 py-1"
|
||||
/>
|
||||
</div>
|
||||
<label className="block font-semibold mt-4">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="border w-full px-2 py-1"
|
||||
/>
|
||||
|
||||
{/* Impacts Section */}
|
||||
<h3 className="text-lg font-semibold mt-4">Financial Impacts</h3>
|
||||
{impacts.map((impact, i) => (
|
||||
<div key={i} className="border rounded p-2 my-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p>Impact #{i + 1}</p>
|
||||
{/* impacts */}
|
||||
<h3 className="text-lg font-semibold mt-6">Financial Impacts</h3>
|
||||
|
||||
{impacts.map((imp, i) => (
|
||||
<div key={i} className="border rounded p-3 mt-4 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">Impact #{i + 1}</span>
|
||||
<button
|
||||
className="text-red-500"
|
||||
onClick={() => handleRemoveImpact(i)}
|
||||
className="text-red-600 text-sm"
|
||||
onClick={() => removeImpact(i)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Impact Type */}
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">Type</label>
|
||||
{/* type */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Type</label>
|
||||
<select
|
||||
value={impact.impact_type}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'impact_type', e.target.value)
|
||||
}
|
||||
value={imp.impact_type}
|
||||
onChange={e => updateImpact(i, 'impact_type', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
>
|
||||
<option value="ONE_TIME">One-Time</option>
|
||||
<option value="MONTHLY">Monthly</option>
|
||||
{IMPACT_TYPES.map(t => (
|
||||
<option key={t} value={t}>
|
||||
{t === 'salary' ? 'Salary change'
|
||||
: t === 'cost' ? 'Cost / expense'
|
||||
: t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Direction */}
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">Direction</label>
|
||||
{/* frequency */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Frequency</label>
|
||||
<select
|
||||
value={impact.direction}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'direction', e.target.value)
|
||||
}
|
||||
value={imp.frequency}
|
||||
onChange={e => updateImpact(i, 'frequency', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
>
|
||||
<option value="add">Add (Income)</option>
|
||||
<option value="subtract">Subtract (Expense)</option>
|
||||
<option value="ONE_TIME">One‑time</option>
|
||||
<option value="MONTHLY">Monthly (recurring)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">Amount</label>
|
||||
{/* direction */}
|
||||
<div>
|
||||
<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
|
||||
type="number"
|
||||
value={impact.amount}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'amount', e.target.value)
|
||||
}
|
||||
value={imp.amount}
|
||||
onChange={e => updateImpact(i, 'amount', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start Month */}
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">Start Month</label>
|
||||
<input
|
||||
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>
|
||||
{/* dates */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Start date</label>
|
||||
<input
|
||||
type="number"
|
||||
value={impact.end_month || ''}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'end_month', e.target.value || null)
|
||||
}
|
||||
type="date"
|
||||
value={imp.start_date}
|
||||
onChange={e => updateImpact(i, 'start_date', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
placeholder="Leave blank for indefinite"
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
|
||||
<button onClick={handleAddImpact} className="bg-gray-200 px-3 py-1 my-2">
|
||||
+ Add Impact
|
||||
<button
|
||||
onClick={addImpactRow}
|
||||
className="bg-gray-200 px-4 py-1 rounded mt-4"
|
||||
>
|
||||
+ Add impact
|
||||
</button>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="flex justify-end mt-4">
|
||||
<button className="mr-2" onClick={onClose}>
|
||||
{/* actions */}
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button onClick={() => onClose(false)} className="px-4 py-2">
|
||||
Cancel
|
||||
</button>
|
||||
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={handleSave}>
|
||||
Save Milestone
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-blue-600 text-white px-5 py-2 rounded"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MilestoneAddModal;
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -461,15 +461,17 @@ milestoneImpacts.forEach((rawImpact) => {
|
||||
if (!isActiveThisMonth) return; // skip to next impact
|
||||
|
||||
/* ---------- 3. Apply the impact ---------- */
|
||||
const sign = direction === 'add' ? 1 : -1;
|
||||
|
||||
if (type.startsWith('SALARY')) {
|
||||
// SALARY = already-monthly | SALARY_ANNUAL = annual → divide by 12
|
||||
if (type.startsWith('SALARY')) {
|
||||
// ─── salary changes affect GROSS income ───
|
||||
const monthlyDelta = type.endsWith('ANNUAL') ? amount / 12 : amount;
|
||||
salaryAdjustThisMonth += sign * monthlyDelta;
|
||||
const salarySign = direction === 'add' ? 1 : -1; // unchanged
|
||||
salaryAdjustThisMonth += salarySign * monthlyDelta;
|
||||
} else {
|
||||
// MONTHLY or ONE_TIME expenses / windfalls
|
||||
extraImpactsThisMonth += sign * amount;
|
||||
// ─── everything else is an expense or windfall ───
|
||||
// “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