Added manual task manipulation, fixed UI
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
e6d567d839
commit
893757646b
@ -1 +1 @@
|
||||
b632ad41cfb05900be9a667c396e66a4dfb26320-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
afd62e0deab27814cfa0067f1fae1dc4ad79e7dd-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
|
@ -1,60 +1,58 @@
|
||||
// src/components/MilestoneDrawer.js
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button.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 authFetch from '../utils/authFetch.js';
|
||||
import format from 'date-fns/format';
|
||||
|
||||
/* simple status → color map */
|
||||
const pillStyle = {
|
||||
completed : 'bg-green-100 text-green-800',
|
||||
in_progress : 'bg-blue-100 text-blue-800',
|
||||
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({
|
||||
milestone, // ← pass a single milestone object
|
||||
milestones = [], // still needed to compute progress %
|
||||
milestone, // single milestone object
|
||||
milestones = [], // still available if you compute progress elsewhere
|
||||
open,
|
||||
onClose,
|
||||
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 [tasks, setTasks] = useState(
|
||||
milestone ? flattenTasks([milestone]) : []
|
||||
);
|
||||
const [draftNew, setDraftNew] = useState({ title:'', due_date:'', description:'' });
|
||||
const [draftEdit, setDraftEdit] = useState({ title:'', due_date:'', description:'' });
|
||||
|
||||
// refresh local copy whenever the user selects a different milestone
|
||||
useEffect(() => {
|
||||
setTasks(milestone ? flattenTasks([milestone]) : []);
|
||||
setAdding(false);
|
||||
setEditingId(null);
|
||||
setDraftNew({ title:'', due_date:'', description:'' });
|
||||
}, [milestone]);
|
||||
|
||||
if (!open || !milestone) return null;
|
||||
|
||||
const done = tasks.filter(t => t.status === 'completed').length;
|
||||
const prog = tasks.length ? Math.round(100 * done / tasks.length) : 0;
|
||||
|
||||
if (!open || !milestone) return null;
|
||||
|
||||
async function toggle(t) {
|
||||
const newStatus = nextStatus[t.status] || 'not_started';
|
||||
|
||||
const next = {
|
||||
not_started : 'in_progress',
|
||||
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 */
|
||||
// optimistic local update
|
||||
setTasks(prev => prev.map(x => x.id === t.id ? { ...x, status:newStatus } : x));
|
||||
onTaskToggle(t.id, newStatus);
|
||||
|
||||
await authFetch(`/api/premium/tasks/${t.id}`, {
|
||||
@ -64,12 +62,83 @@ const newStatus = next[t.status] || 'not_started';
|
||||
});
|
||||
}
|
||||
|
||||
const statusLabel = {
|
||||
not_started : 'Not started',
|
||||
in_progress : 'In progress',
|
||||
completed : 'Completed'
|
||||
async function createTask() {
|
||||
const { title, due_date, description } = draftNew;
|
||||
if (!title.trim()) return;
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-y-0 right-0 w-full max-w-sm bg-white shadow-xl z-40 flex flex-col">
|
||||
{/* Header */}
|
||||
@ -85,57 +154,135 @@ const newStatus = next[t.status] || 'not_started';
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setAdding(a => !a)}>
|
||||
{adding ? 'Cancel' : '+ Add task'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<Card className="flex-1 overflow-y-auto rounded-none">
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
{/* Progress bar */}
|
||||
{/* Progress */}
|
||||
<div>
|
||||
<progress value={prog} max={100} className="w-full h-2" />
|
||||
<p className="text-xs text-gray-500 mt-1">{prog}% complete</p>
|
||||
</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 */}
|
||||
{tasks.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="border p-3 rounded-lg flex items-start justify-between"
|
||||
>
|
||||
<div className="pr-2">
|
||||
<div key={t.id} className="border p-3 rounded-lg space-y-2">
|
||||
{editingId === t.id ? (
|
||||
<>
|
||||
<div>
|
||||
<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>
|
||||
{t.due_date && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{format(new Date(t.due_date), 'PP')}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 space-x-2">
|
||||
{t.due_date && <span>{format(new Date(t.due_date), 'PP')}</span>}
|
||||
{t.description && <span className="block text-gray-600">{t.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="Toggle task status"
|
||||
onClick={() => toggle(t)}
|
||||
>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button size="icon" variant="ghost" aria-label="Toggle" onClick={() => toggle(t)}>
|
||||
{t.status === 'completed'
|
||||
? <Check className="w-5 h-5 text-green-600" />
|
||||
: (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
: <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>}
|
||||
</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>
|
||||
))}
|
||||
|
||||
{!tasks.length && (
|
||||
<p className="text-sm text-gray-500">
|
||||
No tasks have been added to this milestone yet.
|
||||
</p>
|
||||
{!tasks.length && !adding && (
|
||||
<p className="text-sm text-gray-500">No tasks have been added to this milestone yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
@ -5,27 +5,41 @@ import InfoTooltip from './ui/infoTooltip.js';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
import MilestoneCopyWizard from './MilestoneCopyWizard.js';
|
||||
|
||||
/* Helper ---------------------------------------------------- */
|
||||
/* Helpers ---------------------------------------------------- */
|
||||
const toSqlDate = (v) => (v ? String(v).slice(0, 10) : '');
|
||||
const toDateOrNull = (v) => (v ? String(v).slice(0, 10) : null);
|
||||
|
||||
export default function MilestoneEditModal({
|
||||
careerProfileId,
|
||||
milestones: incomingMils = [],
|
||||
milestone: selectedMilestone,
|
||||
fetchMilestones,
|
||||
onClose
|
||||
onClose,
|
||||
}) {
|
||||
/* ───────────────── state */
|
||||
const [milestones, setMilestones] = useState(incomingMils);
|
||||
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 [originalTaskIdsMap, setOriginalTaskIdsMap] = useState({});
|
||||
|
||||
const [addingNew, setAddingNew] = useState(false);
|
||||
const [newMilestone, setNewMilestone] = useState({
|
||||
title:'', description:'', date:'', progress:0, newSalary:'',
|
||||
impacts:[], isUniversal:0
|
||||
title: '',
|
||||
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 [isSavingNew, setIsSavingNew] = useState(false);
|
||||
|
||||
@ -33,23 +47,37 @@ export default function MilestoneEditModal({
|
||||
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);
|
||||
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=>({
|
||||
|
||||
// Fetch impacts
|
||||
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,
|
||||
impact_type : i.impact_type||'ONE_TIME',
|
||||
direction : i.direction||'subtract',
|
||||
impact_type: i.impact_type || 'ONE_TIME', // 'salary' | 'ONE_TIME' | 'MONTHLY'
|
||||
direction: i.impact_type === 'salary' ? 'add' : (i.direction || 'subtract'),
|
||||
amount: i.amount || 0,
|
||||
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]: {
|
||||
title: m.title || '',
|
||||
description: m.description || '',
|
||||
@ -57,99 +85,194 @@ export default function MilestoneEditModal({
|
||||
progress: m.progress || 0,
|
||||
newSalary: m.new_salary || '',
|
||||
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)}));
|
||||
}, [editingId]);
|
||||
setOriginalImpactIdsMap((p) => ({ ...p, [m.id]: imps.map((i) => i.id).filter(Boolean) }));
|
||||
setOriginalTaskIdsMap((p) => ({ ...p, [m.id]: tasks.map((t) => t.id).filter(Boolean) }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAccordionClick = (m) => {
|
||||
if (editingId === m.id) {
|
||||
setEditingId(null); // just close
|
||||
setEditingId(null);
|
||||
} else {
|
||||
openEditor(m); // open + fetch
|
||||
openEditor(m);
|
||||
}
|
||||
};
|
||||
|
||||
/* open editor automatically when parent passed selectedMilestone */
|
||||
useEffect(()=>{ if(selectedMilestone) openEditor(selectedMilestone)},[selectedMilestone,openEditor]);
|
||||
// auto-open when a specific milestone is passed in
|
||||
useEffect(() => {
|
||||
if (selectedMilestone) openEditor(selectedMilestone);
|
||||
}, [selectedMilestone, openEditor]);
|
||||
|
||||
/* --------------------------------------------------------- *
|
||||
* Handlers – shared small helpers
|
||||
* Impact 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 };
|
||||
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 } };
|
||||
});
|
||||
|
||||
const addImpactRow = (mid) =>
|
||||
setDraft(p=>{
|
||||
const d=p[mid]; if(!d) return p;
|
||||
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);
|
||||
setDraft((p) => {
|
||||
const d = p[mid];
|
||||
if (!d) return p;
|
||||
const c = [...d.impacts];
|
||||
c.splice(idx, 1);
|
||||
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)
|
||||
* --------------------------------------------------------- */
|
||||
async function saveMilestone(m) {
|
||||
if(isSavingEdit) return; // guard
|
||||
const d = draft[m.id]; if(!d) return;
|
||||
if (isSavingEdit) return;
|
||||
const d = draft[m.id];
|
||||
if (!d) return;
|
||||
setIsSavingEdit(true);
|
||||
|
||||
try {
|
||||
/* 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',
|
||||
title: d.title,
|
||||
description: d.description,
|
||||
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,
|
||||
is_universal:d.isUniversal
|
||||
is_universal: d.isUniversal ? 1 : 0,
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
/* 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))){
|
||||
const originalImpIds = originalImpactIdsMap[m.id] || [];
|
||||
const currentImpIds = (d.impacts || []).map((i) => i.id).filter(Boolean);
|
||||
|
||||
// deletions
|
||||
for (const id of originalImpIds.filter((x) => !currentImpIds.includes(x))) {
|
||||
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 = {
|
||||
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
|
||||
start_date: toDateOrNull(imp.start_date),
|
||||
end_date: toDateOrNull(imp.end_date),
|
||||
};
|
||||
if (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 {
|
||||
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();
|
||||
setEditingId(null);
|
||||
setIsSavingEdit(false);
|
||||
onClose(true);
|
||||
} catch (err) {
|
||||
console.error('saveMilestone:', err);
|
||||
alert(err.message || 'Save failed');
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMilestone(m) {
|
||||
@ -160,64 +283,118 @@ export default function MilestoneEditModal({
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- *
|
||||
* New‑milestone helpers (create flow)
|
||||
* 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 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 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() {
|
||||
if (isSavingNew) return;
|
||||
if (!newMilestone.title.trim() || !newMilestone.date.trim()) {
|
||||
alert('Need title & date'); return;
|
||||
alert('Need title & date');
|
||||
return;
|
||||
}
|
||||
setIsSavingNew(true);
|
||||
const toDate = (v) => (v ? String(v).slice(0,10) : null);
|
||||
|
||||
try {
|
||||
const hdr = {
|
||||
title: newMilestone.title,
|
||||
description: newMilestone.description,
|
||||
date: toDate(newMilestone.date),
|
||||
date: toDateOrNull(newMilestone.date),
|
||||
career_profile_id: careerProfileId,
|
||||
progress: newMilestone.progress,
|
||||
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
|
||||
is_universal: newMilestone.isUniversal,
|
||||
progress: newMilestone.progress || 0,
|
||||
status: (newMilestone.progress || 0) >= 100 ? 'completed' : 'planned',
|
||||
is_universal: newMilestone.isUniversal ? 1 : 0,
|
||||
};
|
||||
|
||||
const res = await authFetch('/api/premium/milestone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(hdr)
|
||||
body: JSON.stringify(hdr),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const createdJson = await res.json();
|
||||
const created = Array.isArray(createdJson) ? createdJson[0] : createdJson;
|
||||
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) {
|
||||
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;
|
||||
const ibody = {
|
||||
milestone_id: created.id,
|
||||
impact_type: imp.impact_type,
|
||||
direction: imp.impact_type === 'salary' ? 'add' : imp.direction,
|
||||
amount: parseFloat(imp.amount) || 0,
|
||||
start_date : toDate(imp.start_date),
|
||||
end_date : toDate(imp.end_date),
|
||||
start_date: toDateOrNull(imp.start_date),
|
||||
end_date: toDateOrNull(imp.end_date),
|
||||
};
|
||||
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());
|
||||
}
|
||||
|
||||
// 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();
|
||||
setAddingNew(false);
|
||||
onClose(true);
|
||||
@ -239,17 +416,17 @@ export default function MilestoneEditModal({
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Milestones</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
Track important events and their financial impact on this scenario.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Track important events and their financial impact on this scenario.</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => onClose(false)}>✕</Button>
|
||||
<Button variant="ghost" onClick={() => onClose(false)}>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto overflow-x-hidden">
|
||||
{/* EXISTING */}
|
||||
{milestones.map(m=>{
|
||||
{milestones.map((m) => {
|
||||
const open = editingId === m.id;
|
||||
const d = draft[m.id] || {};
|
||||
return (
|
||||
@ -257,7 +434,8 @@ export default function MilestoneEditModal({
|
||||
{/* accordion header */}
|
||||
<button
|
||||
className="w-full flex justify-between items-center px-4 py-2 bg-gray-50 hover:bg-gray-100 text-left"
|
||||
onClick={()=>handleAccordionClick(m)}>
|
||||
onClick={() => handleAccordionClick(m)}
|
||||
>
|
||||
<span className="font-medium">{m.title}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{toSqlDate(m.date)} ▸ {open ? 'Hide' : 'Edit'}
|
||||
@ -265,17 +443,17 @@ export default function MilestoneEditModal({
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-4 py-4 grid gap-4 bg-white">
|
||||
<div className="px-4 py-4 grid gap-6 bg-white">
|
||||
{/* ------------- fields */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium flex items-center gap-1">
|
||||
Title <InfoTooltip message="Short, action‑oriented label (max 60 chars)." />
|
||||
Title <InfoTooltip message="Short, action-oriented label (max 60 chars)." />
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
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 className="space-y-1">
|
||||
@ -284,18 +462,20 @@ export default function MilestoneEditModal({
|
||||
type="date"
|
||||
className="input"
|
||||
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 className="md:col-span-2 space-y-1">
|
||||
<label className="text-sm font-medium flex items-center gap-1">
|
||||
Description <InfoTooltip message="1‑2 sentences on what success looks like."/>
|
||||
Description <InfoTooltip message="1-2 sentences on what success looks like." />
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="input resize-none"
|
||||
value={d.description || ''}
|
||||
onChange={e=>setDraft(p=>({...p,[m.id]:{...p[m.id],description:e.target.value}}))}
|
||||
onChange={(e) =>
|
||||
setDraft((p) => ({ ...p, [m.id]: { ...p[m.id], description: e.target.value } }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -304,7 +484,9 @@ export default function MilestoneEditModal({
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<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>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
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">
|
||||
{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 */}
|
||||
<div>
|
||||
<label className="label-xs">Type</label>
|
||||
<select
|
||||
className="input"
|
||||
className="input w-full"
|
||||
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="ONE_TIME">One‑time</option>
|
||||
<option value="ONE_TIME">One-time</option>
|
||||
<option value="MONTHLY">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* direction – hide for salary */}
|
||||
{imp.impact_type !== 'salary' ? (
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<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="subtract">Subtract</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
// keep the grid column to prevent the next columns from collapsing
|
||||
<div className="hidden md:block" />
|
||||
)}
|
||||
|
||||
{/* amount */}
|
||||
<div className="md:w-[100px]">
|
||||
<label className="label-xs">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
className="input w-full"
|
||||
value={imp.amount}
|
||||
onChange={e=>updateImpact(m.id,idx,'amount',e.target.value)}
|
||||
onChange={(e) => updateImpact(m.id, idx, 'amount', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* dates */}
|
||||
<div className="grid grid-cols-2 gap-2 min-w-0">
|
||||
<div>
|
||||
@ -356,7 +549,7 @@ export default function MilestoneEditModal({
|
||||
type="date"
|
||||
className="input w-full min-w-[14ch] px-3"
|
||||
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>
|
||||
{imp.impact_type === 'MONTHLY' && (
|
||||
@ -366,16 +559,16 @@ export default function MilestoneEditModal({
|
||||
type="date"
|
||||
className="input w-full min-w-[14ch] px-3"
|
||||
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>
|
||||
|
||||
{/* remove */}
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-red-600 w-10 h-9 flex items-center justify-center"
|
||||
className="!bg-red-600 !hover:bg-red-700 !text-white w-10 h-9 justify-self-end shrink-0"
|
||||
onClick={() => removeImpactRow(m.id, idx)}
|
||||
>
|
||||
✕
|
||||
@ -385,13 +578,83 @@ export default function MilestoneEditModal({
|
||||
</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 */}
|
||||
<div className="flex justify-between pt-4 border-t">
|
||||
<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
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={()=>setMilestoneCopyWizard(m)}>
|
||||
<Button variant="secondary" onClick={() => setCopyWizardMilestone(m)}>
|
||||
Copy to other scenarios
|
||||
</Button>
|
||||
</div>
|
||||
@ -409,22 +672,25 @@ export default function MilestoneEditModal({
|
||||
<details className="border rounded-md" open={addingNew}>
|
||||
<summary
|
||||
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
|
||||
<span>{addingNew ? '–' : '+'}</span>
|
||||
</summary>
|
||||
|
||||
{addingNew && (
|
||||
<div className="px-4 py-4 space-y-4 bg-white">
|
||||
<div className="px-4 py-4 space-y-6 bg-white">
|
||||
{/* fields */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="label-xs">Title</label>
|
||||
<input
|
||||
className="input"
|
||||
className="input w-full"
|
||||
value={newMilestone.title}
|
||||
onChange={e=>setNewMilestone(n=>({...n,title:e.target.value}))}
|
||||
onChange={(e) => setNewMilestone((n) => ({ ...n, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@ -433,36 +699,40 @@ export default function MilestoneEditModal({
|
||||
type="date"
|
||||
className="input w-full min-w-[12ch] px-3"
|
||||
value={newMilestone.date}
|
||||
onChange={e=>setNewMilestone(n=>({...n,date:e.target.value}))}
|
||||
onChange={(e) => setNewMilestone((n) => ({ ...n, date: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<label className="label-xs">Description</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="input resize-none"
|
||||
className="input w-full resize-none"
|
||||
value={newMilestone.description}
|
||||
onChange={e=>setNewMilestone(n=>({...n,description:e.target.value}))}
|
||||
onChange={(e) => setNewMilestone((n) => ({ ...n, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* impacts */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm">Financial impacts</h4>
|
||||
<Button size="xs" className="shrink-0" onClick={addBlankImpactToNew}>+ Add impact</Button>
|
||||
<Button size="xs" className="shrink-0" onClick={addBlankImpactToNew}>
|
||||
+ Add impact
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 mt-2">
|
||||
{newMilestone.impacts.map((imp, idx) => (
|
||||
<div
|
||||
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 */}
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<label className="label-xs">Type</label>
|
||||
<select
|
||||
className="input"
|
||||
className="input w-full"
|
||||
value={imp.impact_type}
|
||||
onChange={(e) => updateNewImpact(idx, 'impact_type', e.target.value)}
|
||||
>
|
||||
@ -474,10 +744,10 @@ export default function MilestoneEditModal({
|
||||
|
||||
{/* Direction (spacer when salary) */}
|
||||
{imp.impact_type !== 'salary' ? (
|
||||
<div>
|
||||
<div className= "min-w-0">
|
||||
<label className="label-xs">Direction</label>
|
||||
<select
|
||||
className="input"
|
||||
className="input w-full"
|
||||
value={imp.direction}
|
||||
onChange={(e) => updateNewImpact(idx, 'direction', e.target.value)}
|
||||
>
|
||||
@ -489,20 +759,20 @@ export default function MilestoneEditModal({
|
||||
<div className="hidden md:block" />
|
||||
)}
|
||||
|
||||
{/* Amount (fixed width) */}
|
||||
{/* Amount */}
|
||||
<div className="md:w-[100px]">
|
||||
<label className="label-xs">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
className="input w-full"
|
||||
value={imp.amount}
|
||||
onChange={(e) => updateNewImpact(idx, 'amount', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dates (flex) */}
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-2 min-w-0">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<label className="label-xs">Start</label>
|
||||
<input
|
||||
type="date"
|
||||
@ -528,17 +798,82 @@ export default function MilestoneEditModal({
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-red-600 w-11 h-9 flex items-center justify-center"
|
||||
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>
|
||||
</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>
|
||||
|
||||
{/* save row */}
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
<Button disabled={isSavingNew} onClick={saveNew}>
|
||||
@ -552,15 +887,20 @@ export default function MilestoneEditModal({
|
||||
|
||||
{/* footer */}
|
||||
<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>
|
||||
|
||||
{/* COPY wizard */}
|
||||
{MilestoneCopyWizard && (
|
||||
{copyWizardMilestone && (
|
||||
<MilestoneCopyWizard
|
||||
milestone={MilestoneCopyWizard}
|
||||
onClose={(didCopy)=>{setMilestoneCopyWizard(null); if(didCopy) fetchMilestones();}}
|
||||
milestone={copyWizardMilestone}
|
||||
onClose={(didCopy) => {
|
||||
setCopyWizardMilestone(null);
|
||||
if (didCopy) fetchMilestones();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,6 +1,7 @@
|
||||
// CareerOnboarding.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { saveDraft, clearDraft, loadDraft } from '../../utils/onboardingDraftApi.js';
|
||||
|
||||
// 1) Import your CareerSearch component
|
||||
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
|
||||
const handleChange = (e) => {
|
||||
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 ─────────────────────────────────────────── */
|
||||
function handleCareerSelected(obj) {
|
||||
setCareerObj(obj);
|
||||
localStorage.setItem('selectedCareer', JSON.stringify(obj));
|
||||
setData(prev => ({ ...prev, career_name: obj.title, soc_code: obj.soc_code || '' }));
|
||||
function handleCareerSelected(career) {
|
||||
setCareerObj(career);
|
||||
localStorage.setItem('selectedCareer', JSON.stringify(career));
|
||||
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() {
|
||||
@ -95,8 +102,10 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
|
||||
<select
|
||||
value={currentlyWorking}
|
||||
onChange={(e) => {
|
||||
setCurrentlyWorking(e.target.value);
|
||||
setData(prev => ({ ...prev, currently_working: e.target.value }));
|
||||
const val = e.target.value;
|
||||
setCurrentlyWorking(val);
|
||||
setData(prev => ({ ...prev, currently_working: val }));
|
||||
saveDraft({ careerData: { currently_working: val} }).catch(() => {});
|
||||
}}
|
||||
required
|
||||
className="w-full border rounded p-2"
|
||||
@ -158,10 +167,11 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
|
||||
<select
|
||||
value={collegeStatus}
|
||||
onChange={(e) => {
|
||||
setCollegeStatus(e.target.value);
|
||||
setData(prev => ({ ...prev, college_enrollment_status: e.target.value }));
|
||||
const needsPrompt = ['currently_enrolled', 'prospective_student'].includes(e.target.value);
|
||||
setShowFinPrompt(needsPrompt);
|
||||
const val = e.target.value;
|
||||
setCurrentlyWorking(val);
|
||||
setData(prev => ({ ...prev, currently_working: val }));
|
||||
// persist immediately
|
||||
saveDraft({ careerData: { currently_working: val } }).catch(() => {});
|
||||
}}
|
||||
required
|
||||
className="w-full border rounded p-2"
|
||||
|
@ -3,6 +3,7 @@ import Modal from '../../components/ui/modal.js';
|
||||
import FinancialAidWizard from '../../components/FinancialAidWizard.js';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
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>;
|
||||
|
||||
@ -57,7 +58,7 @@ function toSchoolName(objOrStr) {
|
||||
// Destructure parent data
|
||||
const {
|
||||
college_enrollment_status = '',
|
||||
selected_school = selectedSchool,
|
||||
selected_school = '',
|
||||
selected_program = '',
|
||||
program_type = '',
|
||||
academic_calendar = 'semester',
|
||||
@ -99,6 +100,40 @@ function toSchoolName(objOrStr) {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (data.expected_graduation && !expectedGraduation)
|
||||
setExpectedGraduation(data.expected_graduation);
|
||||
@ -110,39 +145,39 @@ useEffect(() => {
|
||||
* Only parseFloat if there's an actual numeric value.
|
||||
*/
|
||||
const handleParentFieldChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
const { name: field, value, type, checked } = e.target;
|
||||
let val = value;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
val = checked;
|
||||
setData(prev => ({ ...prev, [name]: val }));
|
||||
setData(prev => ({ ...prev, [field]: val }));
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user typed an empty string, store '' so they can see it's blank
|
||||
if (val.trim() === '') {
|
||||
setData(prev => ({ ...prev, [name]: '' }));
|
||||
setData(prev => ({ ...prev, [field]: '' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// If parse fails => store '' (or fallback to old value)
|
||||
if (isNaN(parsed)) {
|
||||
setData(prev => ({ ...prev, [name]: '' }));
|
||||
setData(prev => ({ ...prev, [field]: '' }));
|
||||
} else {
|
||||
setData(prev => ({ ...prev, [name]: parsed }));
|
||||
setData(prev => ({ ...prev, [field]: parsed }));
|
||||
}
|
||||
} else if ([
|
||||
'annual_financial_aid','existing_college_debt','credit_hours_per_year',
|
||||
'hours_completed','credit_hours_required','tuition_paid'
|
||||
].includes(name)) {
|
||||
].includes(field)) {
|
||||
const parsed = parseFloat(val);
|
||||
setData(prev => ({ ...prev, [name]: isNaN(parsed) ? '' : parsed }));
|
||||
setData(prev => ({ ...prev, [field]: isNaN(parsed) ? '' : parsed }));
|
||||
} else {
|
||||
// For non-numeric or strings
|
||||
setData(prev => ({ ...prev, [name]: val }));
|
||||
setData(prev => ({ ...prev, [field]: val }));
|
||||
}
|
||||
};
|
||||
|
||||
@ -202,6 +237,7 @@ useEffect(() => {
|
||||
setSchoolSuggestions([]);
|
||||
setProgramSuggestions([]);
|
||||
setAvailableProgramTypes([]);
|
||||
saveDraft({ collegeData: { selected_school: name } }).catch(() => {});
|
||||
};
|
||||
|
||||
// Program
|
||||
@ -222,6 +258,7 @@ useEffect(() => {
|
||||
const handleProgramSelect = (prog) => {
|
||||
setData(prev => ({ ...prev, selected_program: prog }));
|
||||
setProgramSuggestions([]);
|
||||
saveDraft({ collegeData: { selected_program: prog } }).catch(() => {});
|
||||
};
|
||||
|
||||
const handleProgramTypeSelect = (e) => {
|
||||
@ -233,6 +270,7 @@ useEffect(() => {
|
||||
}));
|
||||
setManualProgramLength('');
|
||||
setAutoProgramLength('0.00');
|
||||
saveDraft({ collegeData: { program_type: val } }).catch(() => {});
|
||||
};
|
||||
|
||||
// once we have school+program => load possible program types
|
||||
@ -305,6 +343,26 @@ useEffect(() => {
|
||||
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 */
|
||||
/* (program_length is already in parent data), compute grad date. */
|
||||
@ -466,7 +524,7 @@ const ready =
|
||||
</div>
|
||||
|
||||
<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
|
||||
name="selected_program"
|
||||
value={selected_program}
|
||||
|
@ -3,6 +3,7 @@ import React, { useState } from 'react';
|
||||
import Modal from '../ui/modal.js';
|
||||
import ExpensesWizard from '../../components/ExpensesWizard.js'; // path to your wizard
|
||||
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 {
|
||||
@ -26,10 +27,8 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
};
|
||||
|
||||
const handleExpensesCalculated = (total) => {
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
monthly_expenses: total
|
||||
}));
|
||||
setData(prev => ({...prev, monthly_expenses: total }));
|
||||
saveDraft({ financialData: { monthly_expenses: total } }).catch(() => {});
|
||||
};
|
||||
|
||||
const infoIcon = (msg) => (
|
||||
@ -55,6 +54,12 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
extra_cash_emergency_pct: 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') {
|
||||
val = Math.min(Math.max(val, 0), 100);
|
||||
setData(prevData => ({
|
||||
@ -62,8 +67,20 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
extra_cash_retirement_pct: val,
|
||||
extra_cash_emergency_pct: 100 - val
|
||||
}));
|
||||
saveDraft({
|
||||
financialData: {
|
||||
extra_cash_emergency_pct: val,
|
||||
extra_cash_retirement_pct: 100 - val
|
||||
}
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
setData(prevData => ({ ...prevData, [name]: val }));
|
||||
saveDraft({
|
||||
financialData: {
|
||||
extra_cash_emergency_pct: val,
|
||||
extra_cash_retirement_pct: 100 - val
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user