- 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
92 lines
4.1 KiB
TypeScript
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>
|
|
)
|
|
}
|