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:
parent
89a902d392
commit
0c3102b77f
7 changed files with 470 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue