Initial commit: personal task planner with categories, projects, and tiered assignment

Personal-first lists/tasks/reminders/recurrence, plus an admin-editable status
pipeline, shared categories, project containers, and a three-tier permission
model for cross-user work: assign (hand off your own tasks), reallocate
(dispatcher — move anyone's tasks), admin (reserved, full manage-all).
This commit is contained in:
jtricerolph 2026-09-17 08:07:00 +00:00
commit d8dc4e2b81
54 changed files with 10908 additions and 0 deletions

View file

@ -0,0 +1,78 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { Columns3 } from 'lucide-react'
import type { Task, Status } from '../types'
import { STATUSES, STATUS_LABELS } from '../types'
import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import { PriorityBadge, dueInfo } from '../components/shared'
export default function BoardView() {
const { openTask, changeVersion } = useTaskDrawer()
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const [over, setOver] = useState<Status | null>(null)
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: Status) {
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()
}
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>
{error && <div className="error-banner">{error}</div>}
<div className="board">
{STATUSES.map(status => {
const items = tasks.filter(t => t.status === status)
return (
<div key={status}
className={`board-col${over === status ? ' drop-target' : ''}`}
onDragOver={e => { e.preventDefault(); setOver(status) }}
onDragLeave={() => setOver(o => o === status ? null : o)}
onDrop={() => drop(status)}>
<div className="board-col-head">
{STATUS_LABELS[status]}
<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>
)}
{(() => { 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>
)
}