Added job review toggle
This commit is contained in:
@@ -885,6 +885,66 @@ async def review_job(job_id: str, body: ReviewRequest):
|
|||||||
return JSONResponse(_job_row_to_dict(row))
|
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}")
|
@app.delete("/api/jobs/{job_id}")
|
||||||
async def delete_job(job_id: str):
|
async def delete_job(job_id: str):
|
||||||
"""Delete a job record and its stored image."""
|
"""Delete a job record and its stored image."""
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
|
|||||||
const [saveResult, setSaveResult] = useState(null)
|
const [saveResult, setSaveResult] = useState(null)
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [togglingStatus, setTogglingStatus] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
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 () => {
|
const handleDelete = async () => {
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
try {
|
try {
|
||||||
@@ -148,6 +172,27 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
|
|||||||
{job && (
|
{job && (
|
||||||
<>
|
<>
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
|
<motion.button
|
||||||
|
onClick={handleToggleStatus}
|
||||||
|
disabled={togglingStatus}
|
||||||
|
title={isReviewed ? 'Revert to unreviewed' : 'Mark as reviewed'}
|
||||||
|
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||||
|
isReviewed
|
||||||
|
? 'glass glass-hover text-amber-400 hover:bg-amber-500/10'
|
||||||
|
: 'glass glass-hover text-green-400 hover:bg-green-500/10'
|
||||||
|
}`}
|
||||||
|
whileHover={!togglingStatus ? { scale: 1.02 } : {}}
|
||||||
|
whileTap={!togglingStatus ? { scale: 0.98 } : {}}
|
||||||
|
>
|
||||||
|
{togglingStatus ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : isReviewed ? (
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
{isReviewed ? 'Mark Unreviewed' : 'Mark Reviewed'}
|
||||||
|
</motion.button>
|
||||||
<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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user