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:
parent
03d1cd2bb6
commit
ca006f9aea
3 changed files with 118 additions and 5 deletions
|
|
@ -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 || {}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue