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:
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user