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
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue