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:
parent
d8dc4e2b81
commit
017f6dfdc1
19 changed files with 1008 additions and 71 deletions
104
frontend/src/components/ProjectEditor.tsx
Normal file
104
frontend/src/components/ProjectEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue