909 lines
37 KiB
JavaScript
909 lines
37 KiB
JavaScript
// src/components/MilestoneEditModal.js
|
||
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { Button } from './ui/button.js';
|
||
import InfoTooltip from './ui/infoTooltip.js';
|
||
import authFetch from '../utils/authFetch.js';
|
||
import MilestoneCopyWizard from './MilestoneCopyWizard.js';
|
||
|
||
/* 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,
|
||
}) {
|
||
/* ───────────────── state */
|
||
const [milestones, setMilestones] = useState(incomingMils);
|
||
const [editingId, setEditingId] = useState(null);
|
||
|
||
// 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: [],
|
||
tasks: [],
|
||
isUniversal: 0,
|
||
});
|
||
|
||
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
|
||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||
const [isSavingNew, setIsSavingNew] = useState(false);
|
||
|
||
/* keep list in sync with prop */
|
||
useEffect(() => setMilestones(incomingMils), [incomingMils]);
|
||
|
||
/* --------------------------------------------------------- *
|
||
* Load impacts + tasks for one milestone then open it
|
||
* --------------------------------------------------------- */
|
||
const openEditor = useCallback(
|
||
async (m) => {
|
||
setEditingId(m.id);
|
||
|
||
// 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', // '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),
|
||
}));
|
||
|
||
// 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 || '',
|
||
date: toSqlDate(m.date),
|
||
progress: m.progress || 0,
|
||
newSalary: m.new_salary || '',
|
||
impacts: imps,
|
||
tasks,
|
||
isUniversal: m.is_universal ? 1 : 0,
|
||
},
|
||
}));
|
||
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);
|
||
} else {
|
||
openEditor(m);
|
||
}
|
||
};
|
||
|
||
// auto-open when a specific milestone is passed in
|
||
useEffect(() => {
|
||
if (selectedMilestone) openEditor(selectedMilestone);
|
||
}, [selectedMilestone, openEditor]);
|
||
|
||
/* --------------------------------------------------------- *
|
||
* 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 };
|
||
return { ...p, [mid]: { ...d, impacts: copy } };
|
||
});
|
||
|
||
const addImpactRow = (mid) =>
|
||
setDraft((p) => {
|
||
const d = p[mid];
|
||
if (!d) return p;
|
||
const blank = { impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' };
|
||
return { ...p, [mid]: { ...d, impacts: [...d.impacts, blank] } };
|
||
});
|
||
|
||
const removeImpactRow = (mid, idx) =>
|
||
setDraft((p) => {
|
||
const d = p[mid];
|
||
if (!d) return p;
|
||
const c = [...d.impacts];
|
||
c.splice(idx, 1);
|
||
return { ...p, [mid]: { ...d, impacts: c } };
|
||
});
|
||
|
||
/* --------------------------------------------------------- *
|
||
* 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;
|
||
const d = draft[m.id];
|
||
if (!d) return;
|
||
setIsSavingEdit(true);
|
||
|
||
try {
|
||
/* header */
|
||
const payload = {
|
||
milestone_type: 'Financial',
|
||
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 ? 1 : 0,
|
||
};
|
||
|
||
const res = await authFetch(`/api/premium/milestones/${m.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!res.ok) throw new Error(await res.text());
|
||
const saved = await res.json();
|
||
|
||
/* impacts diff */
|
||
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 || []) {
|
||
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: 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),
|
||
});
|
||
} else {
|
||
await authFetch('/api/premium/milestone-impacts', {
|
||
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);
|
||
onClose(true);
|
||
} catch (err) {
|
||
console.error('saveMilestone:', err);
|
||
alert(err.message || 'Save failed');
|
||
} finally {
|
||
setIsSavingEdit(false);
|
||
}
|
||
}
|
||
|
||
async function deleteMilestone(m) {
|
||
if (!window.confirm(`Delete “${m.title}”?`)) return;
|
||
await authFetch(`/api/premium/milestones/${m.id}`, { method: 'DELETE' });
|
||
await fetchMilestones();
|
||
onClose(true);
|
||
}
|
||
|
||
/* --------------------------------------------------------- *
|
||
* New-milestone helpers (create flow)
|
||
* --------------------------------------------------------- */
|
||
const addBlankImpactToNew = () =>
|
||
setNewMilestone((n) => ({
|
||
...n,
|
||
impacts: [...n.impacts, { impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' }],
|
||
}));
|
||
|
||
const updateNewImpact = (idx, field, val) =>
|
||
setNewMilestone((n) => {
|
||
const c = [...n.impacts];
|
||
c[idx] = { ...c[idx], [field]: val };
|
||
return { ...n, impacts: c };
|
||
});
|
||
|
||
const removeNewImpact = (idx) =>
|
||
setNewMilestone((n) => {
|
||
const c = [...n.impacts];
|
||
c.splice(idx, 1);
|
||
return { ...n, impacts: c };
|
||
});
|
||
|
||
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;
|
||
}
|
||
setIsSavingNew(true);
|
||
|
||
try {
|
||
const hdr = {
|
||
title: newMilestone.title,
|
||
description: newMilestone.description,
|
||
date: toDateOrNull(newMilestone.date),
|
||
career_profile_id: careerProfileId,
|
||
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),
|
||
});
|
||
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 impacts
|
||
for (const imp of newMilestone.impacts) {
|
||
if (!imp) continue;
|
||
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: 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),
|
||
});
|
||
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);
|
||
} catch (err) {
|
||
console.error('saveNew:', err);
|
||
alert(err.message || 'Failed to save milestone');
|
||
} finally {
|
||
setIsSavingNew(false);
|
||
}
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════ */
|
||
/* RENDER */
|
||
/* ══════════════════════════════════════════════════════════════ */
|
||
return (
|
||
<div className="fixed inset-0 z-[9999] flex items-start justify-center overflow-y-auto bg-black/40">
|
||
<div className="bg-white w-full max-w-3xl mx-4 my-10 rounded-md shadow-lg ring-1 ring-gray-300 overflow-hidden">
|
||
{/* header */}
|
||
<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>
|
||
</div>
|
||
<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) => {
|
||
const open = editingId === m.id;
|
||
const d = draft[m.id] || {};
|
||
return (
|
||
<div key={m.id} className="border rounded-md">
|
||
{/* 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)}
|
||
>
|
||
<span className="font-medium">{m.title}</span>
|
||
<span className="text-sm text-gray-500">
|
||
{toSqlDate(m.date)} ▸ {open ? 'Hide' : 'Edit'}
|
||
</span>
|
||
</button>
|
||
|
||
{open && (
|
||
<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)." />
|
||
</label>
|
||
<input
|
||
className="input"
|
||
value={d.title || ''}
|
||
onChange={(e) => setDraft((p) => ({ ...p, [m.id]: { ...p[m.id], title: e.target.value } }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium">Date</label>
|
||
<input
|
||
type="date"
|
||
className="input"
|
||
value={d.date || ''}
|
||
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." />
|
||
</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 } }))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* impacts */}
|
||
<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>
|
||
</div>
|
||
<p className="text-xs text-gray-500 mb-2">
|
||
Use <em>salary</em> for annual income changes; <em>monthly</em> for recurring amounts.
|
||
</p>
|
||
|
||
<div className="space-y-3">
|
||
{d.impacts?.map((imp, 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]"
|
||
>
|
||
{/* type */}
|
||
<div>
|
||
<label className="label-xs">Type</label>
|
||
<select
|
||
className="input w-full"
|
||
value={imp.impact_type}
|
||
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="MONTHLY">Monthly</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* direction – hide for salary */}
|
||
{imp.impact_type !== 'salary' ? (
|
||
<div className="min-w-0">
|
||
<label className="label-xs">Direction</label>
|
||
<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>
|
||
) : (
|
||
<div className="hidden md:block" />
|
||
)}
|
||
|
||
{/* amount */}
|
||
<div className="md:w-[100px]">
|
||
<label className="label-xs">Amount</label>
|
||
<input
|
||
type="number"
|
||
className="input w-full"
|
||
value={imp.amount}
|
||
onChange={(e) => updateImpact(m.id, idx, 'amount', e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* dates */}
|
||
<div className="grid grid-cols-2 gap-2 min-w-0">
|
||
<div>
|
||
<label className="label-xs">Start</label>
|
||
<input
|
||
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)}
|
||
/>
|
||
</div>
|
||
{imp.impact_type === 'MONTHLY' && (
|
||
<div>
|
||
<label className="label-xs">End</label>
|
||
<input
|
||
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)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* remove */}
|
||
<Button
|
||
size="icon-xs"
|
||
className="!bg-red-600 !hover:bg-red-700 !text-white w-10 h-9 justify-self-end shrink-0"
|
||
onClick={() => removeImpactRow(m.id, idx)}
|
||
>
|
||
✕
|
||
</Button>
|
||
</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 */}
|
||
<div className="flex justify-between pt-4 border-t">
|
||
<div className="space-x-2">
|
||
<Button
|
||
onClick={() => deleteMilestone(m)}
|
||
className="bg-red-600 hover:bg-red-700 text-white"
|
||
>
|
||
Delete milestone
|
||
</Button>
|
||
<Button variant="secondary" onClick={() => setCopyWizardMilestone(m)}>
|
||
Copy to other scenarios
|
||
</Button>
|
||
</div>
|
||
<Button disabled={isSavingEdit} onClick={() => saveMilestone(m)}>
|
||
{isSavingEdit ? 'Saving…' : 'Save'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* NEW milestone accordion */}
|
||
<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);
|
||
}}
|
||
>
|
||
Add new milestone
|
||
<span>{addingNew ? '–' : '+'}</span>
|
||
</summary>
|
||
|
||
{addingNew && (
|
||
<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 w-full"
|
||
value={newMilestone.title}
|
||
onChange={(e) => setNewMilestone((n) => ({ ...n, title: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="label-xs">Date</label>
|
||
<input
|
||
type="date"
|
||
className="input w-full min-w-[12ch] px-3"
|
||
value={newMilestone.date}
|
||
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 w-full resize-none"
|
||
value={newMilestone.description}
|
||
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>
|
||
</div>
|
||
<div className="space-y-3 mt-2">
|
||
{newMilestone.impacts.map((imp, 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]"
|
||
>
|
||
{/* Type */}
|
||
<div className="min-w-0">
|
||
<label className="label-xs">Type</label>
|
||
<select
|
||
className="input w-full"
|
||
value={imp.impact_type}
|
||
onChange={(e) => updateNewImpact(idx, 'impact_type', e.target.value)}
|
||
>
|
||
<option value="salary">Salary (annual)</option>
|
||
<option value="ONE_TIME">One-time</option>
|
||
<option value="MONTHLY">Monthly</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Direction (spacer when salary) */}
|
||
{imp.impact_type !== 'salary' ? (
|
||
<div className= "min-w-0">
|
||
<label className="label-xs">Direction</label>
|
||
<select
|
||
className="input w-full"
|
||
value={imp.direction}
|
||
onChange={(e) => updateNewImpact(idx, 'direction', e.target.value)}
|
||
>
|
||
<option value="add">Add</option>
|
||
<option value="subtract">Subtract</option>
|
||
</select>
|
||
</div>
|
||
) : (
|
||
<div className="hidden md:block" />
|
||
)}
|
||
|
||
{/* Amount */}
|
||
<div className="md:w-[100px]">
|
||
<label className="label-xs">Amount</label>
|
||
<input
|
||
type="number"
|
||
className="input w-full"
|
||
value={imp.amount}
|
||
onChange={(e) => updateNewImpact(idx, 'amount', e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Dates */}
|
||
<div className="grid grid-cols-2 gap-2 min-w-0">
|
||
<div className="min-w-0">
|
||
<label className="label-xs">Start</label>
|
||
<input
|
||
type="date"
|
||
className="input w-full min-w-[14ch] px-3"
|
||
value={imp.start_date}
|
||
onChange={(e) => updateNewImpact(idx, 'start_date', e.target.value)}
|
||
/>
|
||
</div>
|
||
{imp.impact_type === 'MONTHLY' && (
|
||
<div>
|
||
<label className="label-xs">End</label>
|
||
<input
|
||
type="date"
|
||
className="input w-full min-w-[14ch] px-3"
|
||
value={imp.end_date}
|
||
onChange={(e) => updateNewImpact(idx, 'end_date', e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Remove */}
|
||
<Button
|
||
size="icon-xs"
|
||
variant="ghost"
|
||
onClick={() => removeNewImpact(idx)}
|
||
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}>
|
||
{isSavingNew ? 'Saving…' : 'Save milestone'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</details>
|
||
</div>
|
||
|
||
{/* footer */}
|
||
<div className="px-6 py-4 border-t text-right">
|
||
<Button variant="secondary" onClick={() => onClose(false)}>
|
||
Close
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* COPY wizard */}
|
||
{copyWizardMilestone && (
|
||
<MilestoneCopyWizard
|
||
milestone={copyWizardMilestone}
|
||
onClose={(didCopy) => {
|
||
setCopyWizardMilestone(null);
|
||
if (didCopy) fetchMilestones();
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|