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,4 +1,4 @@
|
||||||
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail, AuthUser } from './types'
|
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail, ProjectMember, ProjectRole, AuthUser } from './types'
|
||||||
|
|
||||||
const BASE = '/tasks/api'
|
const BASE = '/tasks/api'
|
||||||
|
|
||||||
|
|
@ -121,6 +121,32 @@ export function deleteProject(id: number): Promise<{ ok: boolean }> {
|
||||||
return request(`/projects/${id}`, { method: 'DELETE' })
|
return request(`/projects/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Project members (viewer/editor) ──────────────────────────
|
||||||
|
export function fetchProjectMembers(projectId: number): Promise<ProjectMember[]> {
|
||||||
|
return request(`/projects/${projectId}/members`)
|
||||||
|
}
|
||||||
|
export function addProjectMember(projectId: number, email: string, role: ProjectRole): Promise<ProjectMember> {
|
||||||
|
return request(`/projects/${projectId}/members`, { method: 'POST', body: JSON.stringify({ email, role }) })
|
||||||
|
}
|
||||||
|
export function removeProjectMember(projectId: number, email: string): Promise<{ ok: boolean }> {
|
||||||
|
return request(`/projects/${projectId}/members/${encodeURIComponent(email)}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-project status pipeline (falls back to the global one if a project
|
||||||
|
// has none of its own — see task_statuses view on the backend) ──────────
|
||||||
|
export function fetchProjectStatuses(projectId: number): Promise<AppStatus[]> {
|
||||||
|
return request(`/projects/${projectId}/statuses`)
|
||||||
|
}
|
||||||
|
export function createProjectStatus(projectId: number, body: { label: string; value: string; is_hold?: boolean }): Promise<AppStatus> {
|
||||||
|
return request(`/projects/${projectId}/statuses`, { method: 'POST', body: JSON.stringify(body) })
|
||||||
|
}
|
||||||
|
export function updateProjectStatus(projectId: number, statusId: number, body: Partial<AppStatus>): Promise<AppStatus> {
|
||||||
|
return request(`/projects/${projectId}/statuses/${statusId}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||||
|
}
|
||||||
|
export function deleteProjectStatus(projectId: number, statusId: number): Promise<{ ok: boolean }> {
|
||||||
|
return request(`/projects/${projectId}/statuses/${statusId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
// ── Checklist items ──────────────────────────────────────────
|
// ── Checklist items ──────────────────────────────────────────
|
||||||
export function addItem(taskId: number, label: string): Promise<TaskItem> {
|
export function addItem(taskId: number, label: string): Promise<TaskItem> {
|
||||||
return request(`/tasks/${taskId}/items`, { method: 'POST', body: JSON.stringify({ label }) })
|
return request(`/tasks/${taskId}/items`, { method: 'POST', body: JSON.stringify({ label }) })
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { X, Trash2 } from 'lucide-react'
|
import { X, Trash2, ChevronDown, PauseCircle, Plus } from 'lucide-react'
|
||||||
import type { Project } from '../types'
|
import type { Project, ProjectMember, ProjectRole, AppStatus } from '../types'
|
||||||
import { LIST_COLOURS } from '../types'
|
import { LIST_COLOURS } from '../types'
|
||||||
import { createProject, updateProject, deleteProject } from '../api'
|
import {
|
||||||
|
createProject, updateProject, deleteProject,
|
||||||
|
fetchProjectMembers, addProjectMember, removeProjectMember,
|
||||||
|
fetchProjectStatuses, createProjectStatus, updateProjectStatus, deleteProjectStatus,
|
||||||
|
} from '../api'
|
||||||
import { useProjects } from '../context/ProjectsContext'
|
import { useProjects } from '../context/ProjectsContext'
|
||||||
|
import { UserPicker } from './shared'
|
||||||
|
|
||||||
export default function ProjectEditor({ project, onClose }: { project: Project | null; onClose: () => void }) {
|
export default function ProjectEditor({ project, onClose }: { project: Project | null; onClose: () => void }) {
|
||||||
const { reload } = useProjects()
|
const { reload } = useProjects()
|
||||||
|
|
@ -87,6 +92,9 @@ export default function ProjectEditor({ project, onClose }: { project: Project |
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{project && <MembersManager projectId={project.id} />}
|
||||||
|
{project && <ProjectStatusManager projectId={project.id} />}
|
||||||
|
|
||||||
<div className="modal-footer-actions">
|
<div className="modal-footer-actions">
|
||||||
{project && (
|
{project && (
|
||||||
<button className="btn-ghost-sm btn-ghost-danger" onClick={remove} disabled={busy} style={{ marginRight: 'auto' }}>
|
<button className="btn-ghost-sm btn-ghost-danger" onClick={remove} disabled={busy} style={{ marginRight: 'auto' }}>
|
||||||
|
|
@ -102,3 +110,164 @@ export default function ProjectEditor({ project, onClose }: { project: Project |
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collapsed by default — membership is a lesser-used, occasional action
|
||||||
|
// compared to the name/description/colour fields above it.
|
||||||
|
function MembersManager({ projectId }: { projectId: number }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [members, setMembers] = useState<ProjectMember[]>([])
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [role, setRole] = useState<ProjectRole>('editor')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => { if (open) fetchProjectMembers(projectId).then(setMembers).catch(e => setError(e.message)) }, [open, projectId])
|
||||||
|
|
||||||
|
async function add() {
|
||||||
|
if (!email) return
|
||||||
|
try {
|
||||||
|
const m = await addProjectMember(projectId, email, role)
|
||||||
|
setMembers(ms => [...ms.filter(x => x.user_email !== m.user_email), m])
|
||||||
|
setEmail('')
|
||||||
|
} catch (e) { setError(e instanceof Error ? e.message : 'Failed to add') }
|
||||||
|
}
|
||||||
|
async function remove(userEmail: string) {
|
||||||
|
await removeProjectMember(projectId, userEmail).catch(e => setError(e.message))
|
||||||
|
setMembers(ms => ms.filter(m => m.user_email !== userEmail))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||||
|
onClick={() => setOpen(o => !o)}>
|
||||||
|
<span>Members {members.length > 0 && `(${members.length})`}</span>
|
||||||
|
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${open ? ' open' : ''}`} />
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="card" style={{ padding: '12px 14px' }}>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
<div className="field-hint" style={{ marginBottom: 10 }}>
|
||||||
|
Viewers can see this project and everything in it. Editors can also edit any task inside it — not just
|
||||||
|
their own — but still can't move a task out of the project or reassign it; that stays with the task's owner.
|
||||||
|
</div>
|
||||||
|
{members.length > 0 && (
|
||||||
|
<div className="chip-bar" style={{ marginBottom: 8 }}>
|
||||||
|
{members.map(m => (
|
||||||
|
<span key={m.user_email} className="chip">
|
||||||
|
{m.user_email} · {m.role}
|
||||||
|
<X size={12} strokeWidth={2} style={{ cursor: 'pointer' }} onClick={() => remove(m.user_email)} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<select value={role} onChange={e => setRole(e.target.value as ProjectRole)} style={{ flexShrink: 0, width: 90 }}>
|
||||||
|
<option value="editor">Editor</option>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
</select>
|
||||||
|
<UserPicker value={email} onChange={setEmail} onSubmit={add} buttonLabel="Add"
|
||||||
|
exclude={members.map(m => m.user_email)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsed by default — most projects never need their own pipeline and
|
||||||
|
// just use the shared global one (see the task_statuses view on the backend).
|
||||||
|
function ProjectStatusManager({ projectId }: { projectId: number }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [statuses, setStatuses] = useState<AppStatus[]>([])
|
||||||
|
const [isCustom, setIsCustom] = useState(false)
|
||||||
|
const [newLabel, setNewLabel] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
const data = await fetchProjectStatuses(projectId)
|
||||||
|
setStatuses(data)
|
||||||
|
// The endpoint returns global statuses verbatim (no project_id field) as
|
||||||
|
// a fallback when the project has none of its own — that's the cheapest
|
||||||
|
// way to tell "this project has its own pipeline" from "borrowing the
|
||||||
|
// shared one" without a second request.
|
||||||
|
setIsCustom(data.length > 0 && 'project_id' in data[0])
|
||||||
|
}
|
||||||
|
useEffect(() => { if (open) reload().catch(e => setError(e.message)) }, [open, projectId])
|
||||||
|
|
||||||
|
async function add() {
|
||||||
|
if (!newLabel.trim()) return
|
||||||
|
const value = newLabel.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||||
|
if (!value) return
|
||||||
|
try {
|
||||||
|
await createProjectStatus(projectId, { label: newLabel.trim(), value })
|
||||||
|
setNewLabel('')
|
||||||
|
await reload()
|
||||||
|
} catch (e) { setError(e instanceof Error ? e.message : 'Failed to add') }
|
||||||
|
}
|
||||||
|
async function rename(id: number, label: string) {
|
||||||
|
await updateProjectStatus(projectId, id, { label }).catch(e => setError(e.message))
|
||||||
|
await reload()
|
||||||
|
}
|
||||||
|
async function toggleHold(id: number, is_hold: boolean) {
|
||||||
|
await updateProjectStatus(projectId, id, { is_hold }).catch(e => setError(e.message))
|
||||||
|
await reload()
|
||||||
|
}
|
||||||
|
async function toggleDone(id: number, is_done: boolean) {
|
||||||
|
await updateProjectStatus(projectId, id, { is_done }).catch(e => setError(e.message))
|
||||||
|
await reload()
|
||||||
|
}
|
||||||
|
async function remove(id: number) {
|
||||||
|
if (!confirm('Delete this status? Only possible if no tasks in this project currently use it.')) return
|
||||||
|
try { await deleteProjectStatus(projectId, id); await reload() }
|
||||||
|
catch (e) { setError(e instanceof Error ? e.message : 'Could not delete — still in use by tasks') }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||||
|
onClick={() => setOpen(o => !o)}>
|
||||||
|
<span>Status pipeline</span>
|
||||||
|
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${open ? ' open' : ''}`} />
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="card" style={{ padding: '12px 14px' }}>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
<div className="field-hint" style={{ marginBottom: 10 }}>
|
||||||
|
{isCustom
|
||||||
|
? "This project has its own pipeline, separate from the shared one — tasks here only use these statuses."
|
||||||
|
: 'Currently using the shared pipeline (Settings → Status pipeline). Add a status below to give this project its own.'}
|
||||||
|
</div>
|
||||||
|
{statuses.map(s => (
|
||||||
|
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<input type="text" defaultValue={s.label} style={{ flex: 1 }}
|
||||||
|
disabled={!isCustom}
|
||||||
|
onBlur={e => isCustom && e.target.value.trim() && e.target.value !== s.label && rename(s.id, e.target.value.trim())} />
|
||||||
|
{isCustom && (
|
||||||
|
<>
|
||||||
|
<label className="field-check" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
|
||||||
|
<input type="checkbox" checked={s.is_done} onChange={e => toggleDone(s.id, e.target.checked)} />
|
||||||
|
Done
|
||||||
|
</label>
|
||||||
|
<label className="field-check" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
|
||||||
|
<input type="checkbox" checked={s.is_hold} onChange={e => toggleHold(s.id, e.target.checked)} />
|
||||||
|
<PauseCircle size={13} strokeWidth={1.75} /> Hold
|
||||||
|
</label>
|
||||||
|
<button className="btn-ghost-sm btn-ghost-danger" onClick={() => remove(s.id)}>
|
||||||
|
<Trash2 size={14} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||||
|
<input type="text" value={newLabel} placeholder={isCustom ? 'New status label…' : 'Add a status to start this project\'s own pipeline…'}
|
||||||
|
onChange={e => setNewLabel(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') add() }} />
|
||||||
|
<button className="btn btn-sm" onClick={add} disabled={!newLabel.trim()}>
|
||||||
|
<Plus size={14} strokeWidth={1.75} /> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
97
frontend/src/components/TaskBoard.tsx
Normal file
97
frontend/src/components/TaskBoard.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { PauseCircle } from 'lucide-react'
|
||||||
|
import type { Task, AppStatus } from '../types'
|
||||||
|
import { PriorityBadge, dueInfo } from './shared'
|
||||||
|
|
||||||
|
// Kanban columns over a given task list + status pipeline — used by the
|
||||||
|
// global Board page, and as a per-list/per-project view-mode toggle (see
|
||||||
|
// ListView/ProjectDetailView). Parameterized on `statuses` so callers can
|
||||||
|
// pass either the app-wide pipeline or a project's own (useProjectStatuses).
|
||||||
|
export default function TaskBoard({ tasks, statuses, onOpen, onDrop, showList = true }: {
|
||||||
|
tasks: Task[]
|
||||||
|
statuses: AppStatus[]
|
||||||
|
onOpen: (id: number) => void
|
||||||
|
onDrop: (id: number, status: string) => void
|
||||||
|
showList?: boolean
|
||||||
|
}) {
|
||||||
|
const [over, setOver] = useState<string | null>(null)
|
||||||
|
// Done columns collapse by default — otherwise the board fills up with
|
||||||
|
// finished cards the longer a list/project stays in use. Anything a user
|
||||||
|
// has explicitly expanded this session stays expanded.
|
||||||
|
const [openDone, setOpenDone] = useState<Set<string>>(new Set())
|
||||||
|
const dragId = useRef<number | null>(null)
|
||||||
|
|
||||||
|
function drop(status: string) {
|
||||||
|
const id = dragId.current
|
||||||
|
dragId.current = null
|
||||||
|
setOver(null)
|
||||||
|
if (id !== null) onDrop(id, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDoneColumn(value: string) {
|
||||||
|
setOpenDone(s => {
|
||||||
|
const next = new Set(s)
|
||||||
|
if (next.has(value)) next.delete(value); else next.add(value)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="board">
|
||||||
|
{statuses.map(status => {
|
||||||
|
const items = tasks.filter(t => t.status === status.value)
|
||||||
|
const collapsed = status.is_done && !openDone.has(status.value)
|
||||||
|
return (
|
||||||
|
<div key={status.value}
|
||||||
|
className={`board-col${over === status.value ? ' drop-target' : ''}${status.is_hold ? ' board-col-hold' : ''}`}
|
||||||
|
onDragOver={e => { e.preventDefault(); setOver(status.value) }}
|
||||||
|
onDragLeave={() => setOver(o => o === status.value ? null : o)}
|
||||||
|
onDrop={() => drop(status.value)}>
|
||||||
|
<div className="board-col-head" style={status.is_done ? { cursor: 'pointer' } : undefined}
|
||||||
|
onClick={() => status.is_done && toggleDoneColumn(status.value)}>
|
||||||
|
{status.is_hold && <PauseCircle size={13} strokeWidth={1.75} />}
|
||||||
|
{status.label}
|
||||||
|
<span className="board-col-count">{items.length}</span>
|
||||||
|
</div>
|
||||||
|
{collapsed ? (
|
||||||
|
items.length > 0 && (
|
||||||
|
<div className="muted" style={{ fontSize: 12, padding: '4px 2px', cursor: 'pointer' }}
|
||||||
|
onClick={() => toggleDoneColumn(status.value)}>
|
||||||
|
Show {items.length} done
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{items.map(t => (
|
||||||
|
<div key={t.id} className="card" style={{ cursor: 'grab', padding: '10px 12px' }}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => { dragId.current = t.id }}
|
||||||
|
onClick={() => onOpen(t.id)}>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 500, marginBottom: 4 }}>{t.title}</div>
|
||||||
|
<div className="task-row-meta">
|
||||||
|
{showList && t.list_name && (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span className="list-dot" style={{ background: t.list_colour, width: 8, height: 8 }} />
|
||||||
|
{t.list_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{t.category_name && (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span className="list-dot" style={{ background: t.category_colour, width: 8, height: 8 }} />
|
||||||
|
{t.category_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(() => { const d = dueInfo(t.due_at); return d && <span className={`due-chip${d.overdue ? ' overdue' : ''}`}>{d.label}</span> })()}
|
||||||
|
<PriorityBadge priority={t.priority} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && <div className="muted" style={{ fontSize: 12, padding: '4px 2px' }}>—</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import { useEffect, useState } from 'react'
|
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 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 {
|
||||||
fetchTask, updateTask, deleteTask,
|
fetchTask, updateTask, deleteTask,
|
||||||
addItem, updateItem, deleteItem, addComment,
|
addItem, updateItem, deleteItem, addComment,
|
||||||
tagTask, untagTask, reallocateTask,
|
tagTask, untagTask, reallocateTask, fetchProjectMembers,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { useLists } from '../context/ListsContext'
|
import { useLists } from '../context/ListsContext'
|
||||||
import { useCategories } from '../context/CategoriesContext'
|
import { useCategories } from '../context/CategoriesContext'
|
||||||
import { useProjects } from '../context/ProjectsContext'
|
import { useProjects } from '../context/ProjectsContext'
|
||||||
import { useStatuses } from '../context/StatusesContext'
|
import { useProjectStatuses } from '../hooks/useProjectStatuses'
|
||||||
import { useAuth } from './AuthGate'
|
import { useAuth } from './AuthGate'
|
||||||
import { can } from '../types'
|
import { can } from '../types'
|
||||||
import { Check_, formatDateTime, toLocalInput, UserPicker } from './shared'
|
import { Check_, formatDateTime, toLocalInput, UserPicker } from './shared'
|
||||||
|
|
@ -21,8 +21,8 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
||||||
const { lists } = useLists()
|
const { lists } = useLists()
|
||||||
const { categories } = useCategories()
|
const { categories } = useCategories()
|
||||||
const { projects } = useProjects()
|
const { projects } = useProjects()
|
||||||
const { statuses } = useStatuses()
|
|
||||||
const [task, setTask] = useState<TDetail | null>(null)
|
const [task, setTask] = useState<TDetail | null>(null)
|
||||||
|
const { statuses } = useProjectStatuses(task?.project_id ?? null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [notes, setNotes] = useState('')
|
const [notes, setNotes] = useState('')
|
||||||
|
|
@ -32,18 +32,32 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
||||||
const [assignEmail, setAssignEmail] = useState('')
|
const [assignEmail, setAssignEmail] = useState('')
|
||||||
const [reallocateEmail, setReallocateEmail] = useState('')
|
const [reallocateEmail, setReallocateEmail] = useState('')
|
||||||
const [sharingOpen, setSharingOpen] = useState(false)
|
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(() => {
|
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 ?? '') })
|
||||||
.catch(e => setError(e.message))
|
.catch(e => setError(e.message))
|
||||||
}, [taskId])
|
}, [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 (
|
if (error) return (
|
||||||
<Shell onClose={onClose}><div className="error-banner">{error}</div></Shell>
|
<Shell onClose={onClose}><div className="error-banner">{error}</div></Shell>
|
||||||
)
|
)
|
||||||
if (!task) return <Shell onClose={onClose}><div className="empty-state">Loading…</div></Shell>
|
if (!task) return <Shell onClose={onClose}><div className="empty-state">Loading…</div></Shell>
|
||||||
|
|
||||||
const isOwner = task.owner_id === user.email
|
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.
|
// Patch task fields, merge the result locally, and notify the parent view.
|
||||||
async function patch(fields: Record<string, unknown>) {
|
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>
|
<div style={{ marginTop: 4 }}><Check_ isDone={!!currentStatus?.is_done} onClick={toggleDone} /></div>
|
||||||
<textarea
|
<textarea
|
||||||
value={title}
|
value={title}
|
||||||
disabled={!isOwner}
|
disabled={!canEdit}
|
||||||
onChange={e => setTitle(e.target.value)}
|
onChange={e => setTitle(e.target.value)}
|
||||||
onBlur={() => title.trim() && title !== task.title && patch({ title: title.trim() })}
|
onBlur={() => title.trim() && title !== task.title && patch({ title: title.trim() })}
|
||||||
rows={1}
|
rows={1}
|
||||||
|
|
@ -135,10 +149,18 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
||||||
fontSize: 17, fontWeight: 600, fontFamily: 'var(--font)', color: 'var(--text-dark)',
|
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>
|
</div>
|
||||||
{!isOwner && (
|
{!isOwner && (
|
||||||
<div className="field-hint" style={{ marginBottom: 10 }}>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -146,7 +168,7 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
||||||
<div className="chip-bar">
|
<div className="chip-bar">
|
||||||
{statuses.map(s => (
|
{statuses.map(s => (
|
||||||
<button key={s.value} className={`chip${task.status === s.value ? ' active' : ''}`}
|
<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}
|
{s.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -161,11 +183,12 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
||||||
{!task.list_id && <option value="">Unfiled</option>}
|
{!task.list_id && <option value="">Unfiled</option>}
|
||||||
{lists.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
{lists.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
{!isOwner && canEdit && <div className="field-hint">Only the owner can move this between lists.</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label><Tag size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Category</label>
|
<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 })}>
|
onChange={e => patch({ category_id: e.target.value ? Number(e.target.value) : null })}>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</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-row">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Priority</label>
|
<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>)}
|
{PRIORITIES.map(p => <option key={p} value={p}>{PRIORITY_LABELS[p]}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label><Repeat size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Repeat</label>
|
<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>
|
<option value="">Doesn't repeat</option>
|
||||||
{RECUR_RULES.map(r => <option key={r} value={r}>{RECUR_LABELS[r as RecurRule]}</option>)}
|
{RECUR_RULES.map(r => <option key={r} value={r}>{RECUR_LABELS[r as RecurRule]}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -198,12 +221,12 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label><CalendarClock size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Due</label>
|
<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) })} />
|
onChange={e => patch({ due_at: toLocalIso(e.target.value) })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label><Bell size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Remind</label>
|
<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) })} />
|
onChange={e => patch({ remind_at: toLocalIso(e.target.value) })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -268,9 +291,9 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div className="section-title">Notes</div>
|
<div className="section-title">Notes</div>
|
||||||
<div className="field">
|
<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)}
|
onChange={e => setNotes(e.target.value)}
|
||||||
onBlur={() => isOwner && notes !== (task.notes ?? '') && patch({ notes })} />
|
onBlur={() => canEdit && notes !== (task.notes ?? '') && patch({ notes })} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Checklist */}
|
{/* Checklist */}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { GripVertical, Bell, Repeat, ListChecks } from 'lucide-react'
|
import { GripVertical, Bell, Repeat, ListChecks, Pin } from 'lucide-react'
|
||||||
import type { Task } from '../types'
|
import type { Task } from '../types'
|
||||||
import { Check_, PriorityBadge, dueInfo } from './shared'
|
import { Check_, PriorityBadge, dueInfo } from './shared'
|
||||||
|
|
||||||
|
|
@ -52,6 +52,7 @@ export default function TaskRow({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{due && <span className={`due-chip${due.overdue ? ' overdue' : ''}`}>{due.label}</span>}
|
{due && <span className={`due-chip${due.overdue ? ' overdue' : ''}`}>{due.label}</span>}
|
||||||
|
{task.pinned_today && <Pin size={12} strokeWidth={1.75} fill="var(--gold)" color="var(--gold)" />}
|
||||||
{task.remind_at && <Bell size={12} strokeWidth={1.75} />}
|
{task.remind_at && <Bell size={12} strokeWidth={1.75} />}
|
||||||
{task.recur_rule && <Repeat size={12} strokeWidth={1.75} />}
|
{task.recur_rule && <Repeat size={12} strokeWidth={1.75} />}
|
||||||
{!!task.item_count && (
|
{!!task.item_count && (
|
||||||
|
|
|
||||||
32
frontend/src/hooks/useProjectStatuses.ts
Normal file
32
frontend/src/hooks/useProjectStatuses.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import type { AppStatus } from '../types'
|
||||||
|
import { fetchProjectStatuses } from '../api'
|
||||||
|
import { useStatuses } from '../context/StatusesContext'
|
||||||
|
|
||||||
|
// A project's own pipeline if it has defined any custom statuses, else the
|
||||||
|
// shared global one (same resolution the backend applies) — for anything
|
||||||
|
// that needs to render a specific project's board/status pills rather than
|
||||||
|
// the app-wide one from StatusesContext. Pass null for "no project" to just
|
||||||
|
// mirror the global context.
|
||||||
|
export function useProjectStatuses(projectId: number | null) {
|
||||||
|
const global = useStatuses()
|
||||||
|
const [projectStatuses, setProjectStatuses] = useState<AppStatus[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(!!projectId)
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
if (!projectId) { setProjectStatuses(null); return }
|
||||||
|
setLoading(true)
|
||||||
|
const data = await fetchProjectStatuses(projectId).catch(() => [])
|
||||||
|
setProjectStatuses(data)
|
||||||
|
setLoading(false)
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
|
const statuses = projectId ? (projectStatuses ?? []) : global.statuses
|
||||||
|
const byValue = (value: string) => statuses.find(s => s.value === value)
|
||||||
|
const firstDoneValue = () => statuses.find(s => s.is_done)?.value
|
||||||
|
const firstNotDoneValue = () => statuses.find(s => !s.is_done)?.value
|
||||||
|
|
||||||
|
return { statuses, loading: projectId ? loading : global.loading, reload, byValue, firstDoneValue, firstNotDoneValue }
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { Columns3, PauseCircle } from 'lucide-react'
|
import { Columns3 } from 'lucide-react'
|
||||||
import type { Task } from '../types'
|
import type { Task } from '../types'
|
||||||
import { fetchTasks, updateTask } from '../api'
|
import { fetchTasks, updateTask } from '../api'
|
||||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||||
import { useStatuses } from '../context/StatusesContext'
|
import { useStatuses } from '../context/StatusesContext'
|
||||||
import { useSearch } from '../context/SearchContext'
|
import { useSearch } from '../context/SearchContext'
|
||||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||||
import { PriorityBadge, dueInfo, TaskFilterBar } from '../components/shared'
|
import { TaskFilterBar } from '../components/shared'
|
||||||
|
import TaskBoard from '../components/TaskBoard'
|
||||||
|
|
||||||
export default function BoardView() {
|
export default function BoardView() {
|
||||||
const { openTask, changeVersion } = useTaskDrawer()
|
const { openTask, changeVersion } = useTaskDrawer()
|
||||||
|
|
@ -15,19 +16,13 @@ export default function BoardView() {
|
||||||
const { matches } = useTaskFilters()
|
const { matches } = useTaskFilters()
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [over, setOver] = useState<string | null>(null)
|
|
||||||
const dragId = useRef<number | null>(null)
|
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
fetchTasks({ search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
|
fetchTasks({ search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
|
||||||
}, [debouncedQuery])
|
}, [debouncedQuery])
|
||||||
useEffect(() => { load() }, [load, changeVersion])
|
useEffect(() => { load() }, [load, changeVersion])
|
||||||
|
|
||||||
async function drop(status: string) {
|
async function drop(id: number, status: string) {
|
||||||
const id = dragId.current
|
|
||||||
dragId.current = null
|
|
||||||
setOver(null)
|
|
||||||
if (id === null) return
|
|
||||||
const task = tasks.find(t => t.id === id)
|
const task = tasks.find(t => t.id === id)
|
||||||
if (!task || task.status === status) return
|
if (!task || task.status === status) return
|
||||||
setTasks(ts => ts.map(t => t.id === id ? { ...t, status } : t))
|
setTasks(ts => ts.map(t => t.id === id ? { ...t, status } : t))
|
||||||
|
|
@ -45,49 +40,7 @@ export default function BoardView() {
|
||||||
<TaskFilterBar />
|
<TaskFilterBar />
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
<div className="board">
|
<TaskBoard tasks={visibleTasks} statuses={statuses} onOpen={openTask} onDrop={drop} />
|
||||||
{statuses.map(status => {
|
|
||||||
const items = visibleTasks.filter(t => t.status === status.value)
|
|
||||||
return (
|
|
||||||
<div key={status.value}
|
|
||||||
className={`board-col${over === status.value ? ' drop-target' : ''}${status.is_hold ? ' board-col-hold' : ''}`}
|
|
||||||
onDragOver={e => { e.preventDefault(); setOver(status.value) }}
|
|
||||||
onDragLeave={() => setOver(o => o === status.value ? null : o)}
|
|
||||||
onDrop={() => drop(status.value)}>
|
|
||||||
<div className="board-col-head">
|
|
||||||
{status.is_hold && <PauseCircle size={13} strokeWidth={1.75} />}
|
|
||||||
{status.label}
|
|
||||||
<span className="board-col-count">{items.length}</span>
|
|
||||||
</div>
|
|
||||||
{items.map(t => (
|
|
||||||
<div key={t.id} className="card" style={{ cursor: 'grab', padding: '10px 12px' }}
|
|
||||||
draggable
|
|
||||||
onDragStart={() => { dragId.current = t.id }}
|
|
||||||
onClick={() => openTask(t.id)}>
|
|
||||||
<div style={{ fontSize: 13.5, fontWeight: 500, marginBottom: 4 }}>{t.title}</div>
|
|
||||||
<div className="task-row-meta">
|
|
||||||
{t.list_name && (
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
<span className="list-dot" style={{ background: t.list_colour, width: 8, height: 8 }} />
|
|
||||||
{t.list_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{t.category_name && (
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
<span className="list-dot" style={{ background: t.category_colour, width: 8, height: 8 }} />
|
|
||||||
{t.category_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(() => { const d = dueInfo(t.due_at); return d && <span className={`due-chip${d.overdue ? ' overdue' : ''}`}>{d.label}</span> })()}
|
|
||||||
<PriorityBadge priority={t.priority} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{items.length === 0 && <div className="muted" style={{ fontSize: 12, padding: '4px 2px' }}>—</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { Plus, Pencil } from 'lucide-react'
|
import { Plus, Pencil, List as ListIcon, Columns3, ChevronDown } from 'lucide-react'
|
||||||
import type { Task, List } from '../types'
|
import type { Task, List } from '../types'
|
||||||
import { fetchTasks, createTask, updateTask, reorderTasks } from '../api'
|
import { fetchTasks, createTask, updateTask, reorderTasks } from '../api'
|
||||||
import { useLists } from '../context/ListsContext'
|
import { useLists } from '../context/ListsContext'
|
||||||
|
|
@ -9,6 +9,7 @@ import { useSearch } from '../context/SearchContext'
|
||||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||||
import TaskRow from '../components/TaskRow'
|
import TaskRow from '../components/TaskRow'
|
||||||
|
import TaskBoard from '../components/TaskBoard'
|
||||||
import ListEditor from '../components/ListEditor'
|
import ListEditor from '../components/ListEditor'
|
||||||
import { TaskFilterBar } from '../components/shared'
|
import { TaskFilterBar } from '../components/shared'
|
||||||
|
|
||||||
|
|
@ -16,7 +17,7 @@ export default function ListView() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const listId = Number(id)
|
const listId = Number(id)
|
||||||
const { lists } = useLists()
|
const { lists } = useLists()
|
||||||
const { firstDoneValue, firstNotDoneValue } = useStatuses()
|
const { statuses, firstDoneValue, firstNotDoneValue } = useStatuses()
|
||||||
const { debouncedQuery } = useSearch()
|
const { debouncedQuery } = useSearch()
|
||||||
// List filter is irrelevant here — the route itself already scopes to one
|
// List filter is irrelevant here — the route itself already scopes to one
|
||||||
// list — so only category/project matter for this view.
|
// list — so only category/project matter for this view.
|
||||||
|
|
@ -28,6 +29,8 @@ export default function ListView() {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [newTitle, setNewTitle] = useState('')
|
const [newTitle, setNewTitle] = useState('')
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [mode, setMode] = useState<'list' | 'board'>('list')
|
||||||
|
const [showDone, setShowDone] = useState(false)
|
||||||
const dragId = useRef<number | null>(null)
|
const dragId = useRef<number | null>(null)
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
|
|
@ -49,7 +52,7 @@ export default function ListView() {
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native drag reorder within the open list.
|
// Native drag reorder within the open list (list mode only).
|
||||||
function onDragStart(taskId: number) { dragId.current = taskId }
|
function onDragStart(taskId: number) { dragId.current = taskId }
|
||||||
function onDrop(targetId: number) {
|
function onDrop(targetId: number) {
|
||||||
const from = dragId.current
|
const from = dragId.current
|
||||||
|
|
@ -64,6 +67,14 @@ export default function ListView() {
|
||||||
reorderTasks(ids).catch(() => load())
|
reorderTasks(ids).catch(() => load())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dropOnStatus(taskId: number, status: string) {
|
||||||
|
const task = tasks.find(t => t.id === taskId)
|
||||||
|
if (!task || task.status === status) return
|
||||||
|
setTasks(ts => ts.map(t => t.id === taskId ? { ...t, status } : t))
|
||||||
|
await updateTask(taskId, { status }).catch(() => load())
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
const visible = tasks.filter(t => categoryMatches(t) && projectMatches(t))
|
const visible = tasks.filter(t => categoryMatches(t) && projectMatches(t))
|
||||||
const open = visible.filter(t => !t.is_done)
|
const open = visible.filter(t => !t.is_done)
|
||||||
const done = visible.filter(t => t.is_done)
|
const done = visible.filter(t => t.is_done)
|
||||||
|
|
@ -75,12 +86,22 @@ export default function ListView() {
|
||||||
{list && <span className="list-dot" style={{ background: list.colour, width: 12, height: 12, display: 'inline-block', marginRight: 8, verticalAlign: 0 }} />}
|
{list && <span className="list-dot" style={{ background: list.colour, width: 12, height: 12, display: 'inline-block', marginRight: 8, verticalAlign: 0 }} />}
|
||||||
{list?.name ?? 'List'}
|
{list?.name ?? 'List'}
|
||||||
</h1>
|
</h1>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<div className="chip-bar" style={{ margin: 0 }}>
|
||||||
|
<button className={`chip${mode === 'list' ? ' active' : ''}`} onClick={() => setMode('list')}>
|
||||||
|
<ListIcon size={13} strokeWidth={1.75} style={{ verticalAlign: -2, marginRight: 4 }} />List
|
||||||
|
</button>
|
||||||
|
<button className={`chip${mode === 'board' ? ' active' : ''}`} onClick={() => setMode('board')}>
|
||||||
|
<Columns3 size={13} strokeWidth={1.75} style={{ verticalAlign: -2, marginRight: 4 }} />Board
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{list && (
|
{list && (
|
||||||
<button className="btn btn-sm" onClick={() => setEditing(true)}>
|
<button className="btn btn-sm" onClick={() => setEditing(true)}>
|
||||||
<Pencil size={13} strokeWidth={1.75} /> Edit
|
<Pencil size={13} strokeWidth={1.75} /> Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<TaskFilterBar showList={false} />
|
<TaskFilterBar showList={false} />
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
|
@ -91,6 +112,14 @@ export default function ListView() {
|
||||||
onKeyDown={e => { if (e.key === 'Enter') add() }} />
|
onKeyDown={e => { if (e.key === 'Enter') add() }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{open.length === 0 && done.length === 0 && (
|
||||||
|
<div className="empty-state">No tasks yet — add one above.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'board' ? (
|
||||||
|
<TaskBoard tasks={visible} statuses={statuses} onOpen={openTask} onDrop={dropOnStatus} showList={false} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{open.map(t => (
|
{open.map(t => (
|
||||||
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)}
|
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)}
|
||||||
draggable dragging={dragId.current === t.id}
|
draggable dragging={dragId.current === t.id}
|
||||||
|
|
@ -99,18 +128,20 @@ export default function ListView() {
|
||||||
onDrop={() => onDrop(t.id)} />
|
onDrop={() => onDrop(t.id)} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{open.length === 0 && done.length === 0 && (
|
|
||||||
<div className="empty-state">No tasks yet — add one above.</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{done.length > 0 && (
|
{done.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="section-title">Completed ({done.length})</div>
|
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||||
{done.map(t => (
|
onClick={() => setShowDone(o => !o)}>
|
||||||
|
<span>Completed ({done.length})</span>
|
||||||
|
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${showDone ? ' open' : ''}`} />
|
||||||
|
</div>
|
||||||
|
{showDone && done.map(t => (
|
||||||
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)} />
|
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{editing && list && <ListEditor list={list} onClose={() => setEditing(false)} />}
|
{editing && list && <ListEditor list={list} onClose={() => setEditing(false)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,28 @@
|
||||||
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, Plus } from 'lucide-react'
|
import { FolderKanban, Pencil, Plus, List as ListIcon, Columns3, ChevronDown } from 'lucide-react'
|
||||||
import type { ProjectDetail, Task } from '../types'
|
import type { ProjectDetail, Task } from '../types'
|
||||||
import { fetchProject, updateTask, createTask } from '../api'
|
import { fetchProject, updateTask, createTask } from '../api'
|
||||||
import { useAuth } from '../components/AuthGate'
|
import { useAuth } from '../components/AuthGate'
|
||||||
import { useStatuses } from '../context/StatusesContext'
|
import { useProjectStatuses } from '../hooks/useProjectStatuses'
|
||||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||||
import TaskRow from '../components/TaskRow'
|
import TaskRow from '../components/TaskRow'
|
||||||
|
import TaskBoard from '../components/TaskBoard'
|
||||||
import ProjectEditor from '../components/ProjectEditor'
|
import ProjectEditor from '../components/ProjectEditor'
|
||||||
|
|
||||||
export default function ProjectDetailView() {
|
export default function ProjectDetailView() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const projectId = Number(id)
|
const projectId = Number(id)
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { firstDoneValue, firstNotDoneValue } = useStatuses()
|
const { statuses, firstDoneValue, firstNotDoneValue } = useProjectStatuses(projectId)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { openTask, changeVersion } = useTaskDrawer()
|
const { openTask, changeVersion } = useTaskDrawer()
|
||||||
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 [newTitle, setNewTitle] = useState('')
|
||||||
|
const [mode, setMode] = useState<'list' | 'board'>('list')
|
||||||
|
const [showDone, setShowDone] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
if (!projectId) return
|
if (!projectId) return
|
||||||
|
|
@ -36,8 +39,9 @@ export default function ProjectDetailView() {
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only meaningful for tasks the viewer owns — the backend 404s a PATCH on
|
// Works for any task the viewer owns, or any task in the project if
|
||||||
// someone else's task, which is expected for a shared project view.
|
// they're an 'editor' member — the backend 404s anything else (e.g. a
|
||||||
|
// 'viewer' member, who can see but not touch other people's tasks).
|
||||||
async function toggle(t: Task) {
|
async function toggle(t: Task) {
|
||||||
const next = t.is_done ? firstNotDoneValue() : firstDoneValue()
|
const next = t.is_done ? firstNotDoneValue() : firstDoneValue()
|
||||||
if (!next) return
|
if (!next) return
|
||||||
|
|
@ -45,6 +49,11 @@ export default function ProjectDetailView() {
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dropOnStatus(taskId: number, status: string) {
|
||||||
|
await updateTask(taskId, { status }).catch(() => {})
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
if (error) return <div className="page"><div className="error-banner">{error}</div></div>
|
if (error) return <div className="page"><div className="error-banner">{error}</div></div>
|
||||||
if (!project) return <div className="page"><div className="empty-state">Loading…</div></div>
|
if (!project) return <div className="page"><div className="empty-state">Loading…</div></div>
|
||||||
|
|
||||||
|
|
@ -60,12 +69,22 @@ export default function ProjectDetailView() {
|
||||||
<span className="list-dot" style={{ background: project.colour, width: 12, height: 12, display: 'inline-block', marginRight: 8, verticalAlign: 0 }} />
|
<span className="list-dot" style={{ background: project.colour, width: 12, height: 12, display: 'inline-block', marginRight: 8, verticalAlign: 0 }} />
|
||||||
{project.name}
|
{project.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<div className="chip-bar" style={{ margin: 0 }}>
|
||||||
|
<button className={`chip${mode === 'list' ? ' active' : ''}`} onClick={() => setMode('list')}>
|
||||||
|
<ListIcon size={13} strokeWidth={1.75} style={{ verticalAlign: -2, marginRight: 4 }} />List
|
||||||
|
</button>
|
||||||
|
<button className={`chip${mode === 'board' ? ' active' : ''}`} onClick={() => setMode('board')}>
|
||||||
|
<Columns3 size={13} strokeWidth={1.75} style={{ verticalAlign: -2, marginRight: 4 }} />Board
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button className="btn btn-sm" onClick={() => setEditing(true)}>
|
<button className="btn btn-sm" onClick={() => setEditing(true)}>
|
||||||
<Pencil size={13} strokeWidth={1.75} /> Edit
|
<Pencil size={13} strokeWidth={1.75} /> Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{project.description && <div className="muted" style={{ marginBottom: 12, fontSize: 13.5 }}>{project.description}</div>}
|
{project.description && <div className="muted" style={{ marginBottom: 12, fontSize: 13.5 }}>{project.description}</div>}
|
||||||
|
|
||||||
<div className="checklist-progress" style={{ marginBottom: 4 }}>
|
<div className="checklist-progress" style={{ marginBottom: 4 }}>
|
||||||
|
|
@ -87,18 +106,28 @@ export default function ProjectDetailView() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === 'board' ? (
|
||||||
|
<TaskBoard tasks={project.tasks} statuses={statuses} onOpen={openTask} onDrop={dropOnStatus} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{open.map(t => (
|
{open.map(t => (
|
||||||
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
|
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{done.length > 0 && (
|
{done.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="section-title">Completed ({done.length})</div>
|
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||||
{done.map(t => (
|
onClick={() => setShowDone(o => !o)}>
|
||||||
|
<span>Completed ({done.length})</span>
|
||||||
|
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${showDone ? ' open' : ''}`} />
|
||||||
|
</div>
|
||||||
|
{showDone && done.map(t => (
|
||||||
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
|
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{editing && <ProjectEditor project={project} onClose={() => { setEditing(false); load() }} />}
|
{editing && <ProjectEditor project={project} onClose={() => { setEditing(false); load() }} />}
|
||||||
{!canManage && (
|
{!canManage && (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { CalendarDays } from 'lucide-react'
|
import { CalendarDays, ChevronDown } from 'lucide-react'
|
||||||
import type { Task } from '../types'
|
import type { Task } from '../types'
|
||||||
import { fetchTasks, updateTask } from '../api'
|
import { fetchTasks, updateTask } from '../api'
|
||||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||||
|
|
@ -18,6 +18,9 @@ export default function TodayView() {
|
||||||
const { matches } = useTaskFilters()
|
const { matches } = useTaskFilters()
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// "Other" (no due date, not pinned) is collapsed by default — otherwise
|
||||||
|
// it's the whole backlog dumped into what's meant to be a short agenda.
|
||||||
|
const [showOther, setShowOther] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
fetchTasks({ view: 'today', search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
|
fetchTasks({ view: 'today', search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
|
||||||
|
|
@ -36,10 +39,22 @@ export default function TodayView() {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const todayStart = startOfDay(now)
|
const todayStart = startOfDay(now)
|
||||||
const tomorrowStart = new Date(todayStart); tomorrowStart.setDate(tomorrowStart.getDate() + 1)
|
const tomorrowStart = new Date(todayStart); tomorrowStart.setDate(tomorrowStart.getDate() + 1)
|
||||||
|
const weekEnd = new Date(todayStart); weekEnd.setDate(weekEnd.getDate() + 7)
|
||||||
|
|
||||||
const overdue = visible.filter(t => t.due_at && new Date(t.due_at) < todayStart)
|
const overdue = visible.filter(t => t.due_at && new Date(t.due_at) < todayStart)
|
||||||
const today = visible.filter(t => t.due_at && new Date(t.due_at) >= todayStart && new Date(t.due_at) < tomorrowStart)
|
const overdueIds = new Set(overdue.map(t => t.id))
|
||||||
const upcoming = visible.filter(t => t.due_at && new Date(t.due_at) >= tomorrowStart)
|
// Pinned tasks land in Today regardless of due date (unless they're
|
||||||
|
// overdue, which is more urgent and stays in that group instead).
|
||||||
|
const today = visible.filter(t => !overdueIds.has(t.id) && (
|
||||||
|
t.pinned_today || (t.due_at && new Date(t.due_at) >= todayStart && new Date(t.due_at) < tomorrowStart)
|
||||||
|
))
|
||||||
|
const todayIds = new Set(today.map(t => t.id))
|
||||||
|
const upcoming = visible.filter(t => !overdueIds.has(t.id) && !todayIds.has(t.id) &&
|
||||||
|
t.due_at && new Date(t.due_at) >= tomorrowStart && new Date(t.due_at) < weekEnd)
|
||||||
|
const upcomingIds = new Set(upcoming.map(t => t.id))
|
||||||
|
// Everything else — no due date (or due further out) and not pinned. This
|
||||||
|
// exists so an empty agenda reads as "nothing due", not "nothing to do".
|
||||||
|
const other = visible.filter(t => !overdueIds.has(t.id) && !todayIds.has(t.id) && !upcomingIds.has(t.id))
|
||||||
|
|
||||||
const groups: Array<{ key: string; title: string; items: Task[]; overdue?: boolean }> = [
|
const groups: Array<{ key: string; title: string; items: Task[]; overdue?: boolean }> = [
|
||||||
{ key: 'overdue', title: 'Overdue', items: overdue, overdue: true },
|
{ key: 'overdue', title: 'Overdue', items: overdue, overdue: true },
|
||||||
|
|
@ -56,7 +71,7 @@ export default function TodayView() {
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
{visible.length === 0 && (
|
{visible.length === 0 && (
|
||||||
<div className="empty-state">Nothing due in the next 7 days. Enjoy the calm. ☕</div>
|
<div className="empty-state">Nothing on your plate at all. Enjoy the calm. ☕</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groups.map(g => g.items.length > 0 && (
|
{groups.map(g => g.items.length > 0 && (
|
||||||
|
|
@ -69,6 +84,23 @@ export default function TodayView() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{other.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="agenda-group-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||||
|
onClick={() => setShowOther(o => !o)}>
|
||||||
|
<span>Other open tasks <span className="muted" style={{ fontWeight: 400 }}>· {other.length}</span></span>
|
||||||
|
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${showOther ? ' open' : ''}`} />
|
||||||
|
</div>
|
||||||
|
{showOther && other.map(t => (
|
||||||
|
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visible.length > 0 && overdue.length === 0 && today.length === 0 && upcoming.length === 0 && !showOther && (
|
||||||
|
<div className="empty-state">Nothing due in the next 7 days — {other.length} other open task{other.length === 1 ? '' : 's'} above, if you want a look.</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export interface Task {
|
||||||
reminded_at: string | null
|
reminded_at: string | null
|
||||||
recur_rule: RecurRule | null
|
recur_rule: RecurRule | null
|
||||||
completed_at: string | null
|
completed_at: string | null
|
||||||
|
pinned_today: boolean
|
||||||
sort_order: number
|
sort_order: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|
@ -99,6 +100,15 @@ export interface ProjectDetail extends Project {
|
||||||
tasks: Task[]
|
tasks: Task[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProjectRole = 'viewer' | 'editor'
|
||||||
|
|
||||||
|
export interface ProjectMember {
|
||||||
|
user_email: string
|
||||||
|
role: ProjectRole
|
||||||
|
added_by: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskTagEntry {
|
export interface TaskTagEntry {
|
||||||
user_email: string
|
user_email: string
|
||||||
tagged_by: string
|
tagged_by: string
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue