302 lines
11 KiB
JavaScript
302 lines
11 KiB
JavaScript
// 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, Trash2, PencilLine, X } from 'lucide-react';
|
|
import { flattenTasks } from '../utils/taskHelpers.js';
|
|
import authFetch from '../utils/authFetch.js';
|
|
import format from 'date-fns/format';
|
|
|
|
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, // 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);
|
|
|
|
const [draftNew, setDraftNew] = useState({ title:'', due_date:'', description:'' });
|
|
const [draftEdit, setDraftEdit] = useState({ title:'', due_date:'', description:'' });
|
|
|
|
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;
|
|
|
|
async function toggle(t) {
|
|
const newStatus = nextStatus[t.status] || 'not_started';
|
|
|
|
// 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}`, {
|
|
method : 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body : JSON.stringify({ status: newStatus })
|
|
});
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
{/* Click-away area: tap to close (sits above page, below the panel) */}
|
|
<div
|
|
className="fixed inset-0 z-[55] bg-transparent"
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
{/* Panel */}
|
|
<div className="fixed inset-y-0 right-0 z-[60] flex w-[88vw] max-w-[380px] sm:w-[360px] md:w-[420px] flex-col bg-white shadow-xl pb-[env(safe-area-inset-bottom)]">
|
|
{/* Header */}
|
|
<div className="px-4 py-2 border-b flex items-center gap-3">
|
|
<Button size="icon" variant="ghost" onClick={onClose}>
|
|
<ChevronLeft className="w-5 h-5" />
|
|
</Button>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">{milestone.title}</p>
|
|
{milestone.date && (
|
|
<p className="text-xs text-gray-500 truncate">
|
|
{format(new Date(milestone.date), 'PP')}
|
|
</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 */}
|
|
<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 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>
|
|
<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>
|
|
|
|
<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={`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 && !adding && (
|
|
<p className="text-sm text-gray-500">No tasks have been added to this milestone yet.</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|