feat(pr3+pr4): deadline field, job clone, reject categories, reviewed-cues gate

PM-1 (deadline):
- Job model: add deadline field (job-level PM deadline)
- POST /jobs: accept deadline as ISO date form param
- JobsList: deadline column with overdue highlight (red + warning icon)
- NewJob: date picker for deadline field
- useMultiUpload: pass deadline to batch job creation

PM-2 (clone job):
- POST /jobs/{id}/clone: creates config copy in 'created' state, no reupload
- useCloneJob hook, Clone button in JobsList actions
- navigate to cloned job on success

R-4 (reject categories):
- LanguageQCState: add reject_category field
- reject_language service: accept optional category (timing/mistranslation/terminology/profanity/length/other)
- RejectLanguageRequest: add category field
- QCDetail reject modal: category pill-selector before free-text notes

R-2 (reviewed-cues tracking):
- LanguageQCState: add reviewed_cues (int) + total_cues (nullable)
- POST /jobs/{id}/languages/{lang}/mark-cue-reviewed endpoint
- QCDetail: progress bar + approve gated at 80% for reviewer (admin bypasses)
- markCueReviewed API client method

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-29 18:39:05 +01:00
parent 460c6ce091
commit 13db347d65
11 changed files with 261 additions and 24 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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,
}

View file

@ -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();

View file

@ -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) => {

View file

@ -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<Job> {
const response = await this.client.post(`/jobs/${id}/clone`);
return response.data;
}
async bulkDeleteJobs(data: BulkDeleteRequest): Promise<BulkDeleteResponse> {
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<import('../types/api').LanguageQCStateResponse> {
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/reject`, { notes });
async rejectLanguageQC(jobId: string, lang: string, notes: string, category?: string): Promise<import('../types/api').LanguageQCStateResponse> {
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<import('../types/api').LanguageQCStateResponse> {
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/mark-cue-reviewed`, { total_cues: totalCues });
return r.data;
}

View file

@ -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
</button>
)}
{canApproveThis && (
<button
onClick={() => approveLanguageMutation.mutate({ lang })}
disabled={approveLanguageMutation.isPending}
className="text-xs px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
Approve
</button>
)}
{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 (
<div className="flex flex-col gap-1">
{totalCues !== null && (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div className="h-full bg-indigo-500 transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-gray-500 whitespace-nowrap">{reviewedCues}/{totalCues} reviewed</span>
</div>
)}
<button
onClick={() => approveLanguageMutation.mutate({ lang })}
disabled={approveLanguageMutation.isPending || gated}
title={gated ? 'Review at least 80% of cues before approving' : undefined}
className="text-xs px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Approve {gated && `(${pct}% reviewed)`}
</button>
</div>
);
})()}
{canRejectThis && (
<button
onClick={() => { setAssignLanguage(lang); setShowLangRejectModal(true); }}
@ -983,9 +1002,29 @@ export function QCDetail() {
{/* Reject / Request-changes modal */}
{showLangRejectModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-medium mb-2">Request changes {assignLanguage.toUpperCase()}</h3>
<p className="text-sm text-gray-500 mb-3">Describe what the linguist needs to correct. This will be sent to them by email.</p>
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 space-y-3">
<h3 className="text-lg font-medium">Request changes {assignLanguage.toUpperCase()}</h3>
<p className="text-sm text-gray-500">Describe what the linguist needs to correct. This will be sent to them by email.</p>
{/* Category selector (R-4) */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Issue category</label>
<div className="flex flex-wrap gap-2">
{['timing', 'mistranslation', 'terminology', 'profanity', 'length', 'other'].map(cat => (
<button
key={cat}
type="button"
onClick={() => setLangRejectCategory(prev => prev === cat ? '' : cat)}
className={`px-3 py-1 text-xs rounded-full border capitalize transition-colors ${
langRejectCategory === cat
? 'bg-red-600 border-red-600 text-white'
: 'border-gray-300 text-gray-600 hover:border-red-400'
}`}
>
{cat}
</button>
))}
</div>
</div>
<textarea
value={langRejectNotes}
onChange={e => setLangRejectNotes(e.target.value)}
@ -993,13 +1032,13 @@ export function QCDetail() {
placeholder="Required feedback…"
className="w-full text-sm border border-gray-300 rounded p-2 focus:outline-none focus:ring-2 focus:ring-red-400"
/>
<div className="flex gap-3 justify-end mt-3">
<button onClick={() => { setShowLangRejectModal(false); setLangRejectNotes(''); }}
<div className="flex gap-3 justify-end">
<button onClick={() => { setShowLangRejectModal(false); setLangRejectNotes(''); setLangRejectCategory(''); }}
className="px-4 py-2 text-sm border border-gray-300 rounded text-gray-700 hover:bg-gray-50">
Cancel
</button>
<button
onClick={() => rejectLanguageMutation.mutate({ lang: assignLanguage, notes: langRejectNotes })}
onClick={() => rejectLanguageMutation.mutate({ lang: assignLanguage, notes: langRejectNotes, category: langRejectCategory || undefined })}
disabled={!langRejectNotes.trim() || rejectLanguageMutation.isPending}
className="px-4 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
>

View file

@ -1,8 +1,8 @@
import { useState, useMemo, useEffect } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { formatDistanceToNow, subDays, isAfter } from 'date-fns';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { formatDistanceToNow, subDays, isAfter, isPast, format } from 'date-fns';
import { useAuthStore } from '../../lib/auth';
import { useJobs, useBulkDeleteJobs, useReprocessJob, useBulkReturnToQC } from '../../hooks/useJob';
import { useJobs, useBulkDeleteJobs, useReprocessJob, useBulkReturnToQC, useCloneJob } from '../../hooks/useJob';
import { StatusBadge } from '../../components/StatusBadge';
import { useToastContext } from '../../contexts/ToastContext';
import { useGlobalWebSocket } from '../../contexts/GlobalWebSocketContext';
@ -89,6 +89,7 @@ function SortableHeader({ column, label, currentSortColumn, sortDirection, onSor
export function JobsList() {
const { user } = useAuthStore();
const toast = useToastContext();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// Filter state
@ -133,6 +134,7 @@ export function JobsList() {
const bulkDeleteMutation = useBulkDeleteJobs();
const reprocessMutation = useReprocessJob();
const bulkReturnToQCMutation = useBulkReturnToQC();
const cloneJobMutation = useCloneJob();
// Get connection status from global WebSocket
const { connectionStatus } = useGlobalWebSocket();
@ -214,6 +216,13 @@ export function JobsList() {
case 'status':
comparison = a.status.localeCompare(b.status);
break;
case 'deadline':
{
const da = a.deadline ? parseUTCDate(a.deadline).getTime() : Infinity;
const db = b.deadline ? parseUTCDate(b.deadline).getTime() : Infinity;
comparison = da - db;
}
break;
default:
comparison = 0;
}
@ -435,6 +444,21 @@ export function JobsList() {
const isProduction = user?.role === 'production';
const isPM = user?.role === 'project_manager';
const canManageJobs = isAdmin || isProduction || isPM;
const isOverdue = (job: Job) =>
!!job.deadline &&
isPast(parseUTCDate(job.deadline)) &&
!['completed'].includes(job.status);
const handleCloneJob = async (jobId: string) => {
try {
const cloned = await cloneJobMutation.mutateAsync(jobId);
toast.toastOnly.success('Job cloned successfully');
navigate(`/jobs/${cloned.id}`);
} catch {
toast.toastOnly.error('Failed to clone job');
}
};
const canBulkDelete = canManageJobs && selectedJobs.size > 0;
const canBulkReprocess = canManageJobs && selectedJobs.size > 0;
@ -794,6 +818,7 @@ export function JobsList() {
Languages
</th>
<SortableHeader column="status" label="Status" {...sortProps} />
<SortableHeader column="deadline" label="Deadline" {...sortProps} />
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
@ -852,9 +877,34 @@ export function JobsList() {
<td className="px-4 py-4 whitespace-nowrap">
<StatusBadge status={job.status} />
</td>
{/* Deadline */}
<td className="px-4 py-4 whitespace-nowrap text-sm">
{job.deadline ? (
<span className={isOverdue(job) ? 'text-red-600 font-medium' : 'text-gray-500'}>
{isOverdue(job) && (
<span className="mr-1" title="Overdue"></span>
)}
{format(parseUTCDate(job.deadline), 'dd MMM yyyy')}
</span>
) : (
<span className="text-gray-300"></span>
)}
</td>
{/* Actions */}
<td className="px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
{getActionButton(job)}
<div className="flex items-center justify-end gap-2">
{getActionButton(job)}
{canManageJobs && (
<button
onClick={() => handleCloneJob(job.id)}
disabled={cloneJobMutation.isPending}
title="Clone job"
className="inline-flex items-center px-2 py-1 text-xs border border-gray-300 rounded text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
Clone
</button>
)}
</div>
</td>
</tr>
))}

View file

@ -40,6 +40,7 @@ export function NewJob() {
// Shared state
const [brandContext, setBrandContext] = useState('');
const [deadline, setDeadline] = useState('');
const [selectedClientId, setSelectedClientId] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState('');
const { data: clients = [] } = useClients();
@ -164,6 +165,7 @@ export function NewJob() {
},
brand_context: brandContext.trim() || undefined,
project_id: selectedProjectId || undefined,
deadline: deadline || undefined,
};
try {
@ -254,6 +256,7 @@ export function NewJob() {
},
brandContext: brandContext.trim() || undefined,
projectId: selectedProjectId || undefined,
deadline: deadline || undefined,
});
};
@ -903,6 +906,20 @@ export function NewJob() {
)}
</div>
{/* Deadline */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Deadline <span className="text-gray-400 font-normal">(optional)</span>
</label>
<input
type="date"
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isUploading}
/>
</div>
{/* Brand Context */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">

View file

@ -240,11 +240,15 @@ export interface LanguageQCState {
assigned_reviewer_at?: string;
review_started_at?: string;
reviewer_deadline?: string;
// Reviewer progress
total_cues?: number;
reviewed_cues?: number;
// Outcome
reviewed_at?: string;
reviewed_by_user_id?: string;
reviewed_by_email?: string;
notes?: string;
reject_category?: string;
history: LanguageQCEvent[];
comments: LanguageQCComment[];
}
@ -290,6 +294,7 @@ export interface Job {
project_id?: string;
cost_tracker_project_id?: string;
language_qc?: Record<string, LanguageQCState>;
deadline?: string;
created_at: string;
updated_at: string;
created_by_name?: string;
@ -335,6 +340,7 @@ export interface JobCreateRequest {
requested_outputs: RequestedOutputs;
brand_context?: string;
project_id?: string;
deadline?: string;
}
export interface JobListResponse {