feat: add Return to QC action for jobs in resting statuses

Allow production/admin users to move jobs back to pending_qc from
completed, pending_final_review, rejected, qc_feedback, tts_failed,
render_failed, approved_english, and approved_source statuses. Includes
single-job endpoint, bulk endpoint, JobDetail inline form with required
notes, bulk action in JobsList with confirmation modal, and a Review
Notes card on the job overview tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael 2026-02-14 13:18:02 -06:00
parent 89a902d392
commit 0c3102b77f
7 changed files with 470 additions and 5 deletions

View file

@ -22,12 +22,15 @@ from ...schemas.job import (
BulkDeleteRequest,
BulkDeleteResponse,
BulkDownloadRequest,
BulkReturnToQCRequest,
BulkReturnToQCResponse,
CompleteJobRequest,
JobDeleteResponse,
JobDownloadsResponse,
JobListResponse,
JobResponse,
RejectJobRequest,
ReturnToQCRequest,
UpdateTTSPreferencesRequest,
VttContentResponse,
VttTimingAdjustRequest,
@ -321,6 +324,70 @@ async def bulk_approve_jobs(
}
@router.post("/bulk/return-to-qc", response_model=BulkReturnToQCResponse)
async def bulk_return_to_qc(
request: BulkReturnToQCRequest,
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Bulk return multiple jobs to QC review status."""
unique_job_ids = list(dict.fromkeys(request.job_ids))
if len(unique_job_ids) != len(request.job_ids):
logger.warning(f"Removed {len(request.job_ids) - len(unique_job_ids)} duplicate job IDs from bulk return-to-qc request")
logger.info(f"Bulk return-to-qc for {len(unique_job_ids)} jobs requested by {current_user.email}")
returned_count = 0
errors = []
for job_id in unique_job_ids:
try:
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
errors.append(f"Job {job_id}: not found")
continue
if job_doc["status"] not in RETURN_TO_QC_ELIGIBLE_STATUSES:
errors.append(f"Job {job_id}: cannot return to QC from status '{job_doc['status']}'")
continue
result = await db.jobs.find_one_and_update(
{"_id": job_id},
{
"$set": {
"status": JobStatus.PENDING_QC.value,
"error": None,
"updated_at": datetime.utcnow()
},
"$push": {
"review.history": {
"at": datetime.utcnow(),
"status": JobStatus.PENDING_QC.value,
"by": str(current_user.id),
"notes": request.notes
}
}
},
return_document=True
)
if result:
returned_count += 1
logger.info(f"Job {job_id} returned to QC")
else:
errors.append(f"Job {job_id}: update failed (may have been modified concurrently)")
except Exception as e:
errors.append(f"Job {job_id}: {str(e)}")
logger.error(f"Failed to return job {job_id} to QC: {e}")
return {
"returned_count": returned_count,
"total_requested": len(unique_job_ids),
"errors": errors
}
# Downloadable job statuses
DOWNLOADABLE_STATUSES = [
JobStatus.COMPLETED.value,
@ -835,6 +902,81 @@ async def reject_final_review(
)
# Statuses eligible for "Return to QC" action
RETURN_TO_QC_ELIGIBLE_STATUSES = [
JobStatus.COMPLETED.value,
JobStatus.PENDING_FINAL_REVIEW.value,
JobStatus.REJECTED.value,
JobStatus.QC_FEEDBACK.value,
JobStatus.TTS_FAILED.value,
JobStatus.RENDER_FAILED.value,
JobStatus.APPROVED_ENGLISH.value,
JobStatus.APPROVED_SOURCE.value,
]
@router.post("/{job_id}/actions/return_to_qc", response_model=JobResponse)
async def return_to_qc(
job_id: str,
request: ReturnToQCRequest,
current_user: User = Depends(require_roles(UserRole.PRODUCTION, UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Return a job to QC review status for re-editing."""
job_doc = await db.jobs.find_one({"_id": job_id})
if not job_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
if job_doc["status"] not in RETURN_TO_QC_ELIGIBLE_STATUSES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Job cannot be returned to QC from status '{job_doc['status']}'"
)
result = await db.jobs.find_one_and_update(
{"_id": job_id},
{
"$set": {
"status": JobStatus.PENDING_QC.value,
"error": None,
"updated_at": datetime.utcnow()
},
"$push": {
"review.history": {
"at": datetime.utcnow(),
"status": JobStatus.PENDING_QC.value,
"by": str(current_user.id),
"notes": request.notes
}
}
},
return_document=True
)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found or status changed"
)
logger.info(f"Job {job_id} returned to QC by {current_user.email}: {request.notes}")
return JobResponse(
id=str(result["_id"]),
title=result["title"],
status=result["status"],
source=result["source"],
requested_outputs=RequestedOutputs(**result["requested_outputs"]),
review=result.get("review", {"notes": "", "history": []}),
outputs=result.get("outputs"),
created_at=result["created_at"].isoformat(),
updated_at=result["updated_at"].isoformat()
)
@router.get("/{job_id}/downloads", response_model=JobDownloadsResponse)
async def get_job_downloads(
job_id: str,

View file

@ -127,6 +127,21 @@ class BulkApproveResponse(BaseModel):
errors: list[str]
class ReturnToQCRequest(BaseModel):
notes: str
class BulkReturnToQCRequest(BaseModel):
job_ids: list[str]
notes: str
class BulkReturnToQCResponse(BaseModel):
returned_count: int
total_requested: int
errors: list[str]
class BulkDownloadRequest(BaseModel):
"""Request to download multiple jobs as a single zip file"""
job_ids: list[str]

View file

@ -6,6 +6,7 @@ import type {
VttUpdateRequest,
BulkDeleteRequest,
BulkApproveRequest,
BulkReturnToQCRequest,
TTSPreferences,
AccessibleVideoMethod
} from '../types/api';
@ -274,4 +275,28 @@ export function useRetryTts() {
queryClient.invalidateQueries({ queryKey: ['jobs'] });
},
});
}
export function useReturnToQC() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, notes }: { id: string; notes: string }) =>
apiClient.returnToQC(id, { notes }),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['jobs', id] });
queryClient.invalidateQueries({ queryKey: ['jobs'] });
},
});
}
export function useBulkReturnToQC() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: BulkReturnToQCRequest) => apiClient.bulkReturnToQC(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobs'] });
},
});
}

View file

@ -16,6 +16,8 @@ import type {
BulkDeleteResponse,
BulkApproveRequest,
BulkApproveResponse,
BulkReturnToQCRequest,
BulkReturnToQCResponse,
JobDeleteResponse,
User,
UserListResponse,
@ -289,6 +291,16 @@ class ApiClient {
return response.data;
}
async returnToQC(id: string, data: { notes: string }): Promise<Job> {
const response = await this.client.post(`/jobs/${id}/actions/return_to_qc`, data);
return response.data;
}
async bulkReturnToQC(data: BulkReturnToQCRequest): Promise<BulkReturnToQCResponse> {
const response = await this.client.post('/jobs/bulk/return-to-qc', data);
return response.data;
}
// User Management endpoints
async listUsers(filters?: {
page?: number;

View file

@ -1,12 +1,18 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import { useJob, useJobDownloads, useJobVttContent, useRetryTts } from '../../hooks/useJob';
import { useJob, useJobDownloads, useJobVttContent, useRetryTts, useReturnToQC } from '../../hooks/useJob';
import { useAuthStore } from '../../lib/auth';
import { StatusBadge } from '../../components/StatusBadge';
import { VideoWithCaptions } from '../../components/VideoWithCaptions';
import { useGlobalWebSocket } from '../../contexts/GlobalWebSocketContext';
import { useToastContext } from '../../contexts/ToastContext';
const RETURN_TO_QC_ELIGIBLE_STATUSES = [
'completed', 'pending_final_review', 'rejected', 'qc_feedback',
'tts_failed', 'render_failed', 'approved_english', 'approved_source',
];
const ProgressIndicator = ({ status }: { status: string }) => {
// New flow: processing happens before QC, approval goes directly to final review
@ -116,6 +122,28 @@ export function JobDetail() {
const retryTtsMutation = useRetryTts();
const toast = useToastContext();
// Return to QC
const { user } = useAuthStore();
const returnToQCMutation = useReturnToQC();
const [showReturnToQC, setShowReturnToQC] = useState(false);
const [returnToQCNotes, setReturnToQCNotes] = useState('');
const canReturnToQC = user &&
(user.role === 'production' || user.role === 'admin') &&
job?.status && RETURN_TO_QC_ELIGIBLE_STATUSES.includes(job.status);
const handleReturnToQC = async () => {
if (!id || !returnToQCNotes.trim()) return;
try {
await returnToQCMutation.mutateAsync({ id, notes: returnToQCNotes.trim() });
toast.toastOnly.success('Job returned to QC review');
setShowReturnToQC(false);
setReturnToQCNotes('');
} catch (err) {
toast.toastOnly.error('Failed to return job to QC');
}
};
const handleRetryTts = async () => {
if (!id) return;
try {
@ -211,6 +239,35 @@ export function JobDetail() {
</dl>
</div>
{/* Review Notes */}
{(() => {
const notesEntries = job.review?.history?.filter(entry => entry.notes && entry.notes.trim()) || [];
return (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Review Notes</h2>
{notesEntries.length > 0 ? (
<div className="space-y-3">
{notesEntries.map((entry, index) => (
<div key={index} className="p-3 bg-gray-50 rounded border-l-4 border-blue-400">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-gray-500 uppercase">
{entry.status.replace(/_/g, ' ')}
</span>
<span className="text-xs text-gray-400">
{formatDistanceToNow(new Date(entry.at))} ago
</span>
</div>
<p className="text-sm text-gray-800">{entry.notes}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400">No review notes yet</p>
)}
</div>
);
})()}
{/* Download Section */}
{job.status === 'completed' && (
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
@ -443,6 +500,60 @@ export function JobDetail() {
</div>
</div>
)}
{/* Return to QC */}
{canReturnToQC && (
<div className="mt-8 bg-amber-50 border border-amber-200 rounded-lg p-6">
{!showReturnToQC ? (
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-amber-800">Return to QC</h3>
<p className="text-amber-700 text-sm mt-1">
Move this job back to QC review for further editing.
</p>
</div>
<button
onClick={() => setShowReturnToQC(true)}
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700 shadow-sm"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
Return to QC
</button>
</div>
) : (
<div>
<h3 className="text-lg font-medium text-amber-800 mb-3">Return to QC</h3>
<p className="text-amber-700 text-sm mb-3">
This will move the job back to QC review status. Please provide a reason.
</p>
<textarea
value={returnToQCNotes}
onChange={(e) => setReturnToQCNotes(e.target.value)}
placeholder="Reason for returning to QC..."
rows={3}
className="w-full px-3 py-2 border border-amber-300 rounded-md focus:outline-none focus:ring-2 focus:ring-amber-500 mb-3"
/>
<div className="flex items-center space-x-3">
<button
onClick={handleReturnToQC}
disabled={!returnToQCNotes.trim() || returnToQCMutation.isPending}
className="px-4 py-2 bg-amber-600 text-white text-sm font-medium rounded-md hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{returnToQCMutation.isPending ? 'Returning...' : 'Confirm Return to QC'}
</button>
<button
onClick={() => { setShowReturnToQC(false); setReturnToQCNotes(''); }}
className="px-4 py-2 text-gray-600 text-sm font-medium hover:text-gray-800"
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* Sidebar */}

View file

@ -2,7 +2,7 @@ import { useState, useMemo, useEffect } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { formatDistanceToNow, subDays, isAfter } from 'date-fns';
import { useAuthStore } from '../../lib/auth';
import { useJobs, useBulkDeleteJobs, useReprocessJob } from '../../hooks/useJob';
import { useJobs, useBulkDeleteJobs, useReprocessJob, useBulkReturnToQC } from '../../hooks/useJob';
import { StatusBadge } from '../../components/StatusBadge';
import { useToastContext } from '../../contexts/ToastContext';
import { useGlobalWebSocket } from '../../contexts/GlobalWebSocketContext';
@ -12,6 +12,12 @@ import type { Job } from '../../types/api';
// Statuses eligible for bulk download (approved or completed)
const DOWNLOADABLE_STATUSES = ['completed', 'approved_english', 'approved_source'];
// Statuses eligible for "Return to QC" action
const RETURN_TO_QC_ELIGIBLE_STATUSES = [
'completed', 'pending_final_review', 'rejected', 'qc_feedback',
'tts_failed', 'render_failed', 'approved_english', 'approved_source',
];
// Human-readable status labels
const STATUS_LABELS: Record<string, string> = {
'created': 'Created',
@ -97,10 +103,12 @@ export function JobsList() {
// Selection and bulk action state
const [selectedJobs, setSelectedJobs] = useState<Set<string>>(new Set());
const [bulkAction, setBulkAction] = useState<'delete' | 'reprocess' | 'download' | ''>('');
const [bulkAction, setBulkAction] = useState<'delete' | 'reprocess' | 'download' | 'return_to_qc' | ''>('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
const [showDownloadConfirm, setShowDownloadConfirm] = useState(false);
const [showReturnToQCConfirm, setShowReturnToQCConfirm] = useState(false);
const [returnToQCNotes, setReturnToQCNotes] = useState('');
const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);
// Update filters from URL params
@ -120,6 +128,7 @@ export function JobsList() {
const bulkDeleteMutation = useBulkDeleteJobs();
const reprocessMutation = useReprocessJob();
const bulkReturnToQCMutation = useBulkReturnToQC();
// Get connection status from global WebSocket
const { connectionStatus } = useGlobalWebSocket();
@ -327,6 +336,51 @@ export function JobsList() {
return { selectedJobsList, eligibleJobs, ineligibleJobs };
};
const getReturnToQCEligibility = () => {
const selectedJobsList = filteredAndSortedJobs.filter(job => selectedJobs.has(job.id));
const eligibleJobs = selectedJobsList.filter(job => RETURN_TO_QC_ELIGIBLE_STATUSES.includes(job.status));
const ineligibleJobs = selectedJobsList.filter(job => !RETURN_TO_QC_ELIGIBLE_STATUSES.includes(job.status));
return { selectedJobsList, eligibleJobs, ineligibleJobs };
};
const handleBulkReturnToQC = async () => {
if (selectedJobs.size === 0 || !returnToQCNotes.trim()) return;
const eligibleJobIds = filteredAndSortedJobs
.filter(job => selectedJobs.has(job.id) && RETURN_TO_QC_ELIGIBLE_STATUSES.includes(job.status))
.map(job => job.id);
if (eligibleJobIds.length === 0) {
toast.toastOnly.error('No eligible jobs to return to QC');
return;
}
try {
const result = await bulkReturnToQCMutation.mutateAsync({
job_ids: eligibleJobIds,
notes: returnToQCNotes.trim(),
});
if (result.errors.length > 0) {
toast.toastOnly.warning(
`${result.returned_count}/${result.total_requested} jobs returned to QC. Some errors occurred.`
);
} else {
toast.toastOnly.success(
`${result.returned_count} job${result.returned_count !== 1 ? 's' : ''} returned to QC`
);
}
clearSelection();
setShowReturnToQCConfirm(false);
setReturnToQCNotes('');
setBulkAction('');
} catch (error) {
console.error('Bulk return to QC failed:', error);
toast.toastOnly.error('Failed to return jobs to QC. Please try again.');
}
};
const handleBulkDownload = async () => {
setShowDownloadConfirm(false);
@ -574,14 +628,15 @@ export function JobsList() {
<>
<select
value={bulkAction}
onChange={(e) => setBulkAction(e.target.value as 'delete' | 'reprocess' | 'download' | '')}
disabled={bulkDeleteMutation.isPending || reprocessMutation.isPending}
onChange={(e) => setBulkAction(e.target.value as 'delete' | 'reprocess' | 'download' | 'return_to_qc' | '')}
disabled={bulkDeleteMutation.isPending || reprocessMutation.isPending || bulkReturnToQCMutation.isPending}
className="text-sm border border-gray-300 rounded px-2 py-1 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">Choose action...</option>
<option value="delete">Delete Selected</option>
<option value="reprocess">Reprocess Selected</option>
<option value="download">Download All Files</option>
<option value="return_to_qc">Return to QC</option>
</select>
{bulkAction === 'delete' && (
@ -614,6 +669,16 @@ export function JobsList() {
</button>
)}
{bulkAction === 'return_to_qc' && (
<button
onClick={() => setShowReturnToQCConfirm(true)}
disabled={bulkReturnToQCMutation.isPending}
className="px-3 py-1 text-sm bg-amber-600 text-white rounded hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{bulkReturnToQCMutation.isPending ? 'Returning...' : 'Return to QC'}
</button>
)}
<button
onClick={clearSelection}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
@ -976,6 +1041,90 @@ export function JobsList() {
);
})()}
{/* Return to QC Confirmation Modal */}
{showReturnToQCConfirm && (() => {
const { eligibleJobs, ineligibleJobs } = getReturnToQCEligibility();
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3 text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-amber-100">
<svg
className="h-6 w-6 text-amber-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mt-4">
Return {selectedJobs.size} Job{selectedJobs.size !== 1 ? 's' : ''} to QC?
</h3>
<div className="mt-2 px-4 py-3">
<p className="text-sm text-gray-700">
<span className="font-semibold text-amber-600">{eligibleJobs.length}</span>{' '}
of {selectedJobs.size} selected job{selectedJobs.size !== 1 ? 's are' : ' is'} eligible to return to QC.
</p>
{ineligibleJobs.length > 0 && (
<div className="mt-3 p-3 bg-yellow-50 rounded-md">
<p className="text-sm text-yellow-800">
<strong>{ineligibleJobs.length}</strong> job{ineligibleJobs.length !== 1 ? 's are' : ' is'} not in an eligible status and will be skipped.
</p>
</div>
)}
<div className="mt-3 p-3 bg-amber-50 rounded-md text-left">
<div className="text-sm text-amber-800">
<strong>This will:</strong>
</div>
<ul className="mt-1 text-sm text-amber-700">
<li>- Move jobs back to QC review status</li>
<li>- Allow re-editing of captions and audio descriptions</li>
<li>- Require re-approval before final review</li>
</ul>
</div>
<div className="mt-3 text-left">
<label className="block text-sm font-medium text-gray-700 mb-1">
Reason for returning to QC <span className="text-red-500">*</span>
</label>
<textarea
value={returnToQCNotes}
onChange={(e) => setReturnToQCNotes(e.target.value)}
placeholder="Describe why these jobs need to be returned to QC..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm"
/>
</div>
</div>
<div className="items-center px-4 py-3 flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-3">
<button
onClick={() => { setShowReturnToQCConfirm(false); setReturnToQCNotes(''); }}
className="px-4 py-2 bg-gray-500 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-300 sm:w-auto"
>
Cancel
</button>
<button
onClick={handleBulkReturnToQC}
disabled={eligibleJobs.length === 0 || !returnToQCNotes.trim() || bulkReturnToQCMutation.isPending}
className="px-4 py-2 bg-amber-600 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-300 disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto"
>
{bulkReturnToQCMutation.isPending ? 'Returning...' : 'Return to QC'}
</button>
</div>
</div>
</div>
</div>
);
})()}
{/* Download Progress Indicator */}
{downloadProgress && (
<div className="fixed bottom-4 right-4 bg-white rounded-lg shadow-lg p-4 z-50 border border-gray-200">

View file

@ -264,6 +264,17 @@ export interface BulkApproveResponse {
errors: string[];
}
export interface BulkReturnToQCRequest {
job_ids: string[];
notes: string;
}
export interface BulkReturnToQCResponse {
returned_count: number;
total_requested: number;
errors: string[];
}
export interface JobDeleteResponse {
message: string;
}