diff --git a/backend/app/api/v1/routes_jobs.py b/backend/app/api/v1/routes_jobs.py index 79566d8..bc1eb0a 100644 --- a/backend/app/api/v1/routes_jobs.py +++ b/backend/app/api/v1/routes_jobs.py @@ -75,6 +75,7 @@ async def create_job( file: UploadFile = File(...), brand_context: Optional[str] = Form(None), project_id: Optional[str] = Form(None), + deadline: Optional[str] = Form(None), # ISO date string e.g. "2026-05-15" request: Request = None, current_user: User = Depends(get_current_user), db: AsyncIOMotorDatabase = Depends(get_database), @@ -127,6 +128,7 @@ async def create_job( }, "brand_context": brand_context or None, "project_id": project_id or None, + "deadline": datetime.fromisoformat(deadline) if deadline else None, "created_at": datetime.utcnow(), "updated_at": datetime.utcnow() } @@ -1591,6 +1593,52 @@ async def adjust_vtt_timing( ) +@router.post("/{job_id}/clone", response_model=JobResponse, status_code=status.HTTP_201_CREATED) +async def clone_job( + job_id: str, + current_user: User = Depends(require_roles(UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN)), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Clone a job config (no file) — creates a new job in 'created' state with same settings.""" + 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") + + new_id = str(ObjectId()) + now = datetime.utcnow() + clone = { + "_id": new_id, + "client_id": job_doc.get("client_id"), + "title": f"{job_doc.get('title', 'Untitled')} (Copy)", + "source": job_doc.get("source", {}), + "requested_outputs": job_doc.get("requested_outputs", {}), + "status": JobStatus.CREATED.value, + "review": {"notes": "", "history": [{"at": now, "status": JobStatus.CREATED.value, "by": str(current_user.id)}]}, + "brand_context": job_doc.get("brand_context"), + "project_id": job_doc.get("project_id"), + "organization_id": job_doc.get("organization_id"), + "deadline": job_doc.get("deadline"), + "language_qc": {}, + "qc_assignments": [], + "created_at": now, + "updated_at": now, + } + await db.jobs.insert_one(clone) + + result = await db.jobs.find_one({"_id": new_id}) + return JobResponse( + id=str(result["_id"]), + client_id=result["client_id"], + title=result["title"], + source=result["source"], + requested_outputs=result["requested_outputs"], + status=result["status"], + review=result.get("review", {}), + created_at=result["created_at"].isoformat(), + updated_at=result["updated_at"].isoformat() + ) + + @router.delete("/{job_id}", response_model=JobDeleteResponse) async def delete_job( job_id: str, diff --git a/backend/app/api/v1/routes_language_qc.py b/backend/app/api/v1/routes_language_qc.py index a9a66ad..7d9d541 100644 --- a/backend/app/api/v1/routes_language_qc.py +++ b/backend/app/api/v1/routes_language_qc.py @@ -48,6 +48,7 @@ class ApproveLanguageRequest(BaseModel): class RejectLanguageRequest(BaseModel): notes: str + category: Optional[str] = None # timing | mistranslation | terminology | profanity | length | other class ReopenLanguageRequest(BaseModel): @@ -252,11 +253,47 @@ async def reject_language( db: AsyncIOMotorDatabase = Depends(get_database), ): state = await lqc.reject_language( - db, job_id, lang, current_user, request.notes, http_request=http_request, + db, job_id, lang, current_user, request.notes, category=request.category, http_request=http_request, ) return LanguageQCStateResponse(lang=lang, state=state) +class MarkCueReviewedRequest(BaseModel): + total_cues: Optional[int] = None # client sends on first call to set total + + +@router.post("/jobs/{job_id}/languages/{lang}/mark-cue-reviewed", response_model=LanguageQCStateResponse) +async def mark_cue_reviewed( + job_id: str, + lang: str, + request: MarkCueReviewedRequest, + current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.ADMIN)), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Increment reviewed_cues counter; optionally set total_cues on first call.""" + job_doc = await db.jobs.find_one({"_id": job_id}) + if not job_doc: + raise HTTPException(status_code=404, detail="Job not found") + + update: dict = { + f"language_qc.{lang}.reviewed_cues": 1, # will use $inc below + "updated_at": datetime.utcnow(), + } + inc_op: dict = {f"language_qc.{lang}.reviewed_cues": 1} + set_op: dict = {"updated_at": datetime.utcnow()} + + if request.total_cues is not None: + set_op[f"language_qc.{lang}.total_cues"] = request.total_cues + + await db.jobs.update_one({"_id": job_id}, {"$inc": inc_op, "$set": set_op}) + + updated_doc = await db.jobs.find_one({"_id": job_id}) + state_dict = (updated_doc.get("language_qc") or {}).get(lang, {}) + from ...models.job import LanguageQCState + state = LanguageQCState(**state_dict) if isinstance(state_dict, dict) else LanguageQCState() + return LanguageQCStateResponse(lang=lang, state=state) + + @router.post("/jobs/{job_id}/languages/{lang}/reopen", response_model=LanguageQCStateResponse) async def reopen_language( job_id: str, diff --git a/backend/app/models/job.py b/backend/app/models/job.py index 6a1effc..241b4e4 100644 --- a/backend/app/models/job.py +++ b/backend/app/models/job.py @@ -192,11 +192,15 @@ class LanguageQCState(BaseModel): assigned_reviewer_at: Optional[datetime] = None review_started_at: Optional[datetime] = None reviewer_deadline: Optional[datetime] = None # when reviewer must decide + # Reviewer progress + total_cues: Optional[int] = None # set when reviewer opens the job + reviewed_cues: int = 0 # incremented as reviewer marks cues reviewed # Final outcome reviewed_at: Optional[datetime] = None reviewed_by_user_id: Optional[str] = None reviewed_by_email: Optional[str] = None notes: Optional[str] = None + reject_category: Optional[str] = None # e.g. timing/mistranslation/terminology/profanity/length history: list[LanguageQCEvent] = [] comments: list[LanguageQCComment] = [] @@ -238,6 +242,7 @@ class Job(BaseModel): project_id: Optional[str] = None # Platform project this job belongs to (Client → Project → Job) brand_context: Optional[str] = None # Brand names present in the video for accurate product identification cost_tracker_project_id: Optional[str] = None # External project ID for AI cost attribution + deadline: Optional[datetime] = None # job-level PM deadline (overdue if past and not completed) language_qc: dict[str, LanguageQCState] = {} # per-language QC state, keyed by lang code qc_assignments: list[QCAssignment] = [] # denormalized for linguist-queue queries created_at: Optional[datetime] = None @@ -263,3 +268,4 @@ class JobUpdate(BaseModel): outputs: Optional[dict[str, LangOutput]] = None ai: Optional[AISection] = None error: Optional[dict[str, Any]] = None + deadline: Optional[datetime] = None diff --git a/backend/app/services/language_qc.py b/backend/app/services/language_qc.py index 9514a84..91c10a7 100644 --- a/backend/app/services/language_qc.py +++ b/backend/app/services/language_qc.py @@ -606,6 +606,9 @@ async def approve_language( return LanguageQCState(**updated_state) +REJECT_CATEGORIES = frozenset(["timing", "mistranslation", "terminology", "profanity", "length", "other"]) + + async def reject_language( db: AsyncIOMotorDatabase, job_id: str, @@ -613,10 +616,13 @@ async def reject_language( actor: User, notes: str, *, + category: str | None = None, http_request=None, ) -> LanguageQCState: if not notes or not notes.strip(): raise HTTPException(status_code=422, detail="Rejection notes are required") + if category and category not in REJECT_CATEGORIES: + raise HTTPException(status_code=422, detail=f"Invalid reject category. Must be one of: {', '.join(sorted(REJECT_CATEGORIES))}") job_doc = await db[_JOBS].find_one({"_id": job_id}) if not job_doc: @@ -639,6 +645,8 @@ async def reject_language( "reviewed_by_user_id": str(actor.id), "reviewed_by_email": actor.email, "notes": notes, + "reject_category": category, + "reviewed_cues": 0, "submitted_for_review_at": None, "history": history, } diff --git a/frontend/src/hooks/useJob.ts b/frontend/src/hooks/useJob.ts index f016a61..8ca4d9c 100644 --- a/frontend/src/hooks/useJob.ts +++ b/frontend/src/hooks/useJob.ts @@ -274,6 +274,17 @@ export function useReprocessJob() { }); } +export function useCloneJob() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => apiClient.cloneJob(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['jobs'] }); + }, + }); +} + export function useRetryTts() { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/useMultiUpload.ts b/frontend/src/hooks/useMultiUpload.ts index a0c3b7d..c7741de 100644 --- a/frontend/src/hooks/useMultiUpload.ts +++ b/frontend/src/hooks/useMultiUpload.ts @@ -14,6 +14,7 @@ export interface SharedJobSettings { requestedOutputs: RequestedOutputs; brandContext?: string; projectId?: string; + deadline?: string; } interface UseMultiUploadOptions { @@ -110,6 +111,7 @@ export function useMultiUpload(options: UseMultiUploadOptions = {}): UseMultiUpl requested_outputs: settings.requestedOutputs, brand_context: settings.brandContext, project_id: settings.projectId, + deadline: settings.deadline, }, item.file, (progressEvent) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 486a60d..ad73b0f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -188,6 +188,9 @@ class ApiClient { if (data.project_id) { formData.append('project_id', data.project_id); } + if (data.deadline) { + formData.append('deadline', data.deadline); + } formData.append('file', file); const response = await this.client.post('/jobs', formData, { @@ -289,6 +292,11 @@ class ApiClient { return response.data; } + async cloneJob(id: string): Promise { + const response = await this.client.post(`/jobs/${id}/clone`); + return response.data; + } + async bulkDeleteJobs(data: BulkDeleteRequest): Promise { const response = await this.client.delete('/jobs/bulk', { data }); return response.data; @@ -770,8 +778,13 @@ class ApiClient { return r.data; } - async rejectLanguageQC(jobId: string, lang: string, notes: string): Promise { - const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/reject`, { notes }); + async rejectLanguageQC(jobId: string, lang: string, notes: string, category?: string): Promise { + const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/reject`, { notes, category: category || undefined }); + return r.data; + } + + async markCueReviewed(jobId: string, lang: string, totalCues?: number): Promise { + const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/mark-cue-reviewed`, { total_cues: totalCues }); return r.data; } diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index e2f20cb..87fe5d6 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -111,6 +111,7 @@ export function QCDetail() { const [showLangRejectModal, setShowLangRejectModal] = useState(false); const [langRejectNotes, setLangRejectNotes] = useState(''); + const [langRejectCategory, setLangRejectCategory] = useState(''); // Unified assign modal state — slot: 'linguist' | 'reviewer' const [showAssignModal, setShowAssignModal] = useState(false); @@ -147,13 +148,14 @@ export function QCDetail() { }); const rejectLanguageMutation = useMutation({ - mutationFn: ({ lang, notes }: { lang: string; notes: string }) => - apiClient.rejectLanguageQC(id!, lang, notes), + mutationFn: ({ lang, notes, category }: { lang: string; notes: string; category?: string }) => + apiClient.rejectLanguageQC(id!, lang, notes, category), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['language-qc', id] }); queryClient.invalidateQueries({ queryKey: ['job', id] }); setShowLangRejectModal(false); setLangRejectNotes(''); + setLangRejectCategory(''); refetchLangQc(); toast.toastOnly.success('Language sent back for changes'); }, @@ -903,15 +905,32 @@ export function QCDetail() { Open review )} - {canApproveThis && ( - - )} + {canApproveThis && (() => { + const reviewedCues = qcState?.reviewed_cues ?? 0; + const totalCues = qcState?.total_cues ?? null; + const pct = totalCues ? Math.round((reviewedCues / totalCues) * 100) : null; + const gated = !canApproveAll && totalCues !== null && reviewedCues < Math.ceil(totalCues * 0.8); + return ( +
+ {totalCues !== null && ( +
+
+
+
+ {reviewedCues}/{totalCues} reviewed +
+ )} + +
+ ); + })()} {canRejectThis && ( + ))} +
+