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
This commit is contained in:
jtricerolph 2026-09-17 08:52:08 +00:00
parent d8dc4e2b81
commit 017f6dfdc1
19 changed files with 1008 additions and 71 deletions

View file

@ -5,10 +5,16 @@ import IosInstallHint from './components/IosInstallHint'
import { useVersionCheck } from './hooks/useVersionCheck'
import Layout from './components/Layout'
import { ListsProvider } from './context/ListsContext'
import { StatusesProvider } from './context/StatusesContext'
import { CategoriesProvider } from './context/CategoriesContext'
import { ProjectsProvider } from './context/ProjectsContext'
import { TaskDrawerProvider } from './components/TaskDrawer'
import TodayView from './pages/TodayView'
import ListView from './pages/ListView'
import BoardView from './pages/BoardView'
import ProjectsView from './pages/ProjectsView'
import ProjectDetailView from './pages/ProjectDetailView'
import DispatchView from './pages/DispatchView'
import Settings from './pages/Settings'
export default function App() {
@ -18,6 +24,9 @@ export default function App() {
<BrowserRouter basename="/tasks">
<AuthGate>
<ListsProvider>
<StatusesProvider>
<CategoriesProvider>
<ProjectsProvider>
<TaskDrawerProvider>
<Layout>
<Routes>
@ -25,11 +34,17 @@ export default function App() {
<Route path="/today" element={<TodayView />} />
<Route path="/board" element={<BoardView />} />
<Route path="/list/:id" element={<ListView />} />
<Route path="/projects" element={<ProjectsView />} />
<Route path="/project/:id" element={<ProjectDetailView />} />
<Route path="/dispatch" element={<DispatchView />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/today" replace />} />
</Routes>
</Layout>
</TaskDrawerProvider>
</ProjectsProvider>
</CategoriesProvider>
</StatusesProvider>
</ListsProvider>
</AuthGate>
</BrowserRouter>

View file

@ -1,4 +1,4 @@
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig } from './types'
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail } from './types'
const BASE = '/tasks/api'
@ -35,7 +35,14 @@ export function deleteList(id: number): Promise<{ ok: boolean }> {
}
// ── Tasks ────────────────────────────────────────────────────
export function fetchTasks(params: { list_id?: number; view?: 'today'; status?: string } = {}): Promise<Task[]> {
export interface TaskFilters {
list_id?: number
view?: 'today' | 'assigned' | 'shared' | 'all'
status?: string
category_id?: number
project_id?: number
}
export function fetchTasks(params: TaskFilters = {}): Promise<Task[]> {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) if (v !== undefined) qs.set(k, String(v))
const s = qs.toString()
@ -56,6 +63,62 @@ export function deleteTask(id: number): Promise<{ ok: boolean }> {
export function reorderTasks(ids: number[]): Promise<{ ok: boolean }> {
return request('/tasks/reorder', { method: 'POST', body: JSON.stringify({ ids }) })
}
export function reallocateTask(id: number, to: string): Promise<Task> {
return request(`/tasks/${id}/reallocate`, { method: 'POST', body: JSON.stringify({ to }) })
}
// ── Tags (share/watch) ───────────────────────────────────────
export function tagTask(id: number, email: string): Promise<{ ok: boolean }> {
return request(`/tasks/${id}/tags`, { method: 'POST', body: JSON.stringify({ email }) })
}
export function untagTask(id: number, email: string): Promise<{ ok: boolean }> {
return request(`/tasks/${id}/tags/${encodeURIComponent(email)}`, { method: 'DELETE' })
}
// ── Statuses ─────────────────────────────────────────────────
export function fetchStatuses(): Promise<AppStatus[]> {
return request('/statuses')
}
export function createStatus(body: { label: string; value: string; is_hold?: boolean }): Promise<AppStatus> {
return request('/statuses', { method: 'POST', body: JSON.stringify(body) })
}
export function updateStatus(id: number, body: Partial<AppStatus>): Promise<AppStatus> {
return request(`/statuses/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteStatus(id: number): Promise<{ ok: boolean }> {
return request(`/statuses/${id}`, { method: 'DELETE' })
}
// ── Categories ───────────────────────────────────────────────
export function fetchCategories(): Promise<Category[]> {
return request('/categories')
}
export function createCategory(body: { name: string; colour?: string }): Promise<Category> {
return request('/categories', { method: 'POST', body: JSON.stringify(body) })
}
export function updateCategory(id: number, body: Partial<Category>): Promise<Category> {
return request(`/categories/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteCategory(id: number): Promise<{ ok: boolean }> {
return request(`/categories/${id}`, { method: 'DELETE' })
}
// ── Projects ─────────────────────────────────────────────────
export function fetchProjects(): Promise<Project[]> {
return request('/projects')
}
export function fetchProject(id: number): Promise<ProjectDetail> {
return request(`/projects/${id}`)
}
export function createProject(body: { name: string; description?: string; colour?: string }): Promise<Project> {
return request('/projects', { method: 'POST', body: JSON.stringify(body) })
}
export function updateProject(id: number, body: Partial<Project>): Promise<Project> {
return request(`/projects/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteProject(id: number): Promise<{ ok: boolean }> {
return request(`/projects/${id}`, { method: 'DELETE' })
}
// ── Checklist items ──────────────────────────────────────────
export function addItem(taskId: number, label: string): Promise<TaskItem> {

View file

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { ListChecks, CalendarDays, Columns3, Settings, Menu, LogOut, Plus } from 'lucide-react'
import { ListChecks, CalendarDays, Columns3, FolderKanban, Send, Settings, Menu, LogOut, Plus } from 'lucide-react'
import { useAuth } from './AuthGate'
import { useLists } from '../context/ListsContext'
import { can } from '../types'
@ -38,6 +38,14 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<NavLink to="/board" className={({ isActive }) => isActive ? 'active' : ''}>
<Columns3 {...ICON_PROPS} /> Board
</NavLink>
<NavLink to="/projects" className={({ isActive }) => isActive ? 'active' : ''}>
<FolderKanban {...ICON_PROPS} /> Projects
</NavLink>
{can(user, 'reallocate') && (
<NavLink to="/dispatch" className={({ isActive }) => isActive ? 'active' : ''}>
<Send {...ICON_PROPS} /> Dispatch
</NavLink>
)}
<div className="list-nav">
<div className="list-nav-head">

View file

@ -0,0 +1,104 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { X, Trash2 } from 'lucide-react'
import type { Project } from '../types'
import { LIST_COLOURS } from '../types'
import { createProject, updateProject, deleteProject } from '../api'
import { useProjects } from '../context/ProjectsContext'
export default function ProjectEditor({ project, onClose }: { project: Project | null; onClose: () => void }) {
const { reload } = useProjects()
const navigate = useNavigate()
const [name, setName] = useState(project?.name ?? '')
const [description, setDescription] = useState(project?.description ?? '')
const [colour, setColour] = useState(project?.colour ?? LIST_COLOURS[0])
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function save() {
if (!name.trim()) return
setBusy(true); setError(null)
try {
if (project) {
await updateProject(project.id, { name: name.trim(), description: description.trim() || null, colour })
await reload()
onClose()
} else {
const created = await createProject({ name: name.trim(), description: description.trim() || undefined, colour })
await reload()
navigate(`/project/${created.id}`)
onClose()
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed')
setBusy(false)
}
}
async function remove() {
if (!project) return
if (!confirm(`Delete "${project.name}"? Tasks inside it won't be deleted, they just lose their project.`)) return
setBusy(true)
try {
await deleteProject(project.id)
await reload()
navigate('/projects')
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Delete failed')
setBusy(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{project ? 'Edit project' : 'New project'}</h2>
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="field">
<label>Name</label>
<input type="text" value={name} autoFocus
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') save() }}
placeholder="e.g. Refurb — Rooms 1-12" />
</div>
<div className="field">
<label>Description</label>
<textarea value={description} onChange={e => setDescription(e.target.value)}
placeholder="What's this project about?" />
</div>
<div className="field">
<label>Colour</label>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{LIST_COLOURS.map(c => (
<button key={c} onClick={() => setColour(c)} title={c}
style={{
width: 26, height: 26, borderRadius: '50%', background: c, cursor: 'pointer',
border: colour === c ? '3px solid var(--text-dark)' : '3px solid transparent',
}} />
))}
</div>
</div>
<div className="modal-footer-actions">
{project && (
<button className="btn-ghost-sm btn-ghost-danger" onClick={remove} disabled={busy} style={{ marginRight: 'auto' }}>
<Trash2 size={14} strokeWidth={1.75} /> Delete
</button>
)}
<button className="btn" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={save} disabled={busy || !name.trim()}>
{project ? 'Save' : 'Create'}
</button>
</div>
</div>
</div>
)
}

View file

@ -1,23 +1,36 @@
import { useEffect, useState } from 'react'
import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput } from 'lucide-react'
import type { TaskDetail as TDetail, Status, Priority, RecurRule } from '../types'
import { PRIORITIES, PRIORITY_LABELS, STATUSES, STATUS_LABELS, RECUR_RULES, RECUR_LABELS } from '../types'
import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send } from 'lucide-react'
import type { TaskDetail as TDetail, Priority, RecurRule } from '../types'
import { PRIORITIES, PRIORITY_LABELS, RECUR_RULES, RECUR_LABELS } from '../types'
import {
fetchTask, updateTask, deleteTask,
addItem, updateItem, deleteItem, addComment,
tagTask, untagTask, reallocateTask,
} from '../api'
import { useLists } from '../context/ListsContext'
import { useCategories } from '../context/CategoriesContext'
import { useProjects } from '../context/ProjectsContext'
import { useStatuses } from '../context/StatusesContext'
import { useAuth } from './AuthGate'
import { can } from '../types'
import { Check_, formatDateTime, toLocalInput } from './shared'
export default function TaskDetail({ taskId, onChange, onClose }:
{ taskId: number; onChange: () => void; onClose: () => void }) {
const { user } = useAuth()
const { lists } = useLists()
const { categories } = useCategories()
const { projects } = useProjects()
const { statuses } = useStatuses()
const [task, setTask] = useState<TDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [notes, setNotes] = useState('')
const [newItem, setNewItem] = useState('')
const [comment, setComment] = useState('')
const [tagEmail, setTagEmail] = useState('')
const [assignEmail, setAssignEmail] = useState('')
const [reallocateEmail, setReallocateEmail] = useState('')
useEffect(() => {
fetchTask(taskId).then(t => { setTask(t); setTitle(t.title); setNotes(t.notes ?? '') })
@ -29,17 +42,22 @@ export default function TaskDetail({ taskId, onChange, onClose }:
)
if (!task) return <Shell onClose={onClose}><div className="empty-state">Loading…</div></Shell>
const isOwner = task.owner_id === user.email
// Patch task fields, merge the result locally, and notify the parent view.
async function patch(fields: Record<string, unknown>) {
const updated = await updateTask(taskId, fields)
const updated = await updateTask(taskId, fields).catch(e => { setError(e.message); throw e })
setTask(t => t ? { ...t, ...updated } : t)
setError(null)
onChange()
}
const toLocalIso = (v: string) => v ? new Date(v).toISOString() : ''
async function toggleDone() {
await patch({ status: task!.status === 'done' ? 'todo' : 'done' })
const meta = statuses.find(s => s.value === task!.status)
const target = meta?.is_done ? statuses.find(s => !s.is_done) : statuses.find(s => s.is_done)
if (target) await patch({ status: target.value })
}
async function addChecklistItem() {
@ -69,16 +87,45 @@ export default function TaskDetail({ taskId, onChange, onClose }:
onChange()
onClose()
}
async function addTag() {
if (!tagEmail.trim()) return
await tagTask(taskId, tagEmail.trim())
setTask(t => t ? { ...t, tags: [...t.tags, { user_email: tagEmail.trim(), tagged_by: user.email, created_at: new Date().toISOString() }] } : t)
setTagEmail('')
}
async function removeTag(email: string) {
await untagTask(taskId, email)
setTask(t => t ? { ...t, tags: t.tags.filter(tg => tg.user_email !== email) } : t)
}
async function assignTo() {
if (!assignEmail.trim()) return
// Once handed off, the task moves off the current owner's board entirely
// (loadVisible no longer matches them) — close rather than leave the
// drawer showing a task they can no longer reopen.
await updateTask(taskId, { assigned_to: assignEmail.trim() }).catch(e => { setError(e.message); throw e })
setAssignEmail('')
onChange()
onClose()
}
async function reallocateTo() {
if (!reallocateEmail.trim()) return
await reallocateTask(taskId, reallocateEmail.trim()).catch(e => { setError(e.message); throw e })
setReallocateEmail('')
onChange()
onClose()
}
const doneItems = task.items.filter(i => i.is_done).length
const currentStatus = statuses.find(s => s.value === task.status)
return (
<Shell onClose={onClose} onDelete={removeTask}>
<Shell onClose={onClose} onDelete={isOwner ? removeTask : undefined}>
{/* Title + completion */}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', marginBottom: 14 }}>
<div style={{ marginTop: 4 }}><Check_ status={task.status} onClick={toggleDone} /></div>
<div style={{ marginTop: 4 }}><Check_ isDone={!!currentStatus?.is_done} onClick={toggleDone} /></div>
<textarea
value={title}
disabled={!isOwner}
onChange={e => setTitle(e.target.value)}
onBlur={() => title.trim() && title !== task.title && patch({ title: title.trim() })}
rows={1}
@ -88,12 +135,18 @@ export default function TaskDetail({ taskId, onChange, onClose }:
}}
/>
</div>
{!isOwner && (
<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.
</div>
)}
{/* Status pills */}
<div className="chip-bar">
{STATUSES.map(s => (
<button key={s} className={`chip${task.status === s ? ' active' : ''}`} onClick={() => patch({ status: s })}>
{STATUS_LABELS[s]}
{statuses.map(s => (
<button key={s.value} className={`chip${task.status === s.value ? ' active' : ''}`}
disabled={!isOwner} onClick={() => isOwner && patch({ status: s.value })}>
{s.label}
</button>
))}
</div>
@ -102,20 +155,40 @@ export default function TaskDetail({ taskId, onChange, onClose }:
<div className="card" style={{ padding: '12px 14px' }}>
<div className="field" style={{ marginBottom: 10 }}>
<label><FolderInput size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> List</label>
<select value={task.list_id} onChange={e => patch({ list_id: Number(e.target.value) })}>
<select value={task.list_id ?? ''} disabled={!isOwner}
onChange={e => patch({ list_id: e.target.value ? Number(e.target.value) : null })}>
{!task.list_id && <option value="">Unfiled</option>}
{lists.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</div>
<div className="field-row">
<div className="field">
<label><Tag size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Category</label>
<select value={task.category_id ?? ''} disabled={!isOwner}
onChange={e => patch({ category_id: e.target.value ? Number(e.target.value) : null })}>
<option value="">None</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="field">
<label><FolderKanban size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Project</label>
<select value={task.project_id ?? ''} disabled={!isOwner}
onChange={e => patch({ project_id: e.target.value ? Number(e.target.value) : null })}>
<option value="">None</option>
{projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
</div>
<div className="field-row">
<div className="field">
<label>Priority</label>
<select value={task.priority} onChange={e => patch({ priority: e.target.value as Priority })}>
<select value={task.priority} disabled={!isOwner} onChange={e => patch({ priority: e.target.value as Priority })}>
{PRIORITIES.map(p => <option key={p} value={p}>{PRIORITY_LABELS[p]}</option>)}
</select>
</div>
<div className="field">
<label><Repeat size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Repeat</label>
<select value={task.recur_rule ?? ''} onChange={e => patch({ recur_rule: e.target.value || null })}>
<select value={task.recur_rule ?? ''} disabled={!isOwner} onChange={e => patch({ recur_rule: e.target.value || null })}>
<option value="">Doesn't repeat</option>
{RECUR_RULES.map(r => <option key={r} value={r}>{RECUR_LABELS[r as RecurRule]}</option>)}
</select>
@ -124,23 +197,80 @@ export default function TaskDetail({ taskId, onChange, onClose }:
<div className="field-row">
<div className="field">
<label><CalendarClock size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Due</label>
<input type="datetime-local" value={toLocalInput(task.due_at)}
<input type="datetime-local" value={toLocalInput(task.due_at)} disabled={!isOwner}
onChange={e => patch({ due_at: toLocalIso(e.target.value) })} />
</div>
<div className="field">
<label><Bell size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Remind</label>
<input type="datetime-local" value={toLocalInput(task.remind_at)}
<input type="datetime-local" value={toLocalInput(task.remind_at)} disabled={!isOwner}
onChange={e => patch({ remind_at: toLocalIso(e.target.value) })} />
</div>
</div>
</div>
{/* Sharing — tag (anyone with edit rights) / assign (owner + assign cap) / reallocate (dispatcher cap) */}
<div className="section-title">Sharing</div>
<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 && (
<div style={{ display: 'flex', gap: 6 }}>
<input type="email" value={tagEmail} placeholder="colleague@hotel.com"
onChange={e => setTagEmail(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addTag() }} />
<button className="btn btn-sm" onClick={addTag} disabled={!tagEmail.trim()}>Tag</button>
</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>
<div style={{ display: 'flex', gap: 6 }}>
<input type="email" value={assignEmail} placeholder={task.assigned_to ?? 'someone@hotel.com'}
onChange={e => setAssignEmail(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') assignTo() }} />
<button className="btn btn-sm" onClick={assignTo} disabled={!assignEmail.trim()}>Assign</button>
</div>
{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>
<div style={{ display: 'flex', gap: 6 }}>
<input type="email" value={reallocateEmail} placeholder="someone@hotel.com"
onChange={e => setReallocateEmail(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') reallocateTo() }} />
<button className="btn btn-sm" onClick={reallocateTo} disabled={!reallocateEmail.trim()}>Move</button>
</div>
</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 */}
<div className="section-title">Notes</div>
<div className="field">
<textarea value={notes} placeholder="Add notes…"
<textarea value={notes} placeholder="Add notes…" disabled={!isOwner}
onChange={e => setNotes(e.target.value)}
onBlur={() => notes !== (task.notes ?? '') && patch({ notes })} />
onBlur={() => isOwner && notes !== (task.notes ?? '') && patch({ notes })} />
</div>
{/* Checklist */}

View file

@ -21,7 +21,7 @@ export default function TaskRow({
const due = dueInfo(task.due_at)
return (
<div
className={`task-row ${task.priority === 'high' ? 'high' : ''} ${task.status === 'done' ? 'done' : ''} ${dragging ? 'dragging' : ''}`}
className={`task-row ${task.priority === 'high' ? 'high' : ''} ${task.is_done ? 'done' : ''} ${dragging ? 'dragging' : ''}`}
onClick={() => onOpen(task)}
draggable={draggable}
onDragStart={onDragStart}
@ -34,7 +34,7 @@ export default function TaskRow({
</span>
)}
<div onClick={e => { e.stopPropagation(); onToggle(task) }}>
<Check_ status={task.status} onClick={() => {}} />
<Check_ isDone={!!task.is_done} onClick={() => {}} />
</div>
<div className="task-row-main">
<div className="task-row-title">{task.title}</div>
@ -45,6 +45,12 @@ export default function TaskRow({
{task.list_name}
</span>
)}
{task.category_name && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span className="list-dot" style={{ background: task.category_colour, width: 8, height: 8 }} />
{task.category_name}
</span>
)}
{due && <span className={`due-chip${due.overdue ? ' overdue' : ''}`}>{due.label}</span>}
{task.remind_at && <Bell size={12} strokeWidth={1.75} />}
{task.recur_rule && <Repeat size={12} strokeWidth={1.75} />}

View file

@ -1,24 +1,61 @@
import { Check } from 'lucide-react'
import type { Priority, Status } from '../types'
import { PRIORITY_LABELS, STATUS_LABELS } from '../types'
import { useState } from 'react'
import { Check, ChevronDown, Tag } from 'lucide-react'
import type { Priority, Category } from '../types'
import { PRIORITY_LABELS } from '../types'
import { useCategories } from '../context/CategoriesContext'
export function PriorityBadge({ priority }: { priority: Priority }) {
if (priority === 'medium') return null
return <span className={`badge badge-prio-${priority}`}>{PRIORITY_LABELS[priority]}</span>
}
export function StatusBadge({ status }: { status: Status }) {
return <span className={`badge badge-st-${status}`}>{STATUS_LABELS[status]}</span>
// Round completion checkbox. Driven by the status's is_done flag rather than
// a fixed literal, since the pipeline is admin-editable.
export function Check_({ isDone, onClick }: { isDone: boolean; onClick: (e: React.MouseEvent) => void }) {
return (
<button className={isDone ? 'check checked' : 'check'} onClick={onClick} aria-label="toggle done" type="button">
{isDone && <Check size={13} strokeWidth={3} />}
</button>
)
}
// Round completion checkbox. 'doing' shows a partial ring, 'done' a filled tick.
export function Check_({ status, onClick }: { status: Status; onClick: (e: React.MouseEvent) => void }) {
const cls = status === 'done' ? 'check checked' : status === 'doing' ? 'check doing' : 'check'
// Collapsible multi-select category filter chip-bar — used on Board/List views.
// `selected` empty means "no filter" (show everything).
export function CategoryFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) {
const { categories } = useCategories()
const [open, setOpen] = useState(false)
if (categories.length === 0) return null
function toggle(id: number) {
onChange(selected.includes(id) ? selected.filter(i => i !== id) : [...selected, id])
}
const summary = selected.length === 0
? 'All categories'
: categories.filter(c => selected.includes(c.id)).map(c => c.name).join(', ')
return (
<button className={cls} onClick={onClick} aria-label="toggle done" type="button">
{status === 'done' && <Check size={13} strokeWidth={3} />}
{status === 'doing' && <span style={{ width: 8, height: 8, borderRadius: '50%', background: 'currentColor' }} />}
<div className="filter-section">
<div className="filter-section-header" onClick={() => setOpen(o => !o)}>
<Tag size={13} strokeWidth={1.75} />
<span className="filter-section-label">Category</span>
<span className={`filter-section-summary${selected.length === 0 ? ' muted' : ''}`}>{summary}</span>
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${open ? ' open' : ''}`} />
</div>
{open && (
<div className="chip-bar" style={{ marginTop: 6 }}>
{selected.length > 0 && (
<button className="chip" onClick={() => onChange([])}>Clear</button>
)}
{categories.map((c: Category) => (
<button key={c.id} className={`chip${selected.includes(c.id) ? ' active' : ''}`} onClick={() => toggle(c.id)}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: c.colour, display: 'inline-block' }} />
{c.name}
</button>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,32 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import type { Category } from '../types'
import { fetchCategories } from '../api'
interface CategoriesCtx {
categories: Category[]
loading: boolean
reload: () => Promise<void>
}
const Ctx = createContext<CategoriesCtx | null>(null)
export function useCategories() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useCategories must be used inside CategoriesProvider')
return ctx
}
export function CategoriesProvider({ children }: { children: React.ReactNode }) {
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const reload = useCallback(async () => {
const data = await fetchCategories().catch(() => [])
setCategories(data)
setLoading(false)
}, [])
useEffect(() => { reload() }, [reload])
return <Ctx.Provider value={{ categories, loading, reload }}>{children}</Ctx.Provider>
}

View file

@ -0,0 +1,32 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import type { Project } from '../types'
import { fetchProjects } from '../api'
interface ProjectsCtx {
projects: Project[]
loading: boolean
reload: () => Promise<void>
}
const Ctx = createContext<ProjectsCtx | null>(null)
export function useProjects() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useProjects must be used inside ProjectsProvider')
return ctx
}
export function ProjectsProvider({ children }: { children: React.ReactNode }) {
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
const reload = useCallback(async () => {
const data = await fetchProjects().catch(() => [])
setProjects(data)
setLoading(false)
}, [])
useEffect(() => { reload() }, [reload])
return <Ctx.Provider value={{ projects, loading, reload }}>{children}</Ctx.Provider>
}

View file

@ -0,0 +1,47 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import type { AppStatus } from '../types'
import { fetchStatuses } from '../api'
interface StatusesCtx {
statuses: AppStatus[]
loading: boolean
reload: () => Promise<void>
byValue: (value: string) => AppStatus | undefined
// Lowest-position done/not-done statuses — used as the toggle-done targets
// now that the pipeline is admin-editable rather than a fixed 'done'/'todo'.
firstDoneValue: () => string | undefined
firstNotDoneValue: () => string | undefined
}
const Ctx = createContext<StatusesCtx | null>(null)
export function useStatuses() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useStatuses must be used inside StatusesProvider')
return ctx
}
export function StatusesProvider({ children }: { children: React.ReactNode }) {
const [statuses, setStatuses] = useState<AppStatus[]>([])
const [loading, setLoading] = useState(true)
const reload = useCallback(async () => {
const data = await fetchStatuses().catch(() => [])
setStatuses(data)
setLoading(false)
}, [])
useEffect(() => { reload() }, [reload])
const byValue = useCallback((value: string) => statuses.find(s => s.value === value), [statuses])
// statuses is already ordered by position (see GET /api/statuses), so the
// first match in array order is the lowest-position one.
const firstDoneValue = useCallback(() => statuses.find(s => s.is_done)?.value, [statuses])
const firstNotDoneValue = useCallback(() => statuses.find(s => !s.is_done)?.value, [statuses])
return (
<Ctx.Provider value={{ statuses, loading, reload, byValue, firstDoneValue, firstNotDoneValue }}>
{children}
</Ctx.Provider>
)
}

View file

@ -183,6 +183,7 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; }
.field input[type="text"], .field input[type="email"], .field input[type="date"],
.field input[type="datetime-local"], .field input[type="time"],
.field input[type="number"], .field select, .field textarea {
width: 100%;
border: 1px solid var(--card-border);
@ -193,6 +194,15 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
color: var(--text-dark);
background: var(--card-bg);
}
/* Safari/iOS renders the calendar/clock glyph in the platform's default
near-black tint regardless of the input's own color — recolour it to match
the field's text colour so it doesn't look like an unstyled foreign control. */
.field input[type="date"]::-webkit-calendar-picker-indicator,
.field input[type="datetime-local"]::-webkit-calendar-picker-indicator,
.field input[type="time"]::-webkit-calendar-picker-indicator {
filter: invert(46%) sepia(11%) saturate(495%) hue-rotate(176deg) brightness(94%) contrast(87%);
cursor: pointer;
}
.field textarea { min-height: 72px; resize: vertical; }
.field-row { display: flex; gap: 12px; }
.field-row > .field { flex: 1; }
@ -555,6 +565,8 @@ table.data tr.clickable:hover td { background: var(--body-bg); }
.board { display: flex; gap: 12px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; }
.board-col { flex: 1; min-width: 240px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); padding: 10px; }
.board-col.drop-target { border-color: var(--gold); background: var(--warn-bg); }
.board-col-hold { background: var(--warn-bg); }
.board-col-hold.drop-target { background: var(--warn-bg); }
.board-col-head { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
.board-col-count { margin-left: auto; font-weight: 500; }

View file

@ -1,16 +1,18 @@
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 { Columns3, PauseCircle } from 'lucide-react'
import type { Task } from '../types'
import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import { PriorityBadge, dueInfo } from '../components/shared'
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<Status | null>(null)
const [over, setOver] = useState<string | null>(null)
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
const dragId = useRef<number | null>(null)
const load = useCallback(() => {
@ -18,7 +20,7 @@ export default function BoardView() {
}, [])
useEffect(() => { load() }, [load, changeVersion])
async function drop(status: Status) {
async function drop(status: string) {
const id = dragId.current
dragId.current = null
setOver(null)
@ -30,24 +32,30 @@ export default function BoardView() {
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 = tasks.filter(t => t.status === status)
{statuses.map(status => {
const items = visibleTasks.filter(t => t.status === status.value)
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 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_LABELS[status]}
{status.is_hold && <PauseCircle size={13} strokeWidth={1.75} />}
{status.label}
<span className="board-col-count">{items.length}</span>
</div>
{items.map(t => (
@ -63,6 +71,12 @@ export default function BoardView() {
{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>

View file

@ -0,0 +1,87 @@
import { useEffect, useState, useCallback } from 'react'
import { Send } from 'lucide-react'
import type { Task } from '../types'
import { fetchTasks, reallocateTask } from '../api'
import { useAuth } from '../components/AuthGate'
import { can } from '../types'
import { dueInfo, PriorityBadge } from '../components/shared'
// A flat, cross-user list for the 'reallocate' cap — deliberately not routed
// through the shared task drawer, since GET /api/tasks/:id only grants
// visibility to the owner/assignee/tagged users, not dispatchers. This view
// only needs enough per-row info to decide where a task should go.
export default function DispatchView() {
const { user } = useAuth()
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const [movingId, setMovingId] = useState<number | null>(null)
const [target, setTarget] = useState('')
const load = useCallback(() => {
fetchTasks({ view: 'all' }).then(setTasks).catch(e => setError(e.message))
}, [])
useEffect(() => { load() }, [load])
if (!can(user, 'reallocate')) {
return <div className="page"><div className="empty-state">You don't have the Reallocate capability.</div></div>
}
async function move(id: number) {
if (!target.trim()) return
await reallocateTask(id, target.trim()).catch(e => setError(e.message))
setMovingId(null)
setTarget('')
load()
}
const open = tasks.filter(t => !t.is_done)
return (
<div className="page" style={{ maxWidth: 900 }}>
<div className="page-header">
<h1><Send size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Dispatch</h1>
</div>
<div className="field-hint" style={{ marginBottom: 12 }}>
Every open task across every user — move any of them to someone else. Only shows title/owner/status; open the
full task from that person's own view for details.
</div>
{error && <div className="error-banner">{error}</div>}
{open.length === 0 && <div className="empty-state">Nothing open anywhere.</div>}
{open.map(t => {
const due = dueInfo(t.due_at)
return (
<div key={t.id} className="task-row" style={{ cursor: 'default' }}>
<div className="task-row-main">
<div className="task-row-title">{t.title}</div>
<div className="task-row-meta">
<span>{t.owner_id}</span>
<span className="badge badge-outline">{t.status_label ?? t.status}</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>
)}
{due && <span className={`due-chip${due.overdue ? ' overdue' : ''}`}>{due.label}</span>}
<PriorityBadge priority={t.priority} />
</div>
</div>
{movingId === t.id ? (
<div style={{ display: 'flex', gap: 6 }}>
<input type="email" autoFocus value={target} placeholder="reallocate to…" style={{ width: 180 }}
onChange={e => setTarget(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') move(t.id); if (e.key === 'Escape') setMovingId(null) }} />
<button className="btn btn-sm btn-primary" onClick={() => move(t.id)} disabled={!target.trim()}>Move</button>
<button className="btn btn-sm" onClick={() => setMovingId(null)}>Cancel</button>
</div>
) : (
<button className="btn btn-sm" onClick={() => { setMovingId(t.id); setTarget('') }}>Reallocate</button>
)}
</div>
)
})}
</div>
)
}

View file

@ -4,14 +4,17 @@ import { Plus, Pencil } from 'lucide-react'
import type { Task, List } from '../types'
import { fetchTasks, createTask, updateTask, reorderTasks } from '../api'
import { useLists } from '../context/ListsContext'
import { useStatuses } from '../context/StatusesContext'
import { useTaskDrawer } from '../components/TaskDrawer'
import TaskRow from '../components/TaskRow'
import ListEditor from '../components/ListEditor'
import { CategoryFilter } from '../components/shared'
export default function ListView() {
const { id } = useParams()
const listId = Number(id)
const { lists } = useLists()
const { firstDoneValue, firstNotDoneValue } = useStatuses()
const { openTask, changeVersion } = useTaskDrawer()
const list = lists.find(l => l.id === listId)
@ -19,6 +22,7 @@ export default function ListView() {
const [error, setError] = useState<string | null>(null)
const [newTitle, setNewTitle] = useState('')
const [editing, setEditing] = useState(false)
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
const dragId = useRef<number | null>(null)
const load = useCallback(() => {
@ -34,7 +38,9 @@ export default function ListView() {
load()
}
async function toggle(t: Task) {
await updateTask(t.id, { status: t.status === 'done' ? 'todo' : 'done' })
const next = t.is_done ? firstNotDoneValue() : firstDoneValue()
if (!next) return
await updateTask(t.id, { status: next })
load()
}
@ -53,8 +59,11 @@ export default function ListView() {
reorderTasks(ids).catch(() => load())
}
const open = tasks.filter(t => t.status !== 'done')
const done = tasks.filter(t => t.status === 'done')
const visible = categoryFilter.length === 0
? tasks
: tasks.filter(t => t.category_id !== null && categoryFilter.includes(t.category_id))
const open = visible.filter(t => !t.is_done)
const done = visible.filter(t => t.is_done)
return (
<div className="page">
@ -69,6 +78,7 @@ export default function ListView() {
</button>
)}
</div>
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
{error && <div className="error-banner">{error}</div>}
<div className="task-add">

View file

@ -0,0 +1,94 @@
import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { FolderKanban, Pencil } from 'lucide-react'
import type { ProjectDetail, Task } from '../types'
import { fetchProject, updateTask } from '../api'
import { useAuth } from '../components/AuthGate'
import { useStatuses } from '../context/StatusesContext'
import { useTaskDrawer } from '../components/TaskDrawer'
import TaskRow from '../components/TaskRow'
import ProjectEditor from '../components/ProjectEditor'
export default function ProjectDetailView() {
const { id } = useParams()
const projectId = Number(id)
const { user } = useAuth()
const { firstDoneValue, firstNotDoneValue } = useStatuses()
const navigate = useNavigate()
const { openTask, changeVersion } = useTaskDrawer()
const [project, setProject] = useState<ProjectDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
const load = useCallback(() => {
if (!projectId) return
fetchProject(projectId).then(setProject).catch(e => setError(e.message))
}, [projectId])
useEffect(() => { load() }, [load, changeVersion])
// 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.
async function toggle(t: Task) {
const next = t.is_done ? firstNotDoneValue() : firstDoneValue()
if (!next) return
await updateTask(t.id, { status: next }).catch(() => {})
load()
}
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>
const canManage = project.created_by === user.email || user.is_admin
const open = project.tasks.filter(t => !t.is_done)
const done = project.tasks.filter(t => t.is_done)
const pct = project.tasks.length ? Math.round((done.length / project.tasks.length) * 100) : 0
return (
<div className="page">
<div className="page-header">
<h1>
<span className="list-dot" style={{ background: project.colour, width: 12, height: 12, display: 'inline-block', marginRight: 8, verticalAlign: 0 }} />
{project.name}
</h1>
{canManage && (
<button className="btn btn-sm" onClick={() => setEditing(true)}>
<Pencil size={13} strokeWidth={1.75} /> Edit
</button>
)}
</div>
{project.description && <div className="muted" style={{ marginBottom: 12, fontSize: 13.5 }}>{project.description}</div>}
<div className="checklist-progress" style={{ marginBottom: 4 }}>
<div style={{ width: `${pct}%`, background: project.colour }} />
</div>
<div className="muted" style={{ fontSize: 12, marginBottom: 16 }}>{done.length}/{project.tasks.length} tasks done</div>
{project.tasks.length === 0 && (
<div className="empty-state">
<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.
</div>
)}
{open.map(t => (
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
))}
{done.length > 0 && (
<>
<div className="section-title">Completed ({done.length})</div>
{done.map(t => (
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
))}
</>
)}
{editing && <ProjectEditor project={project} onClose={() => { setEditing(false); load() }} />}
{!canManage && (
<div className="field-hint" style={{ marginTop: 16 }}>
<button className="btn-ghost-sm" onClick={() => navigate('/projects')} style={{ padding: 0 }}>← Back to projects</button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,51 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FolderKanban, Plus } from 'lucide-react'
import { useProjects } from '../context/ProjectsContext'
import ProjectEditor from '../components/ProjectEditor'
export default function ProjectsView() {
const { projects, loading } = useProjects()
const navigate = useNavigate()
const [editing, setEditing] = useState(false)
return (
<div className="page">
<div className="page-header">
<h1><FolderKanban size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Projects</h1>
<button className="btn btn-primary btn-sm" onClick={() => setEditing(true)}>
<Plus size={14} strokeWidth={1.75} /> New project
</button>
</div>
{!loading && projects.length === 0 && (
<div className="empty-state">No projects yet — group related tasks together with one.</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
{projects.map(p => {
const pct = p.task_count ? Math.round((p.done_count! / p.task_count) * 100) : 0
return (
<div key={p.id} className="card" style={{ cursor: 'pointer', marginBottom: 0 }} onClick={() => navigate(`/project/${p.id}`)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span className="list-dot" style={{ background: p.colour, width: 10, height: 10 }} />
<div style={{ fontWeight: 600, fontSize: 14.5 }}>{p.name}</div>
</div>
{p.description && (
<div className="muted" style={{ fontSize: 12.5, marginBottom: 8 }}>{p.description}</div>
)}
<div className="checklist-progress">
<div style={{ width: `${pct}%`, background: p.colour }} />
</div>
<div className="muted" style={{ fontSize: 11.5, marginTop: 4 }}>
{p.done_count ?? 0}/{p.task_count ?? 0} tasks done
</div>
</div>
)
})}
</div>
{editing && <ProjectEditor project={null} onClose={() => setEditing(false)} />}
</div>
)
}

View file

@ -1,8 +1,11 @@
import { useEffect, useState } from 'react'
import { Bell, BellOff } from 'lucide-react'
import { Bell, BellOff, Plus, Trash2, PauseCircle } from 'lucide-react'
import type { AppConfig } from '../types'
import { fetchConfig, updateConfig } from '../api'
import { LIST_COLOURS } from '../types'
import { fetchConfig, updateConfig, createStatus, updateStatus, deleteStatus, createCategory, updateCategory, deleteCategory } from '../api'
import { useAuth } from '../components/AuthGate'
import { useStatuses } from '../context/StatusesContext'
import { useCategories } from '../context/CategoriesContext'
import { can } from '../types'
export default function Settings() {
@ -76,6 +79,145 @@ export default function Settings() {
</div>
)}
</div>
{editable && (
<>
<StatusManager />
<CategoryManager />
</>
)}
</div>
)
}
function StatusManager() {
const { statuses, reload } = useStatuses()
const [newLabel, setNewLabel] = useState('')
const [error, setError] = useState<string | null>(null)
async function add() {
if (!newLabel.trim()) return
const value = newLabel.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
if (!value) return
try {
await createStatus({ 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 updateStatus(id, { label }).catch(e => setError(e.message))
await reload()
}
async function toggleHold(id: number, is_hold: boolean) {
await updateStatus(id, { is_hold }).catch(e => setError(e.message))
await reload()
}
async function remove(id: number) {
if (!confirm('Delete this status? Only possible if no tasks currently use it.')) return
try { await deleteStatus(id); await reload() }
catch (e) { setError(e instanceof Error ? e.message : 'Could not delete — still in use by tasks') }
}
return (
<>
<div className="section-title">Status pipeline</div>
{error && <div className="error-banner">{error}</div>}
<div className="card">
<div className="field-hint" style={{ marginBottom: 10 }}>
Shared across every task and project. "On hold" columns just get a visual flag on the board — everything
else about them behaves like a normal status. "Done" behaviour is fixed on the built-in Done status.
</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={s.is_done}
onBlur={e => e.target.value.trim() && e.target.value !== s.label && rename(s.id, e.target.value.trim())} />
{!s.is_done && (
<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>
)}
{s.is_done && <span className="badge badge-outline">Done status</span>}
<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="New status label…"
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>
</>
)
}
function CategoryManager() {
const { categories, reload } = useCategories()
const [newName, setNewName] = useState('')
const [newColour, setNewColour] = useState(LIST_COLOURS[0])
const [error, setError] = useState<string | null>(null)
async function add() {
if (!newName.trim()) return
try {
await createCategory({ name: newName.trim(), colour: newColour })
setNewName('')
await reload()
} catch (e) { setError(e instanceof Error ? e.message : 'Failed to add') }
}
async function rename(id: number, name: string) {
await updateCategory(id, { name }).catch(e => setError(e.message))
await reload()
}
async function remove(id: number) {
if (!confirm('Delete this category? Tasks using it just become uncategorised.')) return
await deleteCategory(id).catch(e => setError(e.message))
await reload()
}
return (
<>
<div className="section-title">Categories</div>
{error && <div className="error-banner">{error}</div>}
<div className="card">
<div className="field-hint" style={{ marginBottom: 10 }}>
Shared, flat labels — every user picks from the same list. One category per task.
</div>
{categories.map(c => (
<div key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span className="list-dot" style={{ background: c.colour, width: 10, height: 10, flexShrink: 0 }} />
<input type="text" defaultValue={c.name} style={{ flex: 1 }}
onBlur={e => e.target.value.trim() && e.target.value !== c.name && rename(c.id, e.target.value.trim())} />
<button className="btn-ghost-sm btn-ghost-danger" onClick={() => remove(c.id)}>
<Trash2 size={14} strokeWidth={1.75} />
</button>
</div>
))}
<div style={{ display: 'flex', gap: 6, marginTop: 8, alignItems: 'center' }}>
<input type="text" value={newName} placeholder="New category name…" style={{ flex: 1 }}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') add() }} />
<div style={{ display: 'flex', gap: 4 }}>
{LIST_COLOURS.slice(0, 5).map(c => (
<button key={c} onClick={() => setNewColour(c)} title={c}
style={{
width: 20, height: 20, borderRadius: '50%', background: c, cursor: 'pointer',
border: newColour === c ? '2px solid var(--text-dark)' : '2px solid transparent',
}} />
))}
</div>
<button className="btn btn-sm" onClick={add} disabled={!newName.trim()}>
<Plus size={14} strokeWidth={1.75} /> Add
</button>
</div>
</div>
</>
)
}

View file

@ -3,12 +3,14 @@ import { CalendarDays } from 'lucide-react'
import type { Task } from '../types'
import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import { useStatuses } from '../context/StatusesContext'
import TaskRow from '../components/TaskRow'
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
export default function TodayView() {
const { openTask, changeVersion } = useTaskDrawer()
const { firstDoneValue, firstNotDoneValue } = useStatuses()
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
@ -18,7 +20,9 @@ export default function TodayView() {
useEffect(() => { load() }, [load, changeVersion])
async function toggle(t: Task) {
await updateTask(t.id, { status: t.status === 'done' ? 'todo' : 'done' })
const next = t.is_done ? firstNotDoneValue() : firstDoneValue()
if (!next) return
await updateTask(t.id, { status: next })
load()
}

View file

@ -1,13 +1,12 @@
export type Priority = 'low' | 'medium' | 'high'
export type Status = 'todo' | 'doing' | 'done'
// Status is now an admin-editable pipeline (see AppStatus below), not a fixed
// enum — the string is whatever `statuses.value` the admin has configured.
export type Status = string
export type RecurRule = 'daily' | 'weekdays' | 'weekly' | 'monthly'
export const PRIORITIES: Priority[] = ['low', 'medium', 'high']
export const PRIORITY_LABELS: Record<Priority, string> = { low: 'Low', medium: 'Medium', high: 'High' }
export const STATUSES: Status[] = ['todo', 'doing', 'done']
export const STATUS_LABELS: Record<Status, string> = { todo: 'To do', doing: 'Doing', done: 'Done' }
export const RECUR_RULES: RecurRule[] = ['daily', 'weekdays', 'weekly', 'monthly']
export const RECUR_LABELS: Record<RecurRule, string> = {
daily: 'Every day',
@ -34,10 +33,12 @@ export interface List {
export interface Task {
id: number
list_id: number
list_id: number | null
owner_id: string
created_by: string
assigned_to: string | null
category_id: number | null
project_id: number | null
title: string
notes: string | null
status: Status
@ -53,10 +54,57 @@ export interface Task {
// joined / derived
list_name?: string
list_colour?: string
status_label?: string
is_done?: boolean
is_hold?: boolean
category_name?: string
category_colour?: string
project_name?: string
project_colour?: string
item_count?: number
item_done?: number
}
export interface AppStatus {
id: number
label: string
value: string
position: number
is_done: boolean
is_hold: boolean
}
export interface Category {
id: number
name: string
colour: string
sort_order: number
created_at: string
}
export interface Project {
id: number
created_by: string
name: string
description: string | null
colour: string
is_archived: boolean
sort_order: number
created_at: string
task_count?: number
done_count?: number
}
export interface ProjectDetail extends Project {
tasks: Task[]
}
export interface TaskTagEntry {
user_email: string
tagged_by: string
created_at: string
}
export interface TaskItem {
id: number
task_id: number
@ -77,6 +125,7 @@ export interface TaskComment {
export interface TaskDetail extends Task {
items: TaskItem[]
comments: TaskComment[]
tags: TaskTagEntry[]
}
export interface AppConfig {