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}
>
)}