-
+ {/* frequency */}
+
+
- {/* Amount */}
-
-
+ {/* direction */}
+
+
+
+
+
+ {/* amount */}
+
+
- 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"
/>
- {/* Start Month */}
-
-
-
- handleImpactChange(i, 'start_month', e.target.value)
- }
- className="border px-2 py-1 w-full"
- />
-
-
- {/* End Month (for MONTHLY, can be null/blank if indefinite) */}
- {impact.impact_type === 'MONTHLY' && (
-
-
+ {/* dates */}
+
))}
-
);
-};
-
-export default MilestoneAddModal;
+}
diff --git a/src/components/MilestoneEditModal.js b/src/components/MilestoneEditModal.js
index b01893d..4f56ec9 100644
--- a/src/components/MilestoneEditModal.js
+++ b/src/components/MilestoneEditModal.js
@@ -1,19 +1,13 @@
-import React, { useState, useEffect, useCallback } from "react";
-import { Button } from "./ui/button.js";
-import authFetch from "../utils/authFetch.js";
-import MilestoneCopyWizard from "./MilestoneCopyWizard.js";
+// src/components/MilestoneEditModal.js
+import React, { useState, useEffect, useCallback } from 'react';
+import { Button } from './ui/button.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({
careerProfileId,
milestones: incomingMils = [],
@@ -21,596 +15,506 @@ export default function MilestoneEditModal({
fetchMilestones,
onClose
}) {
- /* ββββββββββββββββββββββββββββββββ
- Local state mirrors ScenarioContainer
- */
- const [milestones, setMilestones] = useState(incomingMils);
- const [editingMilestoneId, setEditingMilestoneId] = useState(null);
- const [newMilestoneMap, setNewMilestoneMap] = useState({});
- const [addingNewMilestone, setAddingNewMilestone] = useState(false);
- const [newMilestoneData, setNewMilestoneData] = useState({
- title: "",
- description: "",
- date: "",
- progress: 0,
- newSalary: "",
- impacts: [],
- isUniversal: 0
- });
- const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
-
- function toSqlDate(val) {
- if (!val) return ''; // null | undefined | '' | 0
- return String(val).slice(0, 10);
- }
-
- /* keep milestones in sync with prop */
- useEffect(() => {
- setMilestones(incomingMils);
- }, [incomingMils]);
-
- /* ββββββββββββββββββββββββββββββββ
- Inline-edit helpers
- ββββββββββββββββββββββββββββββββββ*/
-
- // 1οΈβ£ fetch impacts + open editor ββ moved **up** so the next effect
- // can safely reference it in its dependency array
- const loadMilestoneImpacts = useCallback(async (m) => {
- try {
- const res = await authFetch(
- `/api/premium/milestone-impacts?milestone_id=${m.id}`
- );
- if (!res.ok) throw new Error('impact fetch failed');
- const json = await res.json();
-
- const impacts = (json.impacts || []).map(imp => ({
- id : imp.id,
- impact_type : imp.impact_type || 'ONE_TIME',
- direction : imp.direction || 'subtract',
- amount : imp.amount || 0,
- start_date : toSqlDate(imp.start_date) || '',
- end_date : toSqlDate(imp.end_date) || ''
- }));
-
- // editable copy for the form
- setNewMilestoneMap(prev => ({
- ...prev,
- [m.id]: {
- title : m.title || '',
- description : m.description || '',
- date : toSqlDate(m.date) || '',
- progress : m.progress || 0,
- newSalary : m.new_salary || '',
- impacts,
- isUniversal : m.is_universal ? 1 : 0
- }
- }));
-
- // snapshot of original impact IDs
- setOriginalImpactIdsMap(prev => ({
- ...prev,
- [m.id]: impacts.map(i => i.id)
- }));
-
- 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]);
-
+ /* βββββββββββββββββ state */
+ const [milestones, setMilestones] = useState(incomingMils);
+ const [editingId, setEditingId] = useState(null);
+ const [draft, setDraft] = useState({}); // id β {β¦fields}
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 */
-const updateInlineImpact = (mid, idx, field, value) => {
- setNewMilestoneMap(prev => {
- const m = prev[mid];
- if (!m) return prev;
- const impacts = [...m.impacts];
- impacts[idx] = { ...impacts[idx], [field]: value };
- return { ...prev, [mid]: { ...m, impacts } };
+ const [addingNew, setAddingNew] = useState(false);
+ const [newMilestone, setNewMilestone] = useState({
+ title:'', description:'', date:'', progress:0, newSalary:'',
+ impacts:[], isUniversal:0
});
-};
+ const [MilestoneCopyWizard, setMilestoneCopyWizard] = useState(null);
+ const [isSavingEdit, setIsSavingEdit] = useState(false);
+ const [isSavingNew , setIsSavingNew ] = useState(false);
-/* 4οΈβ£ add an empty impact row */
-const addInlineImpact = (mid) => {
- setNewMilestoneMap(prev => {
- const m = prev[mid];
- if (!m) return prev;
- return {
- ...prev,
- [mid]: {
- ...m,
- impacts: [
- ...m.impacts,
- {
- impact_type : 'ONE_TIME',
- direction : 'subtract',
- amount : 0,
- start_date : '',
- end_date : ''
- }
- ]
+ /* keep list in sync with prop */
+ useEffect(()=> setMilestones(incomingMils), [incomingMils]);
+
+ /* --------------------------------------------------------- *
+ * Load impacts for one milestone then open its accordion
+ * --------------------------------------------------------- */
+ const openEditor = useCallback(async (m) => {
+
+ setEditingId(m.id);
+ 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=>({
+ id:i.id,
+ impact_type : i.impact_type||'ONE_TIME',
+ direction : i.direction||'subtract',
+ amount : i.amount||0,
+ start_date : toSqlDate(i.start_date),
+ end_date : toSqlDate(i.end_date)
+ }));
+
+ setDraft(d => ({ ...d,
+ [m.id]: {
+ title : m.title||'',
+ description : m.description||'',
+ date : toSqlDate(m.date),
+ progress : m.progress||0,
+ newSalary : m.new_salary||'',
+ impacts : imps,
+ isUniversal : m.is_universal?1:0
}
- };
- });
-};
+ }));
+ setOriginalImpactIdsMap(p => ({ ...p, [m.id]: imps.map(i=>i.id)}));
+ }, [editingId]);
-/* 5οΈβ£ remove one impact row (local only β diff happens on save) */
-const removeInlineImpact = (mid, idx) => {
- setNewMilestoneMap(prev => {
- const m = prev[mid];
- if (!m) return prev;
- const clone = [...m.impacts];
- clone.splice(idx, 1);
- return { ...prev, [mid]: { ...m, impacts: clone } };
- });
-};
-
-/* 6οΈβ£ persist the edits β PUT milestone, diff impacts */
-const saveInlineMilestone = async (m) => {
- const data = newMilestoneMap[m.id];
- if (!data) return;
-
- /* --- update the milestone header --- */
- const payload = {
- milestone_type : 'Financial',
- title : data.title,
- description : data.description,
- date : toSqlDate(data.date),
- career_profile_id : careerProfileId,
- progress : data.progress,
- status : data.progress >= 100 ? 'completed' : 'planned',
- new_salary : data.newSalary ? parseFloat(data.newSalary) : null,
- is_universal : data.isUniversal || 0
+ const handleAccordionClick = (m) => {
+ if (editingId === m.id) {
+ setEditingId(null); // just close
+ } else {
+ openEditor(m); // open + fetch
+ }
};
- try {
- const res = await authFetch(`/api/premium/milestones/${m.id}`, {
- method : 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body : JSON.stringify(payload)
+ /* open editor automatically when parent passed selectedMilestone */
+ useEffect(()=>{ if(selectedMilestone) openEditor(selectedMilestone)},[selectedMilestone,openEditor]);
+
+ /* --------------------------------------------------------- *
+ * Handlers β shared small helpers
+ * --------------------------------------------------------- */
+ const updateImpact = (mid, idx, field, value) =>
+ 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 }};
});
- if (!res.ok) throw new Error(await res.text());
+
+ const addImpactRow = (mid) =>
+ setDraft(p=>{
+ const d=p[mid]; if(!d) return p;
+ const blank = { impact_type:'ONE_TIME', direction:'subtract', amount:0, start_date:'', end_date:'' };
+ return { ...p, [mid]: { ...d, impacts:[...d.impacts, blank]}};
+ });
+
+ const removeImpactRow = (mid,idx)=>
+ setDraft(p=>{
+ const d=p[mid]; if(!d) return p;
+ const c=[...d.impacts]; c.splice(idx,1);
+ return {...p,[mid]:{...d,impacts:c}};
+ });
+
+ /* --------------------------------------------------------- *
+ * Persist edits (UPDATE / create / delete diff)
+ * --------------------------------------------------------- */
+ async function saveMilestone(m){
+ if(isSavingEdit) return; // guard
+ const d = draft[m.id]; if(!d) return;
+ setIsSavingEdit(true);
+
+ /* header */
+ const payload = {
+ milestone_type:'Financial',
+ title:d.title, description:d.description, date:toSqlDate(d.date),
+ career_profile_id:careerProfileId, progress:d.progress,
+ status:d.progress>=100?'completed':'planned',
+ new_salary:d.newSalary?parseFloat(d.newSalary):null,
+ is_universal:d.isUniversal
+ };
+ const res = await authFetch(`/api/premium/milestones/${m.id}`,{
+ method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
+ });
+ if(!res.ok){ alert('Save failed'); return;}
const saved = await res.json();
- /* --- figure out what changed ---------------------------------- */
- const originalIds = originalImpactIdsMap[m.id] || [];
- const currentIds = (data.impacts || []).map(i => i.id).filter(Boolean);
- const toDelete = originalIds.filter(id => !currentIds.includes(id));
-
- /* --- deletions first --- */
- for (const delId of toDelete) {
- await authFetch(`/api/premium/milestone-impacts/${delId}`, {
- method: 'DELETE'
- });
+ /* impacts diff */
+ const originalIds = originalImpactIdsMap[m.id]||[];
+ const currentIds = d.impacts.map(i=>i.id).filter(Boolean);
+ /* deletions */
+ for(const id of originalIds.filter(x=>!currentIds.includes(x))){
+ await authFetch(`/api/premium/milestone-impacts/${id}`,{method:'DELETE'});
}
-
- /* --- creates / updates --- */
- for (const imp of data.impacts) {
- const impPayload = {
- milestone_id : saved.id,
- impact_type : imp.impact_type,
- direction : imp.impact_type === "salary" ? "add" : imp.direction,
- amount : parseFloat(imp.amount) || 0,
- start_date : toSqlDate(imp.start_date) || null,
- end_date : toSqlDate(imp.end_date) || null
+ /* upserts */
+ for(const imp of d.impacts){
+ const body = {
+ milestone_id:saved.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
};
-
- if (imp.id) {
- await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
- method : 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body : JSON.stringify(impPayload)
- });
- } else {
- await authFetch('/api/premium/milestone-impacts', {
- method : 'POST',
- headers: { 'Content-Type': 'application/json' },
- body : JSON.stringify(impPayload)
- });
+ if(imp.id){
+ await authFetch(`/api/premium/milestone-impacts/${imp.id}`,{
+ method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
+ }else{
+ await authFetch('/api/premium/milestone-impacts',{
+ method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
}
}
-
- /* --- refresh + close --- */
await fetchMilestones();
- setEditingMilestoneId(null);
-
- } catch (err) {
- alert('Failed to save milestone');
- console.error(err);
- }
-};
-
-/* βββββββββββββ misc helpers the JSX still calls βββββββββββββ */
-
-/* A) delete one milestone row altogether */
-const deleteMilestone = async (milestone) => {
- if (!window.confirm(`Delete β${milestone.title}β ?`)) return;
- try {
- const res = await authFetch(
- `/api/premium/milestones/${milestone.id}`,
- { method: 'DELETE' }
- );
- if (!res.ok) throw new Error(await res.text());
- await fetchMilestones(); // refresh parent list
- onClose(true); // bubble up that something changed
- } catch (err) {
- alert('Failed to delete milestone');
- console.error(err);
- }
-};
-
-/* B) add a blank impact row while creating a brand-new milestone */
-const addNewImpactToNewMilestone = () => {
- setNewMilestoneData(prev => ({
- ...prev,
- impacts: [
- ...prev.impacts,
- {
- impact_type : 'ONE_TIME',
- direction : 'subtract',
- amount : 0,
- start_date : '',
- end_date : ''
- }
- ]
- }));
-};
-
-/* C) create an entirely new milestone + its impacts */
-const saveNewMilestone = async () => {
- if (!newMilestoneData.title.trim() || !newMilestoneData.date.trim()) {
- alert('Need title and date'); return;
- }
-
- const payload = {
- title : newMilestoneData.title,
- description : newMilestoneData.description,
- date : toSqlDate(newMilestoneData.date),
- career_profile_id: careerProfileId,
- progress : newMilestoneData.progress,
- status : newMilestoneData.progress >= 100 ? 'completed' : 'planned',
- is_universal : newMilestoneData.isUniversal || 0
- };
-
- try {
- const res = await authFetch('/api/premium/milestone', {
- method : 'POST',
- headers: { 'Content-Type': 'application/json' },
- body : JSON.stringify(payload)
- });
- if (!res.ok) throw new Error(await res.text());
- const created =
- Array.isArray(await res.json()) ? (await res.json())[0] : await res.json();
-
- /* impacts for the new milestone */
- for (const imp of newMilestoneData.impacts) {
- const impPayload = {
- milestone_id : created.id,
- impact_type : imp.impact_type,
- direction : imp.impact_type === "salary" ? "add" : imp.direction,
- amount : parseFloat(imp.amount) || 0,
- start_date : toSqlDate(imp.start_date) || null,
- end_date : toSqlDate(imp.end_date) || null
- };
- await authFetch('/api/premium/milestone-impacts', {
- method : 'POST',
- headers: { 'Content-Type': 'application/json' },
- body : JSON.stringify(impPayload)
- });
- }
-
- await fetchMilestones(); // refresh list
- setAddingNewMilestone(false); // collapse the new-mile form
+ setEditingId(null);
+ setIsSavingEdit(false);
onClose(true);
- } catch (err) {
- alert('Failed to save milestone');
- console.error(err);
}
-};
- /* ββββββββββββββββββββββββββββββββ
- Render
- */
+ async function deleteMilestone(m){
+ if(!window.confirm(`Delete β${m.title}β?`)) return;
+ await authFetch(`/api/premium/milestones/${m.id}`,{method:'DELETE'});
+ await fetchMilestones();
+ onClose(true);
+ }
+
+ /* --------------------------------------------------------- *
+ * Newβmilestone helpers (create flow)
+ * --------------------------------------------------------- */
+ const addBlankImpactToNew = ()=> setNewMilestone(n=>({
+ ...n, impacts:[...n.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};
+ });
+ const removeNewImpact = (idx)=> setNewMilestone(n=>{
+ const c=[...n.impacts]; c.splice(idx,1); return {...n,impacts:c};
+ });
+
+ async function saveNew(){
+ if(isSavingNew) return;
+ if(!newMilestone.title.trim()||!newMilestone.date.trim()){
+ alert('Need title & date'); return;
+ }
+ setIsSavingNew(true);
+ const hdr = { title:newMilestone.title, description:newMilestone.description,
+ date:toSqlDate(newMilestone.date), career_profile_id:careerProfileId,
+ 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',{
+ method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
+ }
+ await fetchMilestones();
+ setAddingNew(false);
+ onClose(true);
+ }
+
+ /* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+ /* RENDER */
+ /* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
return (
-
-
-
Edit Milestones
+
+
+ {/* header */}
+
+
+
Milestones
+
+ Track important events and their financial impact on this scenario.
+
+
+
onClose(false)}>β
+
- {milestones.map((m) => {
- const hasEditOpen = editingMilestoneId === m.id;
- const data = newMilestoneMap[m.id] || {};
+ {/* body */}
+
+ {/* EXISTING */}
+ {milestones.map(m=>{
+ const open = editingId===m.id;
+ const d = draft[m.id]||{};
+ return (
+
+ {/* accordion header */}
+
handleAccordionClick(m)}>
+ {m.title}
+
+ {toSqlDate(m.date)} βΈ {open?'Hide':'Edit'}
+
+
- return (
-
-
-
{m.title}
- handleEditMilestoneInline(m)}>
- {hasEditOpen ? "Cancel" : "Edit"}
-
- deleteMilestone(m)}
- >
- Delete
-
-
-
{m.description}
-
- Date: {toSqlDate(m.date)}
-
-
Progress: {m.progress}%
+ {open && (
+
+ {/* ------------- fields */}
+
+
+
+ setDraft(p=>({...p,[m.id]:{...p[m.id],title:e.target.value}}))}
+ />
+
+
+
+ setDraft(p=>({...p,[m.id]:{...p[m.id],date:e.target.value}}))}
+ />
+
+
+
+
+
- {/* inline form */}
- {hasEditOpen && (
-
- {/* Copy Wizard */}
- {copyWizardMilestone && (
-
{
- setCopyWizardMilestone(null);
- if (didCopy) fetchMilestones();
- }}
- />
- )}
-
-
-
onClose(false)}>Close
+ {/* footer */}
+
+ onClose(false)}>Close
+
+ {/* COPY wizard */}
+ {MilestoneCopyWizard && (
+ {setMilestoneCopyWizard(null); if(didCopy) fetchMilestones();}}
+ />
+ )}
);
}
+
+/* -------------- 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;
diff --git a/src/utils/FinancialProjectionService.js b/src/utils/FinancialProjectionService.js
index 9b896a3..998fcba 100644
--- a/src/utils/FinancialProjectionService.js
+++ b/src/utils/FinancialProjectionService.js
@@ -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;
}
});
diff --git a/user_profile.db b/user_profile.db
index 20d3853..051a0bf 100644
Binary files a/user_profile.db and b/user_profile.db differ