Added manual task manipulation, fixed UI
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Josh 2025-08-25 16:03:22 +00:00
parent e6d567d839
commit 893757646b
6 changed files with 910 additions and 338 deletions

View File

@ -1 +1 @@
b632ad41cfb05900be9a667c396e66a4dfb26320-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b afd62e0deab27814cfa0067f1fae1dc4ad79e7dd-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -1,60 +1,58 @@
// src/components/MilestoneDrawer.js
import React, { useMemo, useState, useEffect } from 'react'; import React, { useMemo, useState, useEffect } from 'react';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import { Card, CardContent } from './ui/card.js'; import { Card, CardContent } from './ui/card.js';
import { ChevronLeft, Check, Loader2 } from 'lucide-react'; import { ChevronLeft, Check, Trash2, PencilLine, X } from 'lucide-react';
import { flattenTasks } from '../utils/taskHelpers.js'; import { flattenTasks } from '../utils/taskHelpers.js';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import format from 'date-fns/format'; import format from 'date-fns/format';
/* simple status → color map */
const pillStyle = { const pillStyle = {
completed : 'bg-green-100 text-green-800', completed : 'bg-green-100 text-green-800',
in_progress : 'bg-blue-100 text-blue-800', in_progress : 'bg-blue-100 text-blue-800',
not_started : 'bg-gray-100 text-gray-700' not_started : 'bg-gray-100 text-gray-700'
}; };
const statusLabel = {
not_started : 'Not started',
in_progress : 'In progress',
completed : 'Completed'
};
const nextStatus = { not_started:'in_progress', in_progress:'completed', completed:'not_started' };
export default function MilestoneDrawer({ export default function MilestoneDrawer({
milestone, // ← pass a single milestone object milestone, // single milestone object
milestones = [], // still needed to compute progress % milestones = [], // still available if you compute progress elsewhere
open, open,
onClose, onClose,
onTaskToggle = () => {} onTaskToggle = () => {}
}) { }) {
// Local task list (flatten if your milestone.tasks has nested shape)
const [tasks, setTasks] = useState(milestone ? flattenTasks([milestone]) : []);
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState(null);
/* gather tasks progress for this milestone */ const [draftNew, setDraftNew] = useState({ title:'', due_date:'', description:'' });
const [tasks, setTasks] = useState( const [draftEdit, setDraftEdit] = useState({ title:'', due_date:'', description:'' });
milestone ? flattenTasks([milestone]) : []
);
// refresh local copy whenever the user selects a different milestone
useEffect(() => { useEffect(() => {
setTasks(milestone ? flattenTasks([milestone]) : []); setTasks(milestone ? flattenTasks([milestone]) : []);
setAdding(false);
setEditingId(null);
setDraftNew({ title:'', due_date:'', description:'' });
}, [milestone]); }, [milestone]);
if (!open || !milestone) return null;
const done = tasks.filter(t => t.status === 'completed').length; const done = tasks.filter(t => t.status === 'completed').length;
const prog = tasks.length ? Math.round(100 * done / tasks.length) : 0; const prog = tasks.length ? Math.round(100 * done / tasks.length) : 0;
if (!open || !milestone) return null;
async function toggle(t) { async function toggle(t) {
const newStatus = nextStatus[t.status] || 'not_started';
const next = { // optimistic local update
not_started : 'in_progress', setTasks(prev => prev.map(x => x.id === t.id ? { ...x, status:newStatus } : x));
in_progress : 'completed',
completed : 'not_started' // undo
};
const newStatus = next[t.status] || 'not_started';
/* 1⃣ optimistic local update */
setTasks(prev =>
prev.map(x =>
x.id === t.id ? { ...x, status: newStatus } : x
)
);
/* 2⃣ inform parent so progress bars refresh elsewhere */
onTaskToggle(t.id, newStatus); onTaskToggle(t.id, newStatus);
await authFetch(`/api/premium/tasks/${t.id}`, { await authFetch(`/api/premium/tasks/${t.id}`, {
@ -64,12 +62,83 @@ const newStatus = next[t.status] || 'not_started';
}); });
} }
const statusLabel = { async function createTask() {
not_started : 'Not started', const { title, due_date, description } = draftNew;
in_progress : 'In progress', if (!title.trim()) return;
completed : 'Completed'
const body = {
milestone_id: milestone.id,
title: title.trim(),
description: description || '',
due_date: due_date || null,
status: 'not_started'
}; };
// optimistic add (temporary id)
const tempId = `tmp-${Date.now()}`;
setTasks(prev => [...prev, { ...body, id: tempId }]);
setDraftNew({ title:'', due_date:'', description:'' });
setAdding(false);
const res = await authFetch('/api/premium/tasks', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
});
if (res.ok) {
const saved = await res.json();
const real = Array.isArray(saved) ? saved[0] : saved;
// replace temp id with real id
setTasks(prev => prev.map(t => t.id === tempId ? { ...t, id: real.id } : t));
} else {
// rollback on failure
setTasks(prev => prev.filter(t => t.id !== tempId));
alert(await res.text());
}
}
function beginEdit(t) {
setEditingId(t.id);
setDraftEdit({
title: t.title || '',
due_date: t.due_date ? String(t.due_date).slice(0,10) : '',
description: t.description || ''
});
}
function cancelEdit() {
setEditingId(null);
setDraftEdit({ title:'', due_date:'', description:'' });
}
async function saveEdit(id) {
const body = {
title: (draftEdit.title || '').trim(),
description: draftEdit.description || '',
due_date: draftEdit.due_date || null
};
if (!body.title) return;
// optimistic local update
setTasks(prev => prev.map(t => t.id === id ? { ...t, ...body } : t));
setEditingId(null);
const res = await authFetch(`/api/premium/tasks/${id}`, {
method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)
});
if (!res.ok) alert(await res.text());
}
async function remove(id) {
// optimistic local delete
const prev = tasks;
setTasks(prev.filter(t => t.id !== id));
const res = await authFetch(`/api/premium/tasks/${id}`, { method:'DELETE' });
if (!res.ok) {
alert(await res.text());
setTasks(prev); // rollback
}
}
return ( return (
<div className="fixed inset-y-0 right-0 w-full max-w-sm bg-white shadow-xl z-40 flex flex-col"> <div className="fixed inset-y-0 right-0 w-full max-w-sm bg-white shadow-xl z-40 flex flex-col">
{/* Header */} {/* Header */}
@ -85,57 +154,135 @@ const newStatus = next[t.status] || 'not_started';
</p> </p>
)} )}
</div> </div>
<Button size="sm" onClick={() => setAdding(a => !a)}>
{adding ? 'Cancel' : '+ Add task'}
</Button>
</div> </div>
{/* Body */} {/* Body */}
<Card className="flex-1 overflow-y-auto rounded-none"> <Card className="flex-1 overflow-y-auto rounded-none">
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Progress bar */} {/* Progress */}
<div> <div>
<progress value={prog} max={100} className="w-full h-2" /> <progress value={prog} max={100} className="w-full h-2" />
<p className="text-xs text-gray-500 mt-1">{prog}% complete</p> <p className="text-xs text-gray-500 mt-1">{prog}% complete</p>
</div> </div>
{/* Add composer */}
{adding && (
<div className="border rounded-lg p-3 space-y-2">
<div>
<label className="label-xs">Title</label>
<input
className="input w-full"
value={draftNew.title}
onChange={e => setDraftNew(d => ({ ...d, title:e.target.value }))}
placeholder="What needs to be done?"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="label-xs">Due date</label>
<input
type="date"
className="input w-full"
value={draftNew.due_date}
onChange={e => setDraftNew(d => ({ ...d, due_date:e.target.value }))}
/>
</div>
<div>
<label className="label-xs">Status</label>
<input className="input w-full" value="Not started" disabled />
</div>
</div>
<div>
<label className="label-xs">Description (optional)</label>
<textarea
rows={2}
className="input w-full resize-none"
value={draftNew.description}
onChange={e => setDraftNew(d => ({ ...d, description:e.target.value }))}
/>
</div>
<div className="text-right">
<Button onClick={createTask}>Save task</Button>
</div>
</div>
)}
{/* Task list */} {/* Task list */}
{tasks.map(t => ( {tasks.map(t => (
<div <div key={t.id} className="border p-3 rounded-lg space-y-2">
key={t.id} {editingId === t.id ? (
className="border p-3 rounded-lg flex items-start justify-between" <>
> <div>
<div className="pr-2"> <label className="label-xs">Title</label>
<input
className="input w-full"
value={draftEdit.title}
onChange={e => setDraftEdit(d => ({ ...d, title:e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="label-xs">Due date</label>
<input
type="date"
className="input w-full"
value={draftEdit.due_date}
onChange={e => setDraftEdit(d => ({ ...d, due_date:e.target.value }))}
/>
</div>
<div>
<label className="label-xs">Status</label>
<input className="input w-full" value={statusLabel[t.status]} disabled />
</div>
</div>
<div>
<label className="label-xs">Description</label>
<textarea
rows={2}
className="input w-full resize-none"
value={draftEdit.description}
onChange={e => setDraftEdit(d => ({ ...d, description:e.target.value }))}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={cancelEdit}>Cancel</Button>
<Button onClick={() => saveEdit(t.id)}>Save</Button>
</div>
</>
) : (
<div className="flex items-start justify-between gap-3">
<div className="pr-2 min-w-0">
<p className="font-medium break-words">{t.title}</p> <p className="font-medium break-words">{t.title}</p>
{t.due_date && ( <div className="text-xs text-gray-500 space-x-2">
<p className="text-xs text-gray-500"> {t.due_date && <span>{format(new Date(t.due_date), 'PP')}</span>}
{format(new Date(t.due_date), 'PP')} {t.description && <span className="block text-gray-600">{t.description}</span>}
</p> </div>
)}
</div> </div>
<Button <div className="flex items-center gap-1 shrink-0">
size="icon" <Button size="icon" variant="ghost" aria-label="Toggle" onClick={() => toggle(t)}>
variant="ghost"
aria-label="Toggle task status"
onClick={() => toggle(t)}
>
{t.status === 'completed' {t.status === 'completed'
? <Check className="w-5 h-5 text-green-600" /> ? <Check className="w-5 h-5 text-green-600" />
: ( : <span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-semibold ${pillStyle[t.status]}`} style={{whiteSpace:'nowrap'}}>{statusLabel[t.status]}</span>}
<span
className={`shrink-0 inline-block px-2 py-0.5 rounded-full text-[10px] font-semibold ${pillStyle[t.status]}`} style={{ whiteSpace: 'nowrap' }} /* never wrap */
>
{statusLabel[t.status]}
</span>
)
}
</Button> </Button>
<Button size="icon" variant="outline" aria-label="Edit" onClick={() => beginEdit(t)}>
<PencilLine className="w-5 h-5 text-white" />
</Button>
<Button size="icon" className="bg-rose-600 hover:bg-rose-700 text-white" onClick={() => remove(t.id)}>
<Trash2 className="w-5 h-5" />
</Button>
</div>
</div>
)}
</div> </div>
))} ))}
{!tasks.length && ( {!tasks.length && !adding && (
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">No tasks have been added to this milestone yet.</p>
No tasks have been added to this milestone yet.
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -5,27 +5,41 @@ import InfoTooltip from './ui/infoTooltip.js';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import MilestoneCopyWizard from './MilestoneCopyWizard.js'; import MilestoneCopyWizard from './MilestoneCopyWizard.js';
/* Helper ---------------------------------------------------- */ /* Helpers ---------------------------------------------------- */
const toSqlDate = (v) => (v ? String(v).slice(0, 10) : ''); const toSqlDate = (v) => (v ? String(v).slice(0, 10) : '');
const toDateOrNull = (v) => (v ? String(v).slice(0, 10) : null);
export default function MilestoneEditModal({ export default function MilestoneEditModal({
careerProfileId, careerProfileId,
milestones: incomingMils = [], milestones: incomingMils = [],
milestone: selectedMilestone, milestone: selectedMilestone,
fetchMilestones, fetchMilestones,
onClose onClose,
}) { }) {
/* ───────────────── state */ /* ───────────────── state */
const [milestones, setMilestones] = useState(incomingMils); const [milestones, setMilestones] = useState(incomingMils);
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [draft, setDraft] = useState({}); // id → {…fields}
// draft maps milestoneId -> {title, description, date, progress, newSalary, impacts[], tasks[], isUniversal}
const [draft, setDraft] = useState({});
// Track original IDs so we can detect deletions on save
const [originalImpactIdsMap, setOriginalImpactIdsMap] = useState({}); const [originalImpactIdsMap, setOriginalImpactIdsMap] = useState({});
const [originalTaskIdsMap, setOriginalTaskIdsMap] = useState({});
const [addingNew, setAddingNew] = useState(false); const [addingNew, setAddingNew] = useState(false);
const [newMilestone, setNewMilestone] = useState({ const [newMilestone, setNewMilestone] = useState({
title:'', description:'', date:'', progress:0, newSalary:'', title: '',
impacts:[], isUniversal:0 description: '',
date: '',
progress: 0,
newSalary: '',
impacts: [],
tasks: [],
isUniversal: 0,
}); });
const [MilestoneCopyWizard, setMilestoneCopyWizard] = useState(null);
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
const [isSavingEdit, setIsSavingEdit] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isSavingNew, setIsSavingNew] = useState(false); const [isSavingNew, setIsSavingNew] = useState(false);
@ -33,23 +47,37 @@ export default function MilestoneEditModal({
useEffect(() => setMilestones(incomingMils), [incomingMils]); useEffect(() => setMilestones(incomingMils), [incomingMils]);
/* --------------------------------------------------------- * /* --------------------------------------------------------- *
* Load impacts for one milestone then open its accordion * Load impacts + tasks for one milestone then open it
* --------------------------------------------------------- */ * --------------------------------------------------------- */
const openEditor = useCallback(async (m) => { const openEditor = useCallback(
async (m) => {
setEditingId(m.id); setEditingId(m.id);
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
const json = res.ok ? await res.json() : { impacts:[] }; // Fetch impacts
const imps = (json.impacts||[]).map(i=>({ const resImp = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
const jsonImp = resImp.ok ? await resImp.json() : { impacts: [] };
const imps = (jsonImp.impacts || []).map((i) => ({
id: i.id, id: i.id,
impact_type : i.impact_type||'ONE_TIME', impact_type: i.impact_type || 'ONE_TIME', // 'salary' | 'ONE_TIME' | 'MONTHLY'
direction : i.direction||'subtract', direction: i.impact_type === 'salary' ? 'add' : (i.direction || 'subtract'),
amount: i.amount || 0, amount: i.amount || 0,
start_date: toSqlDate(i.start_date), start_date: toSqlDate(i.start_date),
end_date : toSqlDate(i.end_date) end_date: toSqlDate(i.end_date),
})); }));
setDraft(d => ({ ...d, // Fetch tasks
const resTasks = await authFetch(`/api/premium/tasks?milestone_id=${m.id}`);
const jsonTasks = resTasks.ok ? await resTasks.json() : { tasks: [] };
const tasks = (jsonTasks.tasks || []).map((t) => ({
id: t.id,
title: t.title || '',
description: t.description || '',
due_date: toSqlDate(t.due_date),
status: t.status || 'not_started', // 'not_started' | 'in_progress' | 'completed'
}));
setDraft((d) => ({
...d,
[m.id]: { [m.id]: {
title: m.title || '', title: m.title || '',
description: m.description || '', description: m.description || '',
@ -57,99 +85,194 @@ export default function MilestoneEditModal({
progress: m.progress || 0, progress: m.progress || 0,
newSalary: m.new_salary || '', newSalary: m.new_salary || '',
impacts: imps, impacts: imps,
isUniversal : m.is_universal?1:0 tasks,
} isUniversal: m.is_universal ? 1 : 0,
},
})); }));
setOriginalImpactIdsMap(p => ({ ...p, [m.id]: imps.map(i=>i.id)})); setOriginalImpactIdsMap((p) => ({ ...p, [m.id]: imps.map((i) => i.id).filter(Boolean) }));
}, [editingId]); setOriginalTaskIdsMap((p) => ({ ...p, [m.id]: tasks.map((t) => t.id).filter(Boolean) }));
},
[]
);
const handleAccordionClick = (m) => { const handleAccordionClick = (m) => {
if (editingId === m.id) { if (editingId === m.id) {
setEditingId(null); // just close setEditingId(null);
} else { } else {
openEditor(m); // open + fetch openEditor(m);
} }
}; };
/* open editor automatically when parent passed selectedMilestone */ // auto-open when a specific milestone is passed in
useEffect(()=>{ if(selectedMilestone) openEditor(selectedMilestone)},[selectedMilestone,openEditor]); useEffect(() => {
if (selectedMilestone) openEditor(selectedMilestone);
}, [selectedMilestone, openEditor]);
/* --------------------------------------------------------- * /* --------------------------------------------------------- *
* Handlers shared small helpers * Impact helpers
* --------------------------------------------------------- */ * --------------------------------------------------------- */
const updateImpact = (mid, idx, field, value) => const updateImpact = (mid, idx, field, value) =>
setDraft(p => { setDraft((p) => {
const d = p[mid]; if(!d) return p; const d = p[mid];
const copy = [...d.impacts]; copy[idx] = { ...copy[idx], [field]: value }; if (!d) return p;
const copy = [...d.impacts];
copy[idx] = { ...copy[idx], [field]: value };
return { ...p, [mid]: { ...d, impacts: copy } }; return { ...p, [mid]: { ...d, impacts: copy } };
}); });
const addImpactRow = (mid) => const addImpactRow = (mid) =>
setDraft(p=>{ setDraft((p) => {
const d=p[mid]; if(!d) return p; const d = p[mid];
if (!d) return p;
const blank = { impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' }; const blank = { impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' };
return { ...p, [mid]: { ...d, impacts: [...d.impacts, blank] } }; return { ...p, [mid]: { ...d, impacts: [...d.impacts, blank] } };
}); });
const removeImpactRow = (mid, idx) => const removeImpactRow = (mid, idx) =>
setDraft(p=>{ setDraft((p) => {
const d=p[mid]; if(!d) return p; const d = p[mid];
const c=[...d.impacts]; c.splice(idx,1); if (!d) return p;
const c = [...d.impacts];
c.splice(idx, 1);
return { ...p, [mid]: { ...d, impacts: c } }; return { ...p, [mid]: { ...d, impacts: c } };
}); });
/* --------------------------------------------------------- *
* Task helpers
* --------------------------------------------------------- */
const addTaskRow = (mid) =>
setDraft((p) => {
const d = p[mid];
if (!d) return p;
const blank = { id: null, title: '', description: '', due_date: '', status: 'not_started' };
return { ...p, [mid]: { ...d, tasks: [...(d.tasks || []), blank] } };
});
const updateTask = (mid, idx, field, value) =>
setDraft((p) => {
const d = p[mid];
if (!d) return p;
const copy = [...(d.tasks || [])];
copy[idx] = { ...copy[idx], [field]: value };
return { ...p, [mid]: { ...d, tasks: copy } };
});
const removeTaskRow = (mid, idx) =>
setDraft((p) => {
const d = p[mid];
if (!d) return p;
const copy = [...(d.tasks || [])];
copy.splice(idx, 1);
return { ...p, [mid]: { ...d, tasks: copy } };
});
/* --------------------------------------------------------- * /* --------------------------------------------------------- *
* Persist edits (UPDATE / create / delete diff) * Persist edits (UPDATE / create / delete diff)
* --------------------------------------------------------- */ * --------------------------------------------------------- */
async function saveMilestone(m) { async function saveMilestone(m) {
if(isSavingEdit) return; // guard if (isSavingEdit) return;
const d = draft[m.id]; if(!d) return; const d = draft[m.id];
if (!d) return;
setIsSavingEdit(true); setIsSavingEdit(true);
try {
/* header */ /* header */
const payload = { const payload = {
milestone_type: 'Financial', milestone_type: 'Financial',
title:d.title, description:d.description, date:toSqlDate(d.date), title: d.title,
career_profile_id:careerProfileId, progress:d.progress, description: d.description,
status:d.progress>=100?'completed':'planned', date: toDateOrNull(d.date),
career_profile_id: careerProfileId,
progress: d.progress || 0,
status: (d.progress || 0) >= 100 ? 'completed' : 'planned',
new_salary: d.newSalary ? parseFloat(d.newSalary) : null, new_salary: d.newSalary ? parseFloat(d.newSalary) : null,
is_universal:d.isUniversal is_universal: d.isUniversal ? 1 : 0,
}; };
const res = await authFetch(`/api/premium/milestones/${m.id}`, { const res = await authFetch(`/api/premium/milestones/${m.id}`, {
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}); });
if(!res.ok){ alert('Save failed'); return;} if (!res.ok) throw new Error(await res.text());
const saved = await res.json(); const saved = await res.json();
/* impacts diff */ /* impacts diff */
const originalIds = originalImpactIdsMap[m.id]||[]; const originalImpIds = originalImpactIdsMap[m.id] || [];
const currentIds = d.impacts.map(i=>i.id).filter(Boolean); const currentImpIds = (d.impacts || []).map((i) => i.id).filter(Boolean);
/* deletions */
for(const id of originalIds.filter(x=>!currentIds.includes(x))){ // deletions
for (const id of originalImpIds.filter((x) => !currentImpIds.includes(x))) {
await authFetch(`/api/premium/milestone-impacts/${id}`, { method: 'DELETE' }); await authFetch(`/api/premium/milestone-impacts/${id}`, { method: 'DELETE' });
} }
/* upserts */
for(const imp of d.impacts){ // upserts
for (const imp of d.impacts || []) {
const body = { const body = {
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:imp.start_date||null, start_date: toDateOrNull(imp.start_date),
end_date:imp.end_date||null end_date: toDateOrNull(imp.end_date),
}; };
if (imp.id) { if (imp.id) {
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, { await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} else { } else {
await authFetch('/api/premium/milestone-impacts', { await authFetch('/api/premium/milestone-impacts', {
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} }
} }
/* tasks diff */
const originalTaskIds = originalTaskIdsMap[m.id] || [];
const currentTaskIds = (d.tasks || []).map((t) => t.id).filter(Boolean);
// deletions
for (const id of originalTaskIds.filter((x) => !currentTaskIds.includes(x))) {
await authFetch(`/api/premium/tasks/${id}`, { method: 'DELETE' });
}
// upserts
for (const t of d.tasks || []) {
const body = {
milestone_id: saved.id,
title: t.title || '',
description: t.description || '',
due_date: toDateOrNull(t.due_date),
status: t.status || 'not_started',
};
if (t.id) {
await authFetch(`/api/premium/tasks/${t.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} else {
await authFetch('/api/premium/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
}
await fetchMilestones(); await fetchMilestones();
setEditingId(null); setEditingId(null);
setIsSavingEdit(false);
onClose(true); onClose(true);
} catch (err) {
console.error('saveMilestone:', err);
alert(err.message || 'Save failed');
} finally {
setIsSavingEdit(false);
}
} }
async function deleteMilestone(m) { async function deleteMilestone(m) {
@ -160,64 +283,118 @@ export default function MilestoneEditModal({
} }
/* --------------------------------------------------------- * /* --------------------------------------------------------- *
* Newmilestone helpers (create flow) * New-milestone helpers (create flow)
* --------------------------------------------------------- */ * --------------------------------------------------------- */
const addBlankImpactToNew = ()=> setNewMilestone(n=>({ const addBlankImpactToNew = () =>
...n, impacts:[...n.impacts,{impact_type:'ONE_TIME',direction:'subtract',amount:0,start_date:'',end_date:''}] 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 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}; const removeNewImpact = (idx) =>
setNewMilestone((n) => {
const c = [...n.impacts];
c.splice(idx, 1);
return { ...n, impacts: c };
});
const addBlankTaskToNew = () =>
setNewMilestone((n) => ({
...n,
tasks: [...n.tasks, { title: '', description: '', due_date: '', status: 'not_started' }],
}));
const updateNewTask = (idx, field, val) =>
setNewMilestone((n) => {
const c = [...n.tasks];
c[idx] = { ...c[idx], [field]: val };
return { ...n, tasks: c };
});
const removeNewTask = (idx) =>
setNewMilestone((n) => {
const c = [...n.tasks];
c.splice(idx, 1);
return { ...n, tasks: c };
}); });
async function saveNew() { async function saveNew() {
if (isSavingNew) return; if (isSavingNew) return;
if (!newMilestone.title.trim() || !newMilestone.date.trim()) { if (!newMilestone.title.trim() || !newMilestone.date.trim()) {
alert('Need title & date'); return; alert('Need title & date');
return;
} }
setIsSavingNew(true); setIsSavingNew(true);
const toDate = (v) => (v ? String(v).slice(0,10) : null);
try { try {
const hdr = { const hdr = {
title: newMilestone.title, title: newMilestone.title,
description: newMilestone.description, description: newMilestone.description,
date: toDate(newMilestone.date), date: toDateOrNull(newMilestone.date),
career_profile_id: careerProfileId, career_profile_id: careerProfileId,
progress: newMilestone.progress, progress: newMilestone.progress || 0,
status: newMilestone.progress >= 100 ? 'completed' : 'planned', status: (newMilestone.progress || 0) >= 100 ? 'completed' : 'planned',
is_universal: newMilestone.isUniversal, is_universal: newMilestone.isUniversal ? 1 : 0,
}; };
const res = await authFetch('/api/premium/milestone', { const res = await authFetch('/api/premium/milestone', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hdr) body: JSON.stringify(hdr),
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
const createdJson = await res.json(); const createdJson = await res.json();
const created = Array.isArray(createdJson) ? createdJson[0] : createdJson; const created = Array.isArray(createdJson) ? createdJson[0] : createdJson;
if (!created || !created.id) throw new Error('Milestone create failed — no id returned'); if (!created || !created.id) throw new Error('Milestone create failed — no id returned');
// Save any non-empty impact rows // Save impacts
for (const imp of newMilestone.impacts) { for (const imp of newMilestone.impacts) {
if (!imp) continue; if (!imp) continue;
const hasAnyField = (imp.amount || imp.start_date || imp.end_date || imp.impact_type); const hasAnyField = imp.amount || imp.start_date || imp.end_date || imp.impact_type;
if (!hasAnyField) continue; if (!hasAnyField) continue;
const ibody = { const ibody = {
milestone_id: created.id, milestone_id: created.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 : toDate(imp.start_date), start_date: toDateOrNull(imp.start_date),
end_date : toDate(imp.end_date), end_date: toDateOrNull(imp.end_date),
}; };
const ir = await authFetch('/api/premium/milestone-impacts', { const ir = await authFetch('/api/premium/milestone-impacts', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(ibody) method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ibody),
}); });
if (!ir.ok) throw new Error(await ir.text()); if (!ir.ok) throw new Error(await ir.text());
} }
// Save tasks
for (const t of newMilestone.tasks) {
if (!t) continue;
const hasAnyField = t.title || t.description || t.due_date;
if (!hasAnyField) continue;
const tbody = {
milestone_id: created.id,
title: t.title || '',
description: t.description || '',
due_date: toDateOrNull(t.due_date),
status: t.status || 'not_started',
};
const tr = await authFetch('/api/premium/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tbody),
});
if (!tr.ok) throw new Error(await tr.text());
}
await fetchMilestones(); await fetchMilestones();
setAddingNew(false); setAddingNew(false);
onClose(true); onClose(true);
@ -239,17 +416,17 @@ export default function MilestoneEditModal({
<div className="flex items-center justify-between px-6 py-4 border-b"> <div className="flex items-center justify-between px-6 py-4 border-b">
<div> <div>
<h2 className="text-lg font-semibold">Milestones</h2> <h2 className="text-lg font-semibold">Milestones</h2>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">Track important events and their financial impact on this scenario.</p>
Track important events and their financial impact on this scenario.
</p>
</div> </div>
<Button variant="ghost" onClick={() => onClose(false)}></Button> <Button variant="ghost" onClick={() => onClose(false)}>
</Button>
</div> </div>
{/* body */} {/* body */}
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto overflow-x-hidden"> <div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto overflow-x-hidden">
{/* EXISTING */} {/* EXISTING */}
{milestones.map(m=>{ {milestones.map((m) => {
const open = editingId === m.id; const open = editingId === m.id;
const d = draft[m.id] || {}; const d = draft[m.id] || {};
return ( return (
@ -257,7 +434,8 @@ export default function MilestoneEditModal({
{/* accordion header */} {/* accordion header */}
<button <button
className="w-full flex justify-between items-center px-4 py-2 bg-gray-50 hover:bg-gray-100 text-left" className="w-full flex justify-between items-center px-4 py-2 bg-gray-50 hover:bg-gray-100 text-left"
onClick={()=>handleAccordionClick(m)}> onClick={() => handleAccordionClick(m)}
>
<span className="font-medium">{m.title}</span> <span className="font-medium">{m.title}</span>
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
{toSqlDate(m.date)} {open ? 'Hide' : 'Edit'} {toSqlDate(m.date)} {open ? 'Hide' : 'Edit'}
@ -265,17 +443,17 @@ export default function MilestoneEditModal({
</button> </button>
{open && ( {open && (
<div className="px-4 py-4 grid gap-4 bg-white"> <div className="px-4 py-4 grid gap-6 bg-white">
{/* ------------- fields */} {/* ------------- fields */}
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm font-medium flex items-center gap-1"> <label className="text-sm font-medium flex items-center gap-1">
Title <InfoTooltip message="Short, actionoriented label (max 60chars)." /> Title <InfoTooltip message="Short, action-oriented label (max 60 chars)." />
</label> </label>
<input <input
className="input" className="input"
value={d.title || ''} value={d.title || ''}
onChange={e=>setDraft(p=>({...p,[m.id]:{...p[m.id],title:e.target.value}}))} onChange={(e) => setDraft((p) => ({ ...p, [m.id]: { ...p[m.id], title: e.target.value } }))}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@ -284,18 +462,20 @@ export default function MilestoneEditModal({
type="date" type="date"
className="input" className="input"
value={d.date || ''} value={d.date || ''}
onChange={e=>setDraft(p=>({...p,[m.id]:{...p[m.id],date:e.target.value}}))} onChange={(e) => setDraft((p) => ({ ...p, [m.id]: { ...p[m.id], date: e.target.value } }))}
/> />
</div> </div>
<div className="md:col-span-2 space-y-1"> <div className="md:col-span-2 space-y-1">
<label className="text-sm font-medium flex items-center gap-1"> <label className="text-sm font-medium flex items-center gap-1">
Description <InfoTooltip message="12 sentences on what success looks like."/> Description <InfoTooltip message="1-2 sentences on what success looks like." />
</label> </label>
<textarea <textarea
rows={2} rows={2}
className="input resize-none" className="input resize-none"
value={d.description || ''} value={d.description || ''}
onChange={e=>setDraft(p=>({...p,[m.id]:{...p[m.id],description:e.target.value}}))} onChange={(e) =>
setDraft((p) => ({ ...p, [m.id]: { ...p[m.id], description: e.target.value } }))
}
/> />
</div> </div>
</div> </div>
@ -304,7 +484,9 @@ export default function MilestoneEditModal({
<div> <div>
<div className="flex items-center justify-between gap-2 min-w-0"> <div className="flex items-center justify-between gap-2 min-w-0">
<h4 className="font-medium text-sm">Financial impacts</h4> <h4 className="font-medium text-sm">Financial impacts</h4>
<Button size="xs" onClick={()=>addImpactRow(m.id)}>+ Add impact</Button> <Button size="xs" onClick={() => addImpactRow(m.id)}>
+ Add impact
</Button>
</div> </div>
<p className="text-xs text-gray-500 mb-2"> <p className="text-xs text-gray-500 mb-2">
Use <em>salary</em> for annual income changes; <em>monthly</em> for recurring amounts. Use <em>salary</em> for annual income changes; <em>monthly</em> for recurring amounts.
@ -312,42 +494,53 @@ export default function MilestoneEditModal({
<div className="space-y-3"> <div className="space-y-3">
{d.impacts?.map((imp, idx) => ( {d.impacts?.map((imp, idx) => (
<div key={idx} className="grid gap-2 items-end min-w-0 md:grid-cols-[150px_110px_100px_minmax(240px,1fr)_40px]"> <div
key={idx}
className="grid gap-3 items-end min-w-0 grid-cols-1
md:grid-cols-[140px_120px_110px_minmax(0,1fr)_auto]"
>
{/* type */} {/* type */}
<div> <div>
<label className="label-xs">Type</label> <label className="label-xs">Type</label>
<select <select
className="input" className="input w-full"
value={imp.impact_type} value={imp.impact_type}
onChange={e=>updateImpact(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">Onetime</option> <option value="ONE_TIME">One-time</option>
<option value="MONTHLY">Monthly</option> <option value="MONTHLY">Monthly</option>
</select> </select>
</div> </div>
{/* direction hide for salary */} {/* direction hide for salary */}
{imp.impact_type !== 'salary' ? ( {imp.impact_type !== 'salary' ? (
<div> <div className="min-w-0">
<label className="label-xs">Direction</label> <label className="label-xs">Direction</label>
<select className="input" value={imp.direction} onChange={e=>updateImpact(m.id,idx,'direction',e.target.value)}> <select
className="input w-full"
value={imp.direction}
onChange={(e) => updateImpact(m.id, idx, 'direction', e.target.value)}
>
<option value="add">Add</option> <option value="add">Add</option>
<option value="subtract">Subtract</option> <option value="subtract">Subtract</option>
</select> </select>
</div> </div>
) : ( ) : (
// keep the grid column to prevent the next columns from collapsing
<div className="hidden md:block" /> <div className="hidden md:block" />
)} )}
{/* amount */} {/* amount */}
<div className="md:w-[100px]"> <div className="md:w-[100px]">
<label className="label-xs">Amount</label> <label className="label-xs">Amount</label>
<input <input
type="number" type="number"
className="input" className="input w-full"
value={imp.amount} value={imp.amount}
onChange={e=>updateImpact(m.id,idx,'amount',e.target.value)} onChange={(e) => updateImpact(m.id, idx, 'amount', e.target.value)}
/> />
</div> </div>
{/* dates */} {/* dates */}
<div className="grid grid-cols-2 gap-2 min-w-0"> <div className="grid grid-cols-2 gap-2 min-w-0">
<div> <div>
@ -356,7 +549,7 @@ export default function MilestoneEditModal({
type="date" type="date"
className="input w-full min-w-[14ch] px-3" className="input w-full min-w-[14ch] px-3"
value={imp.start_date} value={imp.start_date}
onChange={e=>updateImpact(m.id,idx,'start_date',e.target.value)} onChange={(e) => updateImpact(m.id, idx, 'start_date', e.target.value)}
/> />
</div> </div>
{imp.impact_type === 'MONTHLY' && ( {imp.impact_type === 'MONTHLY' && (
@ -366,16 +559,16 @@ export default function MilestoneEditModal({
type="date" type="date"
className="input w-full min-w-[14ch] px-3" className="input w-full min-w-[14ch] px-3"
value={imp.end_date} value={imp.end_date}
onChange={e=>updateImpact(m.id,idx,'end_date',e.target.value)} onChange={(e) => updateImpact(m.id, idx, 'end_date', e.target.value)}
/> />
</div> </div>
)} )}
</div> </div>
{/* remove */} {/* remove */}
<Button <Button
size="icon-xs" size="icon-xs"
variant="ghost" className="!bg-red-600 !hover:bg-red-700 !text-white w-10 h-9 justify-self-end shrink-0"
className="text-red-600 w-10 h-9 flex items-center justify-center"
onClick={() => removeImpactRow(m.id, idx)} onClick={() => removeImpactRow(m.id, idx)}
> >
@ -385,13 +578,83 @@ export default function MilestoneEditModal({
</div> </div>
</div> </div>
{/* tasks */}
<div>
<div className="flex items-center justify-between gap-2 min-w-0">
<h4 className="font-medium text-sm">Tasks</h4>
<Button size="xs" onClick={() => addTaskRow(m.id)}>
+ Add task
</Button>
</div>
<p className="text-xs text-gray-500 mb-2">Break this milestone into concrete steps.</p>
<div className="space-y-3">
{d.tasks?.map((t, idx) => (
<div
key={idx}
className="grid gap-3 items-end min-w-0 grid-cols-1
md:grid-cols-[140px_120px_110px_minmax(0,1fr)_auto]"
>
<div className="min-w-0">
<label className="label-xs">Title</label>
<input
className="input w-full"
value={t.title}
onChange={(e) => updateTask(m.id, idx, 'title', e.target.value)}
/>
</div>
<div className="min-w-0">
<label className="label-xs">Description</label>
<input
className="input w-full"
value={t.description}
onChange={(e) => updateTask(m.id, idx, 'description', e.target.value)}
/>
</div>
<div className="min-w-0">
<label className="label-xs">Due Date</label>
<input
type="date"
className="input w-full"
value={t.due_date}
onChange={(e) => updateTask(m.id, idx, 'due_date', e.target.value)}
/>
</div>
<div className="min-w-0">
<label className="label-xs">Status</label>
<select
className="input w-full"
value={t.status}
onChange={(e) => updateTask(m.id, idx, 'status', e.target.value)}
>
<option value="not_started">Not started</option>
<option value="in_progress">In progress</option>
<option value="completed">Completed</option>
</select>
</div>
<Button
size="icon-xs"
variant="ghost"
className="!bg-red-600 !hover:bg-red-700 !text-white w-10 h-9 justify-self-end shrink-0"
onClick={() => removeTaskRow(m.id, idx)}
>
</Button>
</div>
))}
</div>
</div>
{/* footer buttons */} {/* footer buttons */}
<div className="flex justify-between pt-4 border-t"> <div className="flex justify-between pt-4 border-t">
<div className="space-x-2"> <div className="space-x-2">
<Button variant="destructive" onClick={()=>deleteMilestone(m)}> <Button
onClick={() => deleteMilestone(m)}
className="bg-red-600 hover:bg-red-700 text-white"
>
Delete milestone Delete milestone
</Button> </Button>
<Button variant="secondary" onClick={()=>setMilestoneCopyWizard(m)}> <Button variant="secondary" onClick={() => setCopyWizardMilestone(m)}>
Copy to other scenarios Copy to other scenarios
</Button> </Button>
</div> </div>
@ -409,22 +672,25 @@ export default function MilestoneEditModal({
<details className="border rounded-md" open={addingNew}> <details className="border rounded-md" open={addingNew}>
<summary <summary
className="cursor-pointer px-4 py-2 bg-gray-50 hover:bg-gray-100 text-sm font-medium flex justify-between items-center" 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);}} onClick={(e) => {
e.preventDefault();
setAddingNew((p) => !p);
}}
> >
Add new milestone Add new milestone
<span>{addingNew ? '' : '+'}</span> <span>{addingNew ? '' : '+'}</span>
</summary> </summary>
{addingNew && ( {addingNew && (
<div className="px-4 py-4 space-y-4 bg-white"> <div className="px-4 py-4 space-y-6 bg-white">
{/* fields */} {/* fields */}
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<label className="label-xs">Title</label> <label className="label-xs">Title</label>
<input <input
className="input" className="input w-full"
value={newMilestone.title} value={newMilestone.title}
onChange={e=>setNewMilestone(n=>({...n,title:e.target.value}))} onChange={(e) => setNewMilestone((n) => ({ ...n, title: e.target.value }))}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@ -433,36 +699,40 @@ export default function MilestoneEditModal({
type="date" type="date"
className="input w-full min-w-[12ch] px-3" className="input w-full min-w-[12ch] px-3"
value={newMilestone.date} value={newMilestone.date}
onChange={e=>setNewMilestone(n=>({...n,date:e.target.value}))} onChange={(e) => setNewMilestone((n) => ({ ...n, date: e.target.value }))}
/> />
</div> </div>
<div className="md:col-span-2 space-y-1"> <div className="md:col-span-2 space-y-1">
<label className="label-xs">Description</label> <label className="label-xs">Description</label>
<textarea <textarea
rows={2} rows={2}
className="input resize-none" className="input w-full resize-none"
value={newMilestone.description} value={newMilestone.description}
onChange={e=>setNewMilestone(n=>({...n,description:e.target.value}))} onChange={(e) => setNewMilestone((n) => ({ ...n, description: e.target.value }))}
/> />
</div> </div>
</div> </div>
{/* impacts */} {/* impacts */}
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="font-medium text-sm">Financial impacts</h4> <h4 className="font-medium text-sm">Financial impacts</h4>
<Button size="xs" className="shrink-0" onClick={addBlankImpactToNew}>+ Add impact</Button> <Button size="xs" className="shrink-0" onClick={addBlankImpactToNew}>
+ Add impact
</Button>
</div> </div>
<div className="space-y-3 mt-2"> <div className="space-y-3 mt-2">
{newMilestone.impacts.map((imp, idx) => ( {newMilestone.impacts.map((imp, idx) => (
<div <div
key={idx} key={idx}
className="grid gap-2 items-end min-w-0 grid-cols-[140px_110px_100px_minmax(220px,1fr)_44px]" className="grid gap-3 items-end min-w-0 grid-cols-1
md:grid-cols-[140px_120px_110px_minmax(0,1fr)_auto]"
> >
{/* Type */} {/* Type */}
<div> <div className="min-w-0">
<label className="label-xs">Type</label> <label className="label-xs">Type</label>
<select <select
className="input" className="input w-full"
value={imp.impact_type} value={imp.impact_type}
onChange={(e) => updateNewImpact(idx, 'impact_type', e.target.value)} onChange={(e) => updateNewImpact(idx, 'impact_type', e.target.value)}
> >
@ -474,10 +744,10 @@ export default function MilestoneEditModal({
{/* Direction (spacer when salary) */} {/* Direction (spacer when salary) */}
{imp.impact_type !== 'salary' ? ( {imp.impact_type !== 'salary' ? (
<div> <div className= "min-w-0">
<label className="label-xs">Direction</label> <label className="label-xs">Direction</label>
<select <select
className="input" className="input w-full"
value={imp.direction} value={imp.direction}
onChange={(e) => updateNewImpact(idx, 'direction', e.target.value)} onChange={(e) => updateNewImpact(idx, 'direction', e.target.value)}
> >
@ -489,20 +759,20 @@ export default function MilestoneEditModal({
<div className="hidden md:block" /> <div className="hidden md:block" />
)} )}
{/* Amount (fixed width) */} {/* Amount */}
<div className="md:w-[100px]"> <div className="md:w-[100px]">
<label className="label-xs">Amount</label> <label className="label-xs">Amount</label>
<input <input
type="number" type="number"
className="input" className="input w-full"
value={imp.amount} value={imp.amount}
onChange={(e) => updateNewImpact(idx, 'amount', e.target.value)} onChange={(e) => updateNewImpact(idx, 'amount', e.target.value)}
/> />
</div> </div>
{/* Dates (flex) */} {/* Dates */}
<div className="grid grid-cols-2 gap-2 min-w-0"> <div className="grid grid-cols-2 gap-2 min-w-0">
<div> <div className="min-w-0">
<label className="label-xs">Start</label> <label className="label-xs">Start</label>
<input <input
type="date" type="date"
@ -528,17 +798,82 @@ export default function MilestoneEditModal({
<Button <Button
size="icon-xs" size="icon-xs"
variant="ghost" variant="ghost"
className="text-red-600 w-11 h-9 flex items-center justify-center"
onClick={() => removeNewImpact(idx)} onClick={() => removeNewImpact(idx)}
aria-label="Remove impact" className="!bg-red-600 !hover:bg-red-700 !text-white w-10 h-9 justify-self-end shrink-0"
> >
</Button> </Button>
</div> </div>
))} ))}
</div>
</div>
{/* tasks (new) */}
<div>
<div className="flex items-center justify-between gap-2 min-w-0">
<h4 className="font-medium text-sm">Tasks</h4>
<Button size="xs" className="shrink-0" onClick={addBlankTaskToNew}>
+ Add task
</Button>
</div>
<div className="space-y-3 mt-2">
{newMilestone.tasks.map((t, idx) => (
<div
key={idx}
className="grid gap-3 items-end min-w-0 grid-cols-1
md:grid-cols-[140px_120px_110px_minmax(0,1fr)_auto]"
>
<div className="min-w-0">
<label className="label-xs">Title</label>
<input
className="input w-full"
value={t.title}
onChange={(e) => updateNewTask(idx, 'title', e.target.value)}
/>
</div>
<div className="min-w-0">
<label className="label-xs">Description</label>
<input
className="input w-full"
value={t.description}
onChange={(e) => updateNewTask(idx, 'description', e.target.value)}
/>
</div>
<div className="min-w-0">
<label className="label-xs">Due Date</label>
<input
type="date"
className="input w-full"
value={t.due_date}
onChange={(e) => updateNewTask(idx, 'due_date', e.target.value)}
/>
</div>
<div className="min-w-0">
<label className="label-xs">Status</label>
<select
className="input w-full"
value={t.status}
onChange={(e) => updateNewTask(idx, 'status', e.target.value)}
>
<option value="not_started">Not started</option>
<option value="in_progress">In progress</option>
<option value="completed">Completed</option>
</select>
</div>
<Button
size="icon-xs"
variant="ghost"
className="!bg-red-600 !hover:bg-red-700 !text-white w-10 h-9 justify-self-end shrink-0"
onClick={() => removeNewTask(idx)}
aria-label="Remove task"
>
</Button>
</div>
))}
</div> </div>
</div> </div>
{/* save row */} {/* save row */}
<div className="flex justify-end border-t pt-4"> <div className="flex justify-end border-t pt-4">
<Button disabled={isSavingNew} onClick={saveNew}> <Button disabled={isSavingNew} onClick={saveNew}>
@ -552,15 +887,20 @@ export default function MilestoneEditModal({
{/* footer */} {/* footer */}
<div className="px-6 py-4 border-t text-right"> <div className="px-6 py-4 border-t text-right">
<Button variant="secondary" onClick={()=>onClose(false)}>Close</Button> <Button variant="secondary" onClick={() => onClose(false)}>
Close
</Button>
</div> </div>
</div> </div>
{/* COPY wizard */} {/* COPY wizard */}
{MilestoneCopyWizard && ( {copyWizardMilestone && (
<MilestoneCopyWizard <MilestoneCopyWizard
milestone={MilestoneCopyWizard} milestone={copyWizardMilestone}
onClose={(didCopy)=>{setMilestoneCopyWizard(null); if(didCopy) fetchMilestones();}} onClose={(didCopy) => {
setCopyWizardMilestone(null);
if (didCopy) fetchMilestones();
}}
/> />
)} )}
</div> </div>

View File

@ -1,6 +1,7 @@
// CareerOnboarding.js // CareerOnboarding.js
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { saveDraft, clearDraft, loadDraft } from '../../utils/onboardingDraftApi.js';
// 1) Import your CareerSearch component // 1) Import your CareerSearch component
import CareerSearch from '../CareerSearch.js'; // adjust path as necessary import CareerSearch from '../CareerSearch.js'; // adjust path as necessary
@ -51,13 +52,19 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData, finishNow }) => {
// Called whenever other <inputs> change // Called whenever other <inputs> change
const handleChange = (e) => { const handleChange = (e) => {
setData(prev => ({ ...prev, [e.target.name]: e.target.value })); setData(prev => ({ ...prev, [e.target.name]: e.target.value }));
const k = e.target.name;
if (['status','start_date','career_goals'].includes(k)) {
saveDraft({ careerData: { [k]: e.target.value } }).catch(() => {});
}
}; };
/* ── 4. callbacks ─────────────────────────────────────────── */ /* ── 4. callbacks ─────────────────────────────────────────── */
function handleCareerSelected(obj) { function handleCareerSelected(career) {
setCareerObj(obj); setCareerObj(career);
localStorage.setItem('selectedCareer', JSON.stringify(obj)); localStorage.setItem('selectedCareer', JSON.stringify(career));
setData(prev => ({ ...prev, career_name: obj.title, soc_code: obj.soc_code || '' })); setData(prev => ({ ...prev, career_name: career.title, soc_code: career.soc_code || '' }));
// persist immediately
saveDraft({ careerData: { career_name: career.title, soc_code: career.soc_code || '' } }).catch(() => {});
} }
function handleSubmit() { function handleSubmit() {
@ -95,8 +102,10 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
<select <select
value={currentlyWorking} value={currentlyWorking}
onChange={(e) => { onChange={(e) => {
setCurrentlyWorking(e.target.value); const val = e.target.value;
setData(prev => ({ ...prev, currently_working: e.target.value })); setCurrentlyWorking(val);
setData(prev => ({ ...prev, currently_working: val }));
saveDraft({ careerData: { currently_working: val} }).catch(() => {});
}} }}
required required
className="w-full border rounded p-2" className="w-full border rounded p-2"
@ -158,10 +167,11 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
<select <select
value={collegeStatus} value={collegeStatus}
onChange={(e) => { onChange={(e) => {
setCollegeStatus(e.target.value); const val = e.target.value;
setData(prev => ({ ...prev, college_enrollment_status: e.target.value })); setCurrentlyWorking(val);
const needsPrompt = ['currently_enrolled', 'prospective_student'].includes(e.target.value); setData(prev => ({ ...prev, currently_working: val }));
setShowFinPrompt(needsPrompt); // persist immediately
saveDraft({ careerData: { currently_working: val } }).catch(() => {});
}} }}
required required
className="w-full border rounded p-2" className="w-full border rounded p-2"

View File

@ -3,6 +3,7 @@ import Modal from '../../components/ui/modal.js';
import FinancialAidWizard from '../../components/FinancialAidWizard.js'; import FinancialAidWizard from '../../components/FinancialAidWizard.js';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import api from '../../auth/apiClient.js'; import api from '../../auth/apiClient.js';
import { loadDraft, clearDraft, saveDraft } from '../../utils/onboardingDraftApi.js';
const Req = () => <span className="text-red-600 ml-0.5">*</span>; const Req = () => <span className="text-red-600 ml-0.5">*</span>;
@ -57,7 +58,7 @@ function toSchoolName(objOrStr) {
// Destructure parent data // Destructure parent data
const { const {
college_enrollment_status = '', college_enrollment_status = '',
selected_school = selectedSchool, selected_school = '',
selected_program = '', selected_program = '',
program_type = '', program_type = '',
academic_calendar = 'semester', academic_calendar = 'semester',
@ -99,6 +100,40 @@ function toSchoolName(objOrStr) {
} }
}, [selectedSchool, setData]); }, [selectedSchool, setData]);
// Backfill from cookie-backed draft if props aren't populated yet
useEffect(() => {
// if props already have values, do nothing
if (data?.selected_school || data?.selected_program || data?.program_type) return;
let cancelled = false;
(async () => {
let draft;
try { draft = await loadDraft(); } catch { draft = null; }
const cd = draft?.data?.collegeData;
if (!cd) return;
if (cancelled) return;
// 1) write into parent data (so inputs prefill)
setData(prev => ({
...prev,
selected_school : cd.selected_school ?? prev.selected_school ?? '',
selected_program: cd.selected_program ?? prev.selected_program ?? '',
program_type : cd.program_type ?? prev.program_type ?? ''
}));
// 2) set local selectedSchool object (triggers your selectedSchool→data effect too)
setSelectedSchool({
INSTNM : cd.selected_school || '',
CIPDESC : cd.selected_program || '',
CREDDESC: cd.program_type || ''
});
})();
return () => { cancelled = true; };
// run once on mount; we don't want to fight subsequent user edits
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { useEffect(() => {
if (data.expected_graduation && !expectedGraduation) if (data.expected_graduation && !expectedGraduation)
setExpectedGraduation(data.expected_graduation); setExpectedGraduation(data.expected_graduation);
@ -110,39 +145,39 @@ useEffect(() => {
* Only parseFloat if there's an actual numeric value. * Only parseFloat if there's an actual numeric value.
*/ */
const handleParentFieldChange = (e) => { const handleParentFieldChange = (e) => {
const { name, value, type, checked } = e.target; const { name: field, value, type, checked } = e.target;
let val = value; let val = value;
if (type === 'checkbox') { if (type === 'checkbox') {
val = checked; val = checked;
setData(prev => ({ ...prev, [name]: val })); setData(prev => ({ ...prev, [field]: val }));
return; return;
} }
// If the user typed an empty string, store '' so they can see it's blank // If the user typed an empty string, store '' so they can see it's blank
if (val.trim() === '') { if (val.trim() === '') {
setData(prev => ({ ...prev, [name]: '' })); setData(prev => ({ ...prev, [field]: '' }));
return; return;
} }
// Otherwise, parse it if it's one of the numeric fields // Otherwise, parse it if it's one of the numeric fields
if (['interest_rate', 'loan_term', 'extra_payment', 'expected_salary'].includes(name)) { if (['interest_rate', 'loan_term', 'extra_payment', 'expected_salary'].includes(field)) {
const parsed = parseFloat(val); const parsed = parseFloat(val);
// If parse fails => store '' (or fallback to old value) // If parse fails => store '' (or fallback to old value)
if (isNaN(parsed)) { if (isNaN(parsed)) {
setData(prev => ({ ...prev, [name]: '' })); setData(prev => ({ ...prev, [field]: '' }));
} else { } else {
setData(prev => ({ ...prev, [name]: parsed })); setData(prev => ({ ...prev, [field]: parsed }));
} }
} else if ([ } else if ([
'annual_financial_aid','existing_college_debt','credit_hours_per_year', 'annual_financial_aid','existing_college_debt','credit_hours_per_year',
'hours_completed','credit_hours_required','tuition_paid' 'hours_completed','credit_hours_required','tuition_paid'
].includes(name)) { ].includes(field)) {
const parsed = parseFloat(val); const parsed = parseFloat(val);
setData(prev => ({ ...prev, [name]: isNaN(parsed) ? '' : parsed })); setData(prev => ({ ...prev, [field]: isNaN(parsed) ? '' : parsed }));
} else { } else {
// For non-numeric or strings // For non-numeric or strings
setData(prev => ({ ...prev, [name]: val })); setData(prev => ({ ...prev, [field]: val }));
} }
}; };
@ -202,6 +237,7 @@ useEffect(() => {
setSchoolSuggestions([]); setSchoolSuggestions([]);
setProgramSuggestions([]); setProgramSuggestions([]);
setAvailableProgramTypes([]); setAvailableProgramTypes([]);
saveDraft({ collegeData: { selected_school: name } }).catch(() => {});
}; };
// Program // Program
@ -222,6 +258,7 @@ useEffect(() => {
const handleProgramSelect = (prog) => { const handleProgramSelect = (prog) => {
setData(prev => ({ ...prev, selected_program: prog })); setData(prev => ({ ...prev, selected_program: prog }));
setProgramSuggestions([]); setProgramSuggestions([]);
saveDraft({ collegeData: { selected_program: prog } }).catch(() => {});
}; };
const handleProgramTypeSelect = (e) => { const handleProgramTypeSelect = (e) => {
@ -233,6 +270,7 @@ useEffect(() => {
})); }));
setManualProgramLength(''); setManualProgramLength('');
setAutoProgramLength('0.00'); setAutoProgramLength('0.00');
saveDraft({ collegeData: { program_type: val } }).catch(() => {});
}; };
// once we have school+program => load possible program types // once we have school+program => load possible program types
@ -305,6 +343,26 @@ useEffect(() => {
credit_hours_required, credit_hours_required,
]); ]);
useEffect(() => {
const hasSchool = !!data.selected_school;
const hasAnyProgram = !!data.selected_program || !!data.program_type;
if (!hasSchool && !hasAnyProgram) return;
setSelectedSchool(prev => {
const next = {
INSTNM : data.selected_school || '',
CIPDESC : data.selected_program || '',
CREDDESC: data.program_type || ''
};
// avoid useless state churn
if (prev &&
prev.INSTNM === next.INSTNM &&
prev.CIPDESC === next.CIPDESC &&
prev.CREDDESC=== next.CREDDESC) return prev;
return next;
});
}, [data.selected_school, data.selected_program, data.program_type]);
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Whenever the user changes enrollmentDate OR programLength */ /* Whenever the user changes enrollmentDate OR programLength */
/* (program_length is already in parent data), compute grad date. */ /* (program_length is already in parent data), compute grad date. */
@ -466,7 +524,7 @@ const ready =
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="block font-medium">Marjor/Program Name* (Please select from drop-down after typing){infoIcon("Search and click from auto-suggest. If for some reason your major isn't listed, please send us a note.")}</label> <label className="block font-medium">Major/Program Name* (Please select from drop-down after typing){infoIcon("Search and click from auto-suggest. If for some reason your major isn't listed, please send us a note.")}</label>
<input <input
name="selected_program" name="selected_program"
value={selected_program} value={selected_program}

View File

@ -3,6 +3,7 @@ import React, { useState } from 'react';
import Modal from '../ui/modal.js'; import Modal from '../ui/modal.js';
import ExpensesWizard from '../../components/ExpensesWizard.js'; // path to your wizard import ExpensesWizard from '../../components/ExpensesWizard.js'; // path to your wizard
import { Button } from '../../components/ui/button.js'; // using your Tailwind-based button import { Button } from '../../components/ui/button.js'; // using your Tailwind-based button
import { saveDraft, clearDraft, loadDraft } from '../../utils/onboardingDraftApi.js';
const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => { const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
const { const {
@ -26,10 +27,8 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
}; };
const handleExpensesCalculated = (total) => { const handleExpensesCalculated = (total) => {
setData(prev => ({ setData(prev => ({...prev, monthly_expenses: total }));
...prev, saveDraft({ financialData: { monthly_expenses: total } }).catch(() => {});
monthly_expenses: total
}));
}; };
const infoIcon = (msg) => ( const infoIcon = (msg) => (
@ -55,6 +54,12 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
extra_cash_emergency_pct: val, extra_cash_emergency_pct: val,
extra_cash_retirement_pct: 100 - val extra_cash_retirement_pct: 100 - val
})); }));
saveDraft({
financialData: {
extra_cash_emergency_pct: val,
extra_cash_retirement_pct: 100 - val
}
}).catch(() => {});
} else if (name === 'extra_cash_retirement_pct') { } else if (name === 'extra_cash_retirement_pct') {
val = Math.min(Math.max(val, 0), 100); val = Math.min(Math.max(val, 0), 100);
setData(prevData => ({ setData(prevData => ({
@ -62,8 +67,20 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
extra_cash_retirement_pct: val, extra_cash_retirement_pct: val,
extra_cash_emergency_pct: 100 - val extra_cash_emergency_pct: 100 - val
})); }));
saveDraft({
financialData: {
extra_cash_emergency_pct: val,
extra_cash_retirement_pct: 100 - val
}
}).catch(() => {});
} else { } else {
setData(prevData => ({ ...prevData, [name]: val })); setData(prevData => ({ ...prevData, [name]: val }));
saveDraft({
financialData: {
extra_cash_emergency_pct: val,
extra_cash_retirement_pct: 100 - val
}
}).catch(() => {});
} }
}; };