- ProjectEditor gained two collapsed-by-default sections: Members (add/ remove viewer/editor via the UserPicker lookup) and Status pipeline (same add/rename/hold/done pattern as Settings' global one, scoped to the project via the new per-project status endpoints). - TaskDetail now resolves status pills against the task's project pipeline (useProjectStatuses) instead of always the global one, gained a Pin button (pinned_today), and a non-owner project 'editor' can now actually edit a task's content/status in the drawer (checked via a project-members lookup, since the task payload alone can't say who's an editor) -- list/project/assignment stay owner-only, matching the backend's 403s. - TodayView now fetches every open task (not just due-dated ones) and buckets into Overdue/Today(or pinned)/Next 7 days/Other, with "Other" collapsed by default -- so an empty agenda reads as "nothing due" instead of "nothing to do". - Extracted BoardView's column rendering into a reusable TaskBoard component (done columns collapse by default there too), and added a List/Board view-mode toggle to both ListView and ProjectDetailView instead of Board only existing as a separate global page you filter down to one list/project. - ListView and ProjectDetailView's Completed sections also collapse by default now, same reasoning as Board's done columns. tsc --noEmit and vite build both clean.
367 lines
17 KiB
TypeScript
367 lines
17 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send, ChevronDown, Pin } from 'lucide-react'
|
|
import type { TaskDetail as TDetail, Priority, RecurRule } from '../types'
|
|
import { PRIORITIES, PRIORITY_LABELS, RECUR_RULES, RECUR_LABELS } from '../types'
|
|
import {
|
|
fetchTask, updateTask, deleteTask,
|
|
addItem, updateItem, deleteItem, addComment,
|
|
tagTask, untagTask, reallocateTask, fetchProjectMembers,
|
|
} from '../api'
|
|
import { useLists } from '../context/ListsContext'
|
|
import { useCategories } from '../context/CategoriesContext'
|
|
import { useProjects } from '../context/ProjectsContext'
|
|
import { useProjectStatuses } from '../hooks/useProjectStatuses'
|
|
import { useAuth } from './AuthGate'
|
|
import { can } from '../types'
|
|
import { Check_, formatDateTime, toLocalInput, UserPicker } from './shared'
|
|
|
|
export default function TaskDetail({ taskId, onChange, onClose }:
|
|
{ taskId: number; onChange: () => void; onClose: () => void }) {
|
|
const { user } = useAuth()
|
|
const { lists } = useLists()
|
|
const { categories } = useCategories()
|
|
const { projects } = useProjects()
|
|
const [task, setTask] = useState<TDetail | null>(null)
|
|
const { statuses } = useProjectStatuses(task?.project_id ?? null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [title, setTitle] = useState('')
|
|
const [notes, setNotes] = useState('')
|
|
const [newItem, setNewItem] = useState('')
|
|
const [comment, setComment] = useState('')
|
|
const [tagEmail, setTagEmail] = useState('')
|
|
const [assignEmail, setAssignEmail] = useState('')
|
|
const [reallocateEmail, setReallocateEmail] = useState('')
|
|
const [sharingOpen, setSharingOpen] = useState(false)
|
|
// Project 'editor' members can edit a task's content/status without
|
|
// owning it (see loadEditable on the backend) — not resolvable from the
|
|
// task payload alone, so check membership once the task's project is known.
|
|
const [editorViaProject, setEditorViaProject] = useState(false)
|
|
|
|
useEffect(() => {
|
|
fetchTask(taskId).then(t => { setTask(t); setTitle(t.title); setNotes(t.notes ?? '') })
|
|
.catch(e => setError(e.message))
|
|
}, [taskId])
|
|
|
|
useEffect(() => {
|
|
if (!task?.project_id || task.owner_id === user.email) { setEditorViaProject(false); return }
|
|
fetchProjectMembers(task.project_id)
|
|
.then(members => setEditorViaProject(members.some(m => m.user_email === user.email && m.role === 'editor')))
|
|
.catch(() => setEditorViaProject(false))
|
|
}, [task?.project_id, task?.owner_id, user.email])
|
|
|
|
if (error) return (
|
|
<Shell onClose={onClose}><div className="error-banner">{error}</div></Shell>
|
|
)
|
|
if (!task) return <Shell onClose={onClose}><div className="empty-state">Loading…</div></Shell>
|
|
|
|
const isOwner = task.owner_id === user.email
|
|
// Content/status fields; list/project/assignment stay owner-only (see the
|
|
// matching 403 on the backend).
|
|
const canEdit = isOwner || editorViaProject
|
|
|
|
// Patch task fields, merge the result locally, and notify the parent view.
|
|
async function patch(fields: Record<string, unknown>) {
|
|
const updated = await updateTask(taskId, fields).catch(e => { setError(e.message); throw e })
|
|
setTask(t => t ? { ...t, ...updated } : t)
|
|
setError(null)
|
|
onChange()
|
|
}
|
|
|
|
const toLocalIso = (v: string) => v ? new Date(v).toISOString() : ''
|
|
|
|
async function toggleDone() {
|
|
const meta = statuses.find(s => s.value === task!.status)
|
|
const target = meta?.is_done ? statuses.find(s => !s.is_done) : statuses.find(s => s.is_done)
|
|
if (target) await patch({ status: target.value })
|
|
}
|
|
|
|
async function addChecklistItem() {
|
|
if (!newItem.trim()) return
|
|
const item = await addItem(taskId, newItem.trim())
|
|
setTask(t => t ? { ...t, items: [...t.items, item] } : t)
|
|
setNewItem('')
|
|
onChange()
|
|
}
|
|
async function toggleItem(id: number, is_done: boolean) {
|
|
await updateItem(id, { is_done })
|
|
setTask(t => t ? { ...t, items: t.items.map(i => i.id === id ? { ...i, is_done } : i) } : t)
|
|
}
|
|
async function removeItem(id: number) {
|
|
await deleteItem(id)
|
|
setTask(t => t ? { ...t, items: t.items.filter(i => i.id !== id) } : t)
|
|
}
|
|
async function postComment() {
|
|
if (!comment.trim()) return
|
|
const c = await addComment(taskId, comment.trim())
|
|
setTask(t => t ? { ...t, comments: [...t.comments, c] } : t)
|
|
setComment('')
|
|
}
|
|
async function removeTask() {
|
|
if (!confirm('Delete this task?')) return
|
|
await deleteTask(taskId)
|
|
onChange()
|
|
onClose()
|
|
}
|
|
async function addTag() {
|
|
if (!tagEmail.trim()) return
|
|
await tagTask(taskId, tagEmail.trim())
|
|
setTask(t => t ? { ...t, tags: [...t.tags, { user_email: tagEmail.trim(), tagged_by: user.email, created_at: new Date().toISOString() }] } : t)
|
|
setTagEmail('')
|
|
}
|
|
async function removeTag(email: string) {
|
|
await untagTask(taskId, email)
|
|
setTask(t => t ? { ...t, tags: t.tags.filter(tg => tg.user_email !== email) } : t)
|
|
}
|
|
async function assignTo() {
|
|
if (!assignEmail.trim()) return
|
|
// Once handed off, the task moves off the current owner's board entirely
|
|
// (loadVisible no longer matches them) — close rather than leave the
|
|
// drawer showing a task they can no longer reopen.
|
|
await updateTask(taskId, { assigned_to: assignEmail.trim() }).catch(e => { setError(e.message); throw e })
|
|
setAssignEmail('')
|
|
onChange()
|
|
onClose()
|
|
}
|
|
async function reallocateTo() {
|
|
if (!reallocateEmail.trim()) return
|
|
await reallocateTask(taskId, reallocateEmail.trim()).catch(e => { setError(e.message); throw e })
|
|
setReallocateEmail('')
|
|
onChange()
|
|
onClose()
|
|
}
|
|
|
|
const doneItems = task.items.filter(i => i.is_done).length
|
|
const currentStatus = statuses.find(s => s.value === task.status)
|
|
|
|
return (
|
|
<Shell onClose={onClose} onDelete={isOwner ? removeTask : undefined}>
|
|
{/* Title + completion */}
|
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', marginBottom: 14 }}>
|
|
<div style={{ marginTop: 4 }}><Check_ isDone={!!currentStatus?.is_done} onClick={toggleDone} /></div>
|
|
<textarea
|
|
value={title}
|
|
disabled={!canEdit}
|
|
onChange={e => setTitle(e.target.value)}
|
|
onBlur={() => title.trim() && title !== task.title && patch({ title: title.trim() })}
|
|
rows={1}
|
|
style={{
|
|
flex: 1, border: 'none', outline: 'none', background: 'none', resize: 'none',
|
|
fontSize: 17, fontWeight: 600, fontFamily: 'var(--font)', color: 'var(--text-dark)',
|
|
}}
|
|
/>
|
|
<button className="btn-ghost-sm"
|
|
title={task.pinned_today ? 'Unpin from Today' : 'Pin to Today'}
|
|
style={task.pinned_today ? { color: 'var(--gold)' } : undefined}
|
|
onClick={() => patch({ pinned_today: !task.pinned_today })}>
|
|
<Pin size={16} strokeWidth={1.75} fill={task.pinned_today ? 'var(--gold)' : 'none'} />
|
|
</button>
|
|
</div>
|
|
{!isOwner && (
|
|
<div className="field-hint" style={{ marginBottom: 10 }}>
|
|
{editorViaProject
|
|
? "You're a collaborator on this task's project, owned by " + task.owner_id + ". You can edit it, but can't change its list, project, or assignment."
|
|
: "You're tagged on this task, owned by " + task.owner_id + ". You can comment and check off items, but editing is owner-only."}
|
|
</div>
|
|
)}
|
|
|
|
{/* Status pills */}
|
|
<div className="chip-bar">
|
|
{statuses.map(s => (
|
|
<button key={s.value} className={`chip${task.status === s.value ? ' active' : ''}`}
|
|
disabled={!canEdit} onClick={() => canEdit && patch({ status: s.value })}>
|
|
{s.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Attributes */}
|
|
<div className="card" style={{ padding: '12px 14px' }}>
|
|
<div className="field" style={{ marginBottom: 10 }}>
|
|
<label><FolderInput size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> List</label>
|
|
<select value={task.list_id ?? ''} disabled={!isOwner}
|
|
onChange={e => patch({ list_id: e.target.value ? Number(e.target.value) : null })}>
|
|
{!task.list_id && <option value="">Unfiled</option>}
|
|
{lists.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
|
</select>
|
|
{!isOwner && canEdit && <div className="field-hint">Only the owner can move this between lists.</div>}
|
|
</div>
|
|
<div className="field-row">
|
|
<div className="field">
|
|
<label><Tag size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Category</label>
|
|
<select value={task.category_id ?? ''} disabled={!canEdit}
|
|
onChange={e => patch({ category_id: e.target.value ? Number(e.target.value) : null })}>
|
|
<option value="">None</option>
|
|
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="field">
|
|
<label><FolderKanban size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Project</label>
|
|
<select value={task.project_id ?? ''} disabled={!isOwner}
|
|
onChange={e => patch({ project_id: e.target.value ? Number(e.target.value) : null })}>
|
|
<option value="">None</option>
|
|
{projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="field-row">
|
|
<div className="field">
|
|
<label>Priority</label>
|
|
<select value={task.priority} disabled={!canEdit} onChange={e => patch({ priority: e.target.value as Priority })}>
|
|
{PRIORITIES.map(p => <option key={p} value={p}>{PRIORITY_LABELS[p]}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="field">
|
|
<label><Repeat size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Repeat</label>
|
|
<select value={task.recur_rule ?? ''} disabled={!canEdit} onChange={e => patch({ recur_rule: e.target.value || null })}>
|
|
<option value="">Doesn't repeat</option>
|
|
{RECUR_RULES.map(r => <option key={r} value={r}>{RECUR_LABELS[r as RecurRule]}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="field-row">
|
|
<div className="field">
|
|
<label><CalendarClock size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Due</label>
|
|
<input type="datetime-local" value={toLocalInput(task.due_at)} disabled={!canEdit}
|
|
onChange={e => patch({ due_at: toLocalIso(e.target.value) })} />
|
|
</div>
|
|
<div className="field">
|
|
<label><Bell size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Remind</label>
|
|
<input type="datetime-local" value={toLocalInput(task.remind_at)} disabled={!canEdit}
|
|
onChange={e => patch({ remind_at: toLocalIso(e.target.value) })} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sharing — tag (anyone with edit rights) / assign (owner + assign cap) / reallocate (dispatcher cap).
|
|
Collapsed by default: a lesser-used function that otherwise eats space on every task. */}
|
|
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
|
onClick={() => setSharingOpen(o => !o)}>
|
|
<span>Sharing</span>
|
|
<span className="muted" style={{ textTransform: 'none', fontWeight: 400, letterSpacing: 0 }}>
|
|
{task.tags.length > 0 || task.assigned_to
|
|
? [task.assigned_to && `assigned to ${task.assigned_to}`, task.tags.length > 0 && `${task.tags.length} tagged`].filter(Boolean).join(', ')
|
|
: 'nobody'}
|
|
</span>
|
|
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${sharingOpen ? ' open' : ''}`} style={{ marginLeft: 'auto' }} />
|
|
</div>
|
|
{sharingOpen && (
|
|
<div className="card" style={{ padding: '12px 14px' }}>
|
|
<div className="field" style={{ marginBottom: task.tags.length || isOwner ? 8 : 0 }}>
|
|
<label><Tag size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Tagged (view + comment)</label>
|
|
{task.tags.length > 0 && (
|
|
<div className="chip-bar" style={{ margin: '4px 0' }}>
|
|
{task.tags.map(tg => (
|
|
<span key={tg.user_email} className="chip">
|
|
{tg.user_email}
|
|
{isOwner && (
|
|
<X size={12} strokeWidth={2} style={{ cursor: 'pointer' }} onClick={() => removeTag(tg.user_email)} />
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{isOwner && (
|
|
<UserPicker value={tagEmail} onChange={setTagEmail} onSubmit={addTag} buttonLabel="Tag"
|
|
exclude={[user.email, ...task.tags.map(tg => tg.user_email)]} />
|
|
)}
|
|
</div>
|
|
|
|
{isOwner && can(user, 'assign') && (
|
|
<div className="field" style={{ marginBottom: can(user, 'reallocate') ? 8 : 0 }}>
|
|
<label><UserPlus size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Assign (hands off ownership)</label>
|
|
<UserPicker value={assignEmail} onChange={setAssignEmail} onSubmit={assignTo} buttonLabel="Assign"
|
|
exclude={[user.email]} />
|
|
{task.assigned_to && <div className="field-hint">Currently assigned to {task.assigned_to}</div>}
|
|
</div>
|
|
)}
|
|
|
|
{can(user, 'reallocate') && (
|
|
<div className="field" style={{ marginBottom: 0 }}>
|
|
<label><Send size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Reallocate (dispatcher — moves to anyone)</label>
|
|
<UserPicker value={reallocateEmail} onChange={setReallocateEmail} onSubmit={reallocateTo} buttonLabel="Move" />
|
|
</div>
|
|
)}
|
|
|
|
{!isOwner && !can(user, 'reallocate') && task.tags.length === 0 && (
|
|
<div className="muted" style={{ fontSize: 12 }}>Nobody else is tagged on this task.</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Notes */}
|
|
<div className="section-title">Notes</div>
|
|
<div className="field">
|
|
<textarea value={notes} placeholder="Add notes…" disabled={!canEdit}
|
|
onChange={e => setNotes(e.target.value)}
|
|
onBlur={() => canEdit && notes !== (task.notes ?? '') && patch({ notes })} />
|
|
</div>
|
|
|
|
{/* Checklist */}
|
|
<div className="section-title">Checklist {task.items.length > 0 && `(${doneItems}/${task.items.length})`}</div>
|
|
{task.items.length > 0 && (
|
|
<div className="checklist-progress">
|
|
<div style={{ width: `${(doneItems / task.items.length) * 100}%` }} />
|
|
</div>
|
|
)}
|
|
{task.items.map(i => (
|
|
<div key={i.id} className={`checklist-item${i.is_done ? ' done' : ''}`}>
|
|
<button className={`check${i.is_done ? ' checked' : ''}`} onClick={() => toggleItem(i.id, !i.is_done)} type="button" />
|
|
<span>{i.label}</span>
|
|
<button className="checklist-del" onClick={() => removeItem(i.id)}><X size={14} strokeWidth={1.75} /></button>
|
|
</div>
|
|
))}
|
|
<div className="task-add" style={{ marginTop: 8 }}>
|
|
<Plus size={16} strokeWidth={1.75} color="var(--text-mid)" />
|
|
<input value={newItem} placeholder="Add a sub-task"
|
|
onChange={e => setNewItem(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter') addChecklistItem() }} />
|
|
</div>
|
|
|
|
{/* Comments / activity */}
|
|
<div className="section-title">Activity</div>
|
|
<div className="timeline">
|
|
{task.comments.map(c => (
|
|
<div key={c.id} className="timeline-item">
|
|
<div className="timeline-body">
|
|
<div className="timeline-note">{c.body}</div>
|
|
<div className="timeline-meta">{c.author_name || 'You'} · {formatDateTime(c.created_at)}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{task.comments.length === 0 && <div className="muted" style={{ fontSize: 12 }}>No activity yet.</div>}
|
|
</div>
|
|
<div className="task-add" style={{ marginTop: 8 }}>
|
|
<input value={comment} placeholder="Add a note…"
|
|
onChange={e => setComment(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter') postComment() }} />
|
|
<button className="btn btn-sm btn-primary" onClick={postComment} disabled={!comment.trim()}>Post</button>
|
|
</div>
|
|
|
|
<div className="timeline-meta" style={{ marginTop: 14 }}>
|
|
Created {formatDateTime(task.created_at)}
|
|
{task.completed_at && ` · Completed ${formatDateTime(task.completed_at)}`}
|
|
</div>
|
|
</Shell>
|
|
)
|
|
}
|
|
|
|
function Shell({ children, onClose, onDelete }:
|
|
{ children: React.ReactNode; onClose: () => void; onDelete?: () => void }) {
|
|
return (
|
|
<>
|
|
<div className="drawer-overlay" onClick={onClose} />
|
|
<div className="drawer">
|
|
<div className="drawer-head">
|
|
<h2 style={{ visibility: 'hidden' }}>Task</h2>
|
|
{onDelete && (
|
|
<button className="btn-ghost-sm btn-ghost-danger" onClick={onDelete}>
|
|
<Trash2 size={15} strokeWidth={1.75} />
|
|
</button>
|
|
)}
|
|
<button className="modal-close" onClick={onClose}><X size={20} strokeWidth={1.75} /></button>
|
|
</div>
|
|
<div className="drawer-body">{children}</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|