dev1/src/components/MilestoneEditModal.js
Josh 893757646b
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Added manual task manipulation, fixed UI
2025-08-25 16:03:22 +00:00

909 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}