Frontend: project members/statuses UI, pin-to-today, List/Board toggle
- 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.
This commit is contained in:
parent
2a75c7060e
commit
e1c537d3f4
11 changed files with 517 additions and 114 deletions
|
|
@ -1,16 +1,16 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send, ChevronDown } from 'lucide-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,
|
||||
tagTask, untagTask, reallocateTask, fetchProjectMembers,
|
||||
} from '../api'
|
||||
import { useLists } from '../context/ListsContext'
|
||||
import { useCategories } from '../context/CategoriesContext'
|
||||
import { useProjects } from '../context/ProjectsContext'
|
||||
import { useStatuses } from '../context/StatusesContext'
|
||||
import { useProjectStatuses } from '../hooks/useProjectStatuses'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { can } from '../types'
|
||||
import { Check_, formatDateTime, toLocalInput, UserPicker } from './shared'
|
||||
|
|
@ -21,8 +21,8 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
const { lists } = useLists()
|
||||
const { categories } = useCategories()
|
||||
const { projects } = useProjects()
|
||||
const { statuses } = useStatuses()
|
||||
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('')
|
||||
|
|
@ -32,18 +32,32 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
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>) {
|
||||
|
|
@ -126,7 +140,7 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
<div style={{ marginTop: 4 }}><Check_ isDone={!!currentStatus?.is_done} onClick={toggleDone} /></div>
|
||||
<textarea
|
||||
value={title}
|
||||
disabled={!isOwner}
|
||||
disabled={!canEdit}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
onBlur={() => title.trim() && title !== task.title && patch({ title: title.trim() })}
|
||||
rows={1}
|
||||
|
|
@ -135,10 +149,18 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
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 }}>
|
||||
You're tagged on this task, owned by {task.owner_id}. You can comment and check off items, but editing is owner-only.
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
|
@ -146,7 +168,7 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
<div className="chip-bar">
|
||||
{statuses.map(s => (
|
||||
<button key={s.value} className={`chip${task.status === s.value ? ' active' : ''}`}
|
||||
disabled={!isOwner} onClick={() => isOwner && patch({ status: s.value })}>
|
||||
disabled={!canEdit} onClick={() => canEdit && patch({ status: s.value })}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -161,11 +183,12 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
{!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={!isOwner}
|
||||
<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>)}
|
||||
|
|
@ -183,13 +206,13 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Priority</label>
|
||||
<select value={task.priority} disabled={!isOwner} onChange={e => patch({ priority: e.target.value as Priority })}>
|
||||
<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={!isOwner} onChange={e => patch({ recur_rule: e.target.value || null })}>
|
||||
<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>
|
||||
|
|
@ -198,12 +221,12 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
<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={!isOwner}
|
||||
<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={!isOwner}
|
||||
<input type="datetime-local" value={toLocalInput(task.remind_at)} disabled={!canEdit}
|
||||
onChange={e => patch({ remind_at: toLocalIso(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -268,9 +291,9 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
{/* Notes */}
|
||||
<div className="section-title">Notes</div>
|
||||
<div className="field">
|
||||
<textarea value={notes} placeholder="Add notes…" disabled={!isOwner}
|
||||
<textarea value={notes} placeholder="Add notes…" disabled={!canEdit}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
onBlur={() => isOwner && notes !== (task.notes ?? '') && patch({ notes })} />
|
||||
onBlur={() => canEdit && notes !== (task.notes ?? '') && patch({ notes })} />
|
||||
</div>
|
||||
|
||||
{/* Checklist */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue