Add delete job functionality with confirmation step

Adds DELETE /api/jobs/{id} endpoint (removes DB record and image file),
and a two-step Delete / Confirm button on the review page that returns
to the job list on success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Roberts
2026-06-09 18:33:46 +01:00
parent dc5a1a4ff5
commit 02e3099388
2 changed files with 87 additions and 2 deletions

View File

@@ -877,6 +877,39 @@ async def review_job(job_id: str, body: ReviewRequest):
return JSONResponse(_job_row_to_dict(row)) return JSONResponse(_job_row_to_dict(row))
@app.delete("/api/jobs/{job_id}")
async def delete_job(job_id: str):
"""Delete a job record and its stored image."""
try:
uuid.UUID(job_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid job ID.")
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM ocr_jobs WHERE id = %s RETURNING image_path",
(job_id,),
)
row = cur.fetchone()
except Exception as exc:
print(f"delete_job DB error: {exc}")
raise HTTPException(status_code=500, detail="Database error.")
if not row:
raise HTTPException(status_code=404, detail="Job not found.")
# Best-effort removal of the stored image file
try:
if row["image_path"] and os.path.isfile(row["image_path"]):
os.remove(row["image_path"])
except Exception:
pass
return JSONResponse({"deleted": job_id})
if __name__ == "__main__": if __name__ == "__main__":
host = env_config("API_HOST", default="0.0.0.0") host = env_config("API_HOST", default="0.0.0.0")
port = env_config("API_PORT", default=8000, cast=int) port = env_config("API_PORT", default=8000, cast=int)

View File

@@ -3,7 +3,7 @@ import { useSuggestions } from '../hooks/useSuggestions'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { import {
Search, ChevronLeft, ChevronRight, CheckCircle2, Clock, Search, ChevronLeft, ChevronRight, CheckCircle2, Clock,
FileText, Loader2, Save, RefreshCw, FileText, Loader2, Save, RefreshCw, Trash2,
} from 'lucide-react' } from 'lucide-react'
import axios from 'axios' import axios from 'axios'
@@ -31,7 +31,7 @@ function StatusBadge({ status }) {
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Full-screen Job Detail // Full-screen Job Detail
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
function JobDetail({ jobId, onClose, onReviewed, suggestions = {} }) { function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} }) {
const [job, setJob] = useState(null) const [job, setJob] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@@ -45,6 +45,8 @@ function JobDetail({ jobId, onClose, onReviewed, suggestions = {} }) {
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [saveResult, setSaveResult] = useState(null) const [saveResult, setSaveResult] = useState(null)
const [confirmDelete, setConfirmDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -99,6 +101,19 @@ function JobDetail({ jobId, onClose, onReviewed, suggestions = {} }) {
} }
} }
const handleDelete = async () => {
setDeleting(true)
try {
await axios.delete(`${API_BASE}/jobs/${jobId}`)
onDeleted(jobId)
} catch (err) {
setSaveResult({ success: false, error: err.response?.data?.detail || err.message })
setConfirmDelete(false)
} finally {
setDeleting(false)
}
}
const isReviewed = job?.status === 'reviewed' const isReviewed = job?.status === 'reviewed'
return ( return (
@@ -125,6 +140,38 @@ function JobDetail({ jobId, onClose, onReviewed, suggestions = {} }) {
<span className="text-xs text-gray-500 font-mono hidden sm:block">{job.id}</span> <span className="text-xs text-gray-500 font-mono hidden sm:block">{job.id}</span>
</> </>
)} )}
<div className="ml-auto flex items-center gap-2">
{confirmDelete ? (
<>
<span className="text-xs text-red-400">Delete this job permanently?</span>
<motion.button
onClick={handleDelete}
disabled={deleting}
className="flex items-center gap-1 px-3 py-2 rounded-xl text-sm font-medium bg-red-600 hover:bg-red-500 disabled:opacity-50"
whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}
>
{deleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Confirm
</motion.button>
<motion.button
onClick={() => setConfirmDelete(false)}
className="px-3 py-2 rounded-xl text-sm glass glass-hover text-gray-300"
whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}
>
Cancel
</motion.button>
</>
) : (
<motion.button
onClick={() => setConfirmDelete(true)}
className="flex items-center gap-2 px-3 py-2 rounded-xl text-sm glass glass-hover text-red-400 hover:bg-red-500/10"
whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}
>
<Trash2 className="w-4 h-4" />
Delete
</motion.button>
)}
</div>
</div> </div>
{loading && ( {loading && (
@@ -322,6 +369,11 @@ export default function JobsPanel() {
jobId={selectedJobId} jobId={selectedJobId}
onClose={() => setSelectedJobId(null)} onClose={() => setSelectedJobId(null)}
onReviewed={handleReviewed} onReviewed={handleReviewed}
onDeleted={(id) => {
setJobs(prev => prev.filter(j => j.id !== id))
setTotal(prev => prev - 1)
setSelectedJobId(null)
}}
suggestions={suggestions} suggestions={suggestions}
/> />
</AnimatePresence> </AnimatePresence>