- 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
104 lines
3.8 KiB
TypeScript
104 lines
3.8 KiB
TypeScript
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>
|
|
)
|
|
}
|