Collapse Sharing by default; allow creating tasks directly in a project

Sharing (tag/assign/reallocate) is now a collapsed-by-default section in
the task drawer with a one-line summary, since it's a lesser-used function
that was eating space on every task.

list_id is now optional on task creation -- the task lands unfiled (same
state assign/reallocate already use), scoped only by project_id. Projects
are a cross-cutting grouping, not a list replacement, and the only way to
add a task to a project was previously to create it elsewhere and set the
Project field from the drawer. Added a "+ New task" input directly on
ProjectDetailView. Verified against a throwaway Postgres: unfiled creation,
distinct sort_order per unfiled task, project detail lists them, and
existing validation (bad list_id, missing title) still rejects.
This commit is contained in:
jtricerolph 2026-10-12 14:10:24 +00:00
parent 6172617df3
commit 4d19b37b8a
3 changed files with 90 additions and 52 deletions

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send } from 'lucide-react'
import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send, ChevronDown } from 'lucide-react'
import type { TaskDetail as TDetail, Priority, RecurRule } from '../types'
import { PRIORITIES, PRIORITY_LABELS, RECUR_RULES, RECUR_LABELS } from '../types'
import {
@ -31,6 +31,7 @@ export default function TaskDetail({ taskId, onChange, onClose }:
const [tagEmail, setTagEmail] = useState('')
const [assignEmail, setAssignEmail] = useState('')
const [reallocateEmail, setReallocateEmail] = useState('')
const [sharingOpen, setSharingOpen] = useState(false)
useEffect(() => {
fetchTask(taskId).then(t => { setTask(t); setTitle(t.title); setNotes(t.notes ?? '') })
@ -208,49 +209,61 @@ export default function TaskDetail({ taskId, onChange, onClose }:
</div>
</div>
{/* Sharing — tag (anyone with edit rights) / assign (owner + assign cap) / reallocate (dispatcher cap) */}
<div className="section-title">Sharing</div>
<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>
))}
{/* 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>
)}
{isOwner && (
<UserPicker value={tagEmail} onChange={setTagEmail} onSubmit={addTag} buttonLabel="Tag"
exclude={[user.email, ...task.tags.map(tg => tg.user_email)]} />
{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>
{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>