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:
parent
6172617df3
commit
4d19b37b8a
3 changed files with 90 additions and 52 deletions
|
|
@ -149,12 +149,18 @@ export async function taskRoutes(app) {
|
||||||
app.post('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => {
|
app.post('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => {
|
||||||
const { list_id, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id } = req.body || {}
|
const { list_id, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id } = req.body || {}
|
||||||
if (!title?.trim()) return reply.status(400).send({ error: 'title required' })
|
if (!title?.trim()) return reply.status(400).send({ error: 'title required' })
|
||||||
// Verify the target list belongs to the caller.
|
// list_id is optional — omitting it lands the task "unfiled" (list_id
|
||||||
const { rows: [list] } = await pool.query(
|
// NULL), the same state assign/reallocate already use. Projects need this
|
||||||
'SELECT id FROM lists WHERE id = $1 AND owner_id = $2',
|
// so a task can be created straight into a project without first needing
|
||||||
[list_id, req.user.email]
|
// a personal list, since projects are a cross-cutting grouping, not a
|
||||||
)
|
// list replacement.
|
||||||
if (!list) return reply.status(400).send({ error: 'invalid list' })
|
if (list_id) {
|
||||||
|
const { rows: [list] } = await pool.query(
|
||||||
|
'SELECT id FROM lists WHERE id = $1 AND owner_id = $2',
|
||||||
|
[list_id, req.user.email]
|
||||||
|
)
|
||||||
|
if (!list) return reply.status(400).send({ error: 'invalid list' })
|
||||||
|
}
|
||||||
if (priority && !PRIORITIES.includes(priority)) return reply.status(400).send({ error: 'bad priority' })
|
if (priority && !PRIORITIES.includes(priority)) return reply.status(400).send({ error: 'bad priority' })
|
||||||
if (recur_rule && !RECUR_RULES.includes(recur_rule)) return reply.status(400).send({ error: 'bad recur_rule' })
|
if (recur_rule && !RECUR_RULES.includes(recur_rule)) return reply.status(400).send({ error: 'bad recur_rule' })
|
||||||
if (category_id) {
|
if (category_id) {
|
||||||
|
|
@ -166,14 +172,16 @@ export async function taskRoutes(app) {
|
||||||
if (!p) return reply.status(400).send({ error: 'invalid project' })
|
if (!p) return reply.status(400).send({ error: 'invalid project' })
|
||||||
}
|
}
|
||||||
const { rows: [max] } = await pool.query(
|
const { rows: [max] } = await pool.query(
|
||||||
'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id = $1',
|
list_id
|
||||||
[list_id]
|
? 'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id = $1'
|
||||||
|
: 'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id IS NULL',
|
||||||
|
list_id ? [list_id] : []
|
||||||
)
|
)
|
||||||
const { rows: [task] } = await pool.query(
|
const { rows: [task] } = await pool.query(
|
||||||
`INSERT INTO tasks (list_id, owner_id, created_by, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id, sort_order)
|
`INSERT INTO tasks (list_id, owner_id, created_by, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id, sort_order)
|
||||||
VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[list_id, req.user.email, title.trim(), notes || null, priority || null,
|
[list_id || null, req.user.email, title.trim(), notes || null, priority || null,
|
||||||
due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null, Number(max.m) + 1]
|
due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null, Number(max.m) + 1]
|
||||||
)
|
)
|
||||||
return task
|
return task
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from 'react'
|
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 type { TaskDetail as TDetail, Priority, RecurRule } from '../types'
|
||||||
import { PRIORITIES, PRIORITY_LABELS, RECUR_RULES, RECUR_LABELS } from '../types'
|
import { PRIORITIES, PRIORITY_LABELS, RECUR_RULES, RECUR_LABELS } from '../types'
|
||||||
import {
|
import {
|
||||||
|
|
@ -31,6 +31,7 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
||||||
const [tagEmail, setTagEmail] = useState('')
|
const [tagEmail, setTagEmail] = useState('')
|
||||||
const [assignEmail, setAssignEmail] = useState('')
|
const [assignEmail, setAssignEmail] = useState('')
|
||||||
const [reallocateEmail, setReallocateEmail] = useState('')
|
const [reallocateEmail, setReallocateEmail] = useState('')
|
||||||
|
const [sharingOpen, setSharingOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTask(taskId).then(t => { setTask(t); setTitle(t.title); setNotes(t.notes ?? '') })
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sharing — tag (anyone with edit rights) / assign (owner + assign cap) / reallocate (dispatcher cap) */}
|
{/* Sharing — tag (anyone with edit rights) / assign (owner + assign cap) / reallocate (dispatcher cap).
|
||||||
<div className="section-title">Sharing</div>
|
Collapsed by default: a lesser-used function that otherwise eats space on every task. */}
|
||||||
<div className="card" style={{ padding: '12px 14px' }}>
|
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||||
<div className="field" style={{ marginBottom: task.tags.length || isOwner ? 8 : 0 }}>
|
onClick={() => setSharingOpen(o => !o)}>
|
||||||
<label><Tag size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Tagged (view + comment)</label>
|
<span>Sharing</span>
|
||||||
{task.tags.length > 0 && (
|
<span className="muted" style={{ textTransform: 'none', fontWeight: 400, letterSpacing: 0 }}>
|
||||||
<div className="chip-bar" style={{ margin: '4px 0' }}>
|
{task.tags.length > 0 || task.assigned_to
|
||||||
{task.tags.map(tg => (
|
? [task.assigned_to && `assigned to ${task.assigned_to}`, task.tags.length > 0 && `${task.tags.length} tagged`].filter(Boolean).join(', ')
|
||||||
<span key={tg.user_email} className="chip">
|
: 'nobody'}
|
||||||
{tg.user_email}
|
</span>
|
||||||
{isOwner && (
|
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${sharingOpen ? ' open' : ''}`} style={{ marginLeft: 'auto' }} />
|
||||||
<X size={12} strokeWidth={2} style={{ cursor: 'pointer' }} onClick={() => removeTag(tg.user_email)} />
|
</div>
|
||||||
)}
|
{sharingOpen && (
|
||||||
</span>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isOwner && (
|
|
||||||
<UserPicker value={tagEmail} onChange={setTagEmail} onSubmit={addTag} buttonLabel="Tag"
|
{can(user, 'reallocate') && (
|
||||||
exclude={[user.email, ...task.tags.map(tg => tg.user_email)]} />
|
<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>
|
</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 */}
|
{/* Notes */}
|
||||||
<div className="section-title">Notes</div>
|
<div className="section-title">Notes</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { FolderKanban, Pencil } from 'lucide-react'
|
import { FolderKanban, Pencil, Plus } from 'lucide-react'
|
||||||
import type { ProjectDetail, Task } from '../types'
|
import type { ProjectDetail, Task } from '../types'
|
||||||
import { fetchProject, updateTask } from '../api'
|
import { fetchProject, updateTask, createTask } from '../api'
|
||||||
import { useAuth } from '../components/AuthGate'
|
import { useAuth } from '../components/AuthGate'
|
||||||
import { useStatuses } from '../context/StatusesContext'
|
import { useStatuses } from '../context/StatusesContext'
|
||||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||||
|
|
@ -19,6 +19,7 @@ export default function ProjectDetailView() {
|
||||||
const [project, setProject] = useState<ProjectDetail | null>(null)
|
const [project, setProject] = useState<ProjectDetail | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [newTitle, setNewTitle] = useState('')
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
if (!projectId) return
|
if (!projectId) return
|
||||||
|
|
@ -26,6 +27,15 @@ export default function ProjectDetailView() {
|
||||||
}, [projectId])
|
}, [projectId])
|
||||||
useEffect(() => { load() }, [load, changeVersion])
|
useEffect(() => { load() }, [load, changeVersion])
|
||||||
|
|
||||||
|
// No list_id — the task lands unfiled (same state assign/reallocate already
|
||||||
|
// use), scoped into the project by project_id alone.
|
||||||
|
async function add() {
|
||||||
|
if (!newTitle.trim()) return
|
||||||
|
await createTask({ project_id: projectId, title: newTitle.trim() })
|
||||||
|
setNewTitle('')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
// Only meaningful for tasks the viewer owns — the backend 404s a PATCH on
|
// Only meaningful for tasks the viewer owns — the backend 404s a PATCH on
|
||||||
// someone else's task, which is expected for a shared project view.
|
// someone else's task, which is expected for a shared project view.
|
||||||
async function toggle(t: Task) {
|
async function toggle(t: Task) {
|
||||||
|
|
@ -63,10 +73,17 @@ export default function ProjectDetailView() {
|
||||||
</div>
|
</div>
|
||||||
<div className="muted" style={{ fontSize: 12, marginBottom: 16 }}>{done.length}/{project.tasks.length} tasks done</div>
|
<div className="muted" style={{ fontSize: 12, marginBottom: 16 }}>{done.length}/{project.tasks.length} tasks done</div>
|
||||||
|
|
||||||
|
<div className="task-add">
|
||||||
|
<Plus size={18} strokeWidth={1.75} color="var(--text-mid)" />
|
||||||
|
<input value={newTitle} placeholder="Add a task to this project…"
|
||||||
|
onChange={e => setNewTitle(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') add() }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{project.tasks.length === 0 && (
|
{project.tasks.length === 0 && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<FolderKanban size={28} strokeWidth={1.5} style={{ marginBottom: 8, opacity: .4 }} /><br />
|
<FolderKanban size={28} strokeWidth={1.5} style={{ marginBottom: 8, opacity: .4 }} /><br />
|
||||||
No tasks in this project yet — set a task's Project field from its detail view.
|
No tasks in this project yet — add one above, or set a task's Project field from its detail view.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue