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:
parent
460c6ce091
commit
13db347d65
11 changed files with 261 additions and 24 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue