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

@ -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>
)
}