tasks/frontend/src/pages/BoardView.tsx
jtricerolph 017f6dfdc1 Frontend: dynamic status pipeline, categories/projects, tag+assign+reallocate UI
- Board/List/Today views now render the admin-editable status pipeline
  instead of a hardcoded todo/doing/done enum (StatusesContext)
- Category filter chip-bar on Board and List views (ported the idea from
  reviewing the FD's own build, simplified to match our flat single-category
  model rather than his tree)
- New Projects list + detail views, project create/edit modal
- New Dispatch view for the 'reallocate' cap -- flat cross-user task list
  with an inline move action (deliberately not routed through the shared
  task drawer, since GET /api/tasks/:id only grants visibility to
  owner/assignee/tagged users, not dispatchers)
- Task drawer: dynamic status pills, category/project selects, tag
  picker (share/watch, no cap needed), assign field (owner + assign cap),
  reallocate field (reallocate cap) -- all fields disabled for non-owners
  since the backend's single PATCH endpoint is owner-gated
- Settings page: status pipeline + category management (settings cap)
- Fixed missing input[type="datetime-local"]/[type="time"] styling -- the
  Due/Remind fields were rendering with unstyled browser defaults
2026-09-17 08:52:08 +00:00

92 lines
4.1 KiB
TypeScript

import { useEffect, useState, useCallback, useRef } from 'react'
import { Columns3, PauseCircle } from 'lucide-react'
import type { Task } from '../types'
import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import { useStatuses } from '../context/StatusesContext'
import { PriorityBadge, dueInfo, CategoryFilter } from '../components/shared'
export default function BoardView() {
const { openTask, changeVersion } = useTaskDrawer()
const { statuses } = useStatuses()
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const [over, setOver] = useState<string | null>(null)
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
const dragId = useRef<number | null>(null)
const load = useCallback(() => {
fetchTasks({}).then(setTasks).catch(e => setError(e.message))
}, [])
useEffect(() => { load() }, [load, changeVersion])
async function drop(status: string) {
const id = dragId.current
dragId.current = null
setOver(null)
if (id === null) return
const task = tasks.find(t => t.id === id)
if (!task || task.status === status) return
setTasks(ts => ts.map(t => t.id === id ? { ...t, status } : t))
await updateTask(id, { status }).catch(() => load())
load()
}
const visibleTasks = categoryFilter.length === 0
? tasks
: tasks.filter(t => t.category_id !== null && categoryFilter.includes(t.category_id))
return (
<div className="page" style={{ maxWidth: 1200 }}>
<div className="page-header">
<h1><Columns3 size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Board</h1>
</div>
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
{error && <div className="error-banner">{error}</div>}
<div className="board">
{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>
)
}