From 48f958de6cfc0bf72182a1f1c36eebe23ce38718 Mon Sep 17 00:00:00 2001 From: Aaron Roberts Date: Tue, 23 Jun 2026 10:43:44 +0100 Subject: [PATCH] Added job review toggle --- backend/main.py | 60 +++++++++++++++++++++++++++ frontend/src/components/JobsPanel.jsx | 45 ++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/backend/main.py b/backend/main.py index 11e7960..d56efb1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -885,6 +885,66 @@ async def review_job(job_id: str, body: ReviewRequest): return JSONResponse(_job_row_to_dict(row)) +class StatusRequest(BaseModel): + status: str + reviewer_name: Optional[str] = None + + +@app.put("/api/jobs/{job_id}/status") +async def set_job_status(job_id: str, body: StatusRequest): + """Toggle a job's reviewed status without touching its text or metadata. + + Marking 'reviewed' requires a reviewer_name and stamps reviewed_at. + Marking 'unreviewed' clears reviewed_at while preserving reviewed_text. + """ + try: + uuid.UUID(job_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid job ID.") + + if body.status not in ("reviewed", "unreviewed"): + raise HTTPException(status_code=400, detail="status must be 'reviewed' or 'unreviewed'.") + + if body.status == "reviewed" and not (body.reviewer_name or "").strip(): + raise HTTPException(status_code=400, detail="Reviewer name is required to mark reviewed.") + + try: + with get_db() as conn: + with conn.cursor() as cur: + if body.status == "reviewed": + cur.execute( + """ + UPDATE ocr_jobs + SET status = 'reviewed', + reviewer_name = %s, + reviewed_at = NOW() + WHERE id = %s + RETURNING * + """, + (body.reviewer_name.strip(), job_id), + ) + else: + cur.execute( + """ + UPDATE ocr_jobs + SET status = 'unreviewed', + reviewed_at = NULL + WHERE id = %s + RETURNING * + """, + (job_id,), + ) + row = cur.fetchone() + except Exception as exc: + print(f"set_job_status DB error: {exc}") + raise HTTPException(status_code=500, detail="Database error.") + + if not row: + raise HTTPException(status_code=404, detail="Job not found.") + + 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.""" diff --git a/frontend/src/components/JobsPanel.jsx b/frontend/src/components/JobsPanel.jsx index b0f57c5..32b3c4a 100644 --- a/frontend/src/components/JobsPanel.jsx +++ b/frontend/src/components/JobsPanel.jsx @@ -50,6 +50,7 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} }) const [saveResult, setSaveResult] = useState(null) const [confirmDelete, setConfirmDelete] = useState(false) const [deleting, setDeleting] = useState(false) + const [togglingStatus, setTogglingStatus] = useState(false) useEffect(() => { let cancelled = false @@ -112,6 +113,29 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} }) } } + const handleToggleStatus = async () => { + const next = isReviewed ? 'unreviewed' : 'reviewed' + if (next === 'reviewed' && !reviewerName.trim()) { + setSaveResult({ success: false, error: 'Reviewer name is required to mark reviewed.' }) + return + } + setTogglingStatus(true) + setSaveResult(null) + try { + const res = await axios.put(`${API_BASE}/jobs/${jobId}/status`, { + status: next, + reviewer_name: reviewerName.trim() || null, + }) + setJob(res.data) + setReviewerName(res.data.reviewer_name || '') + onReviewed(res.data) + } catch (err) { + setSaveResult({ success: false, error: err.response?.data?.detail || err.message }) + } finally { + setTogglingStatus(false) + } + } + const handleDelete = async () => { setDeleting(true) try { @@ -148,6 +172,27 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} }) {job && ( <> + + {togglingStatus ? ( + + ) : isReviewed ? ( + + ) : ( + + )} + {isReviewed ? 'Mark Unreviewed' : 'Mark Reviewed'} + {job.id} )}