Add edit and delete to task modal

Edit button (update cap): inline form for title, description, priority, unusable flag, due date. Delete button (admin only): hard delete with confirm step, cascades photos/events and cleans files from disk. Backend adds DELETE /api/tasks/:id route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-22 10:17:57 +00:00
parent 03d1cd2bb6
commit ca006f9aea
3 changed files with 118 additions and 5 deletions

View file

@ -4,6 +4,11 @@ import { createTask, logEvent, TRANSITIONS, PRIORITIES, STATUSES } from '../lib/
import { fetchBookings } from '../lib/newbook.js' import { fetchBookings } from '../lib/newbook.js'
import { notifyAssignment } from '../lib/mailer.js' import { notifyAssignment } from '../lib/mailer.js'
import { notifyAssignmentPush } from '../lib/push.js' import { notifyAssignmentPush } from '../lib/push.js'
import { unlink } from 'fs/promises'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const UPLOADS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'uploads')
const PRIORITY_ORDER = `CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END` const PRIORITY_ORDER = `CASE t.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END`
@ -222,6 +227,18 @@ export async function taskRoutes(app) {
return rows[0] return rows[0]
}) })
// DELETE /api/tasks/:id — hard delete; photo files removed from disk; events/photos cascade
app.delete('/api/tasks/:id', { preHandler: requireCap('update') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT id FROM tasks WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Task not found' })
const taskId = existing[0].id
const { rows: photos } = await pool.query('SELECT file_path FROM task_photos WHERE task_id = $1', [taskId])
await pool.query('DELETE FROM tasks WHERE id = $1', [taskId])
for (const p of photos) await unlink(join(UPLOADS_DIR, p.file_path)).catch(() => {})
return { ok: true }
})
// POST /api/tasks/:id/resolve — temporary_fix or fixed, with completed-by and cost // POST /api/tasks/:id/resolve — temporary_fix or fixed, with completed-by and cost
app.post('/api/tasks/:id/resolve', { preHandler: requireCap('resolve') }, async (req, reply) => { app.post('/api/tasks/:id/resolve', { preHandler: requireCap('resolve') }, async (req, reply) => {
const b = req.body || {} const b = req.body || {}

View file

@ -75,6 +75,9 @@ export function createTask(body: Record<string, unknown>): Promise<Task> {
export function updateTask(id: number, body: Record<string, unknown>): Promise<Task> { export function updateTask(id: number, body: Record<string, unknown>): Promise<Task> {
return request(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) return request(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
} }
export function deleteTask(id: number): Promise<{ ok: boolean }> {
return request(`/tasks/${id}`, { method: 'DELETE' })
}
export function resolveTask(id: number, body: { export function resolveTask(id: number, body: {
status: 'temporary_fix' | 'fixed' status: 'temporary_fix' | 'fixed'
completed_by?: string completed_by?: string

View file

@ -1,12 +1,12 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
X, Camera, MessageSquare, ArrowRight, RotateCcw, PlusCircle, X, Camera, MessageSquare, ArrowRight, RotateCcw, PlusCircle,
CheckCircle2, Image as ImageIcon, PoundSterling, UserRound, CheckCircle2, Image as ImageIcon, PoundSterling, UserRound, Pencil, Trash2,
} from 'lucide-react' } from 'lucide-react'
import type { TaskDetail, TaskStatus, TaskEvent, AuthUser } from '../types' import type { TaskDetail, TaskStatus, TaskEvent, AuthUser, Priority } from '../types'
import { STATUS_LABELS, TRANSITIONS, can } from '../types' import { STATUS_LABELS, TRANSITIONS, PRIORITIES, PRIORITY_LABELS, can } from '../types'
import { import {
fetchTask, updateTask, resolveTask, addComment, uploadTaskPhoto, deletePhoto, fetchTask, updateTask, deleteTask, resolveTask, addComment, uploadTaskPhoto, deletePhoto,
photoUrl, fetchAssignableUsers, photoUrl, fetchAssignableUsers,
} from '../api' } from '../api'
import { useAuth } from './AuthGate' import { useAuth } from './AuthGate'
@ -51,6 +51,14 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
const [showReassign, setShowReassign] = useState(false) const [showReassign, setShowReassign] = useState(false)
const [assignment, setAssignment] = useState<Assignment | null>(null) const [assignment, setAssignment] = useState<Assignment | null>(null)
const [showEdit, setShowEdit] = useState(false)
const [editTitle, setEditTitle] = useState('')
const [editDescription, setEditDescription] = useState('')
const [editPriority, setEditPriority] = useState<Priority>('medium')
const [editUnusable, setEditUnusable] = useState(false)
const [editDueDate, setEditDueDate] = useState('')
const [confirmDelete, setConfirmDelete] = useState(false)
const [showResolve, setShowResolve] = useState<null | 'temporary_fix' | 'fixed'>(null) const [showResolve, setShowResolve] = useState<null | 'temporary_fix' | 'fixed'>(null)
const [resolveUsers, setResolveUsers] = useState<AuthUser[]>([]) const [resolveUsers, setResolveUsers] = useState<AuthUser[]>([])
const [completedBy, setCompletedBy] = useState('') const [completedBy, setCompletedBy] = useState('')
@ -77,6 +85,24 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
} }
}, [showResolve]) }, [showResolve])
function openEdit() {
if (!task) return
setEditTitle(task.title)
setEditDescription(task.description ?? '')
setEditPriority(task.priority)
setEditUnusable(task.unusable)
setEditDueDate(task.due_date ?? '')
setShowEdit(true)
}
async function doDelete() {
await run(async () => {
await deleteTask(task!.id)
onChanged()
onClose()
})
}
async function run(fn: () => Promise<unknown>) { async function run(fn: () => Promise<unknown>) {
setBusy(true) setBusy(true)
setError(null) setError(null)
@ -149,6 +175,16 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
{task.template_id && <span className="badge badge-outline">Recurring</span>} {task.template_id && <span className="badge badge-outline">Recurring</span>}
</div> </div>
</div> </div>
{canUpdate && (
<button className="btn btn-sm" onClick={openEdit} title="Edit task">
<Pencil size={13} strokeWidth={1.75} /> Edit
</button>
)}
{user.is_admin && (
<button className="btn btn-sm btn-danger" onClick={() => setConfirmDelete(true)} title="Delete task">
<Trash2 size={13} strokeWidth={1.75} />
</button>
)}
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button> <button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
</div> </div>
@ -171,7 +207,64 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
{showCosts && task.cost != null && <span>Cost £{task.cost}{task.cost_notes ? ` (${task.cost_notes})` : ''}</span>} {showCosts && task.cost != null && <span>Cost £{task.cost}{task.cost_notes ? ` (${task.cost_notes})` : ''}</span>}
</div> </div>
{task.description && <p style={{ whiteSpace: 'pre-wrap', margin: '0 0 12px' }}>{task.description}</p>} {!showEdit && task.description && <p style={{ whiteSpace: 'pre-wrap', margin: '0 0 12px' }}>{task.description}</p>}
{/* Edit form */}
{showEdit && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="section-title" style={{ marginTop: 0 }}>Edit task</div>
<div className="field">
<label>Title</label>
<input type="text" value={editTitle} onChange={e => setEditTitle(e.target.value)} />
</div>
<div className="field">
<label>Description</label>
<textarea value={editDescription} onChange={e => setEditDescription(e.target.value)} style={{ minHeight: 60 }} />
</div>
<div className="field-row">
<div className="field">
<label>Priority</label>
<select value={editPriority} onChange={e => setEditPriority(e.target.value as Priority)}>
{PRIORITIES.map(p => <option key={p} value={p}>{PRIORITY_LABELS[p]}</option>)}
</select>
</div>
<div className="field">
<label>Due date</label>
<input type="date" value={editDueDate} onChange={e => setEditDueDate(e.target.value)} />
</div>
</div>
<label className="field-check">
<input type="checkbox" checked={editUnusable} onChange={e => setEditUnusable(e.target.checked)} />
Mark as unusable (room/area out of service)
</label>
<div className="modal-actions" style={{ marginTop: 10 }}>
<button className="btn btn-sm" onClick={() => setShowEdit(false)}>Cancel</button>
<button className="btn btn-sm btn-primary" disabled={busy || !editTitle.trim()} onClick={() => {
run(() => updateTask(task!.id, {
title: editTitle.trim(),
description: editDescription.trim() || null,
priority: editPriority,
unusable: editUnusable,
due_date: editDueDate || null,
}))
setShowEdit(false)
}}>Save changes</button>
</div>
</div>
)}
{/* Delete confirm */}
{confirmDelete && (
<div className="card" style={{ background: 'var(--danger-bg)', marginBottom: 12 }}>
<p style={{ margin: '0 0 10px', fontWeight: 500 }}>Delete this task permanently? This cannot be undone.</p>
<div className="modal-actions">
<button className="btn btn-sm" onClick={() => setConfirmDelete(false)}>Cancel</button>
<button className="btn btn-sm btn-danger" disabled={busy} onClick={doDelete}>
{busy ? 'Deleting…' : 'Yes, delete'}
</button>
</div>
</div>
)}
{/* Photos */} {/* Photos */}
<div className="section-title">Photos</div> <div className="section-title">Photos</div>