feat(pr6): WS real-time updates, per-cue AD playback, upload guard
W-4: team assignment (linguist/reviewer) stored on job at creation,
auto-assigned to all language QC states on first GET /language-qc
(lazy init via auto_assign_defaults)
L-3 WS: broadcast_to_job when reviewer opens VTT for editing;
QCDetail shows "User X is editing [lang]" banner (auto-clears 5s)
R-5: comment broadcast via broadcast_to_job on add_comment();
QCDetail invalidates comments query on language_qc_comment WS event
L-15: QCDetail subscribes to language_qc_assigned WS event →
refetches lang-qc data and shows toast
R-7: VttEditor gets onCuePlay prop; AD editor in QCDetail wires
handleAdCuePlay → switches to accessible video mode, seeks & plays
T-15: beforeunload warning in NewJob while upload is in progress
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bdfa0f82ab
commit
08fcb4daa4
11 changed files with 276 additions and 13 deletions
|
|
@ -87,6 +87,8 @@ async def create_job(
|
|||
brand_context: str | None = Form(None),
|
||||
project_id: str | None = Form(None),
|
||||
deadline: str | None = Form(None), # ISO date string e.g. "2026-05-15"
|
||||
initial_linguist_id: str | None = Form(None),
|
||||
initial_reviewer_id: str | None = Form(None),
|
||||
request: Request = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
|
|
@ -140,6 +142,8 @@ async def create_job(
|
|||
"brand_context": brand_context or None,
|
||||
"project_id": project_id or None,
|
||||
"deadline": datetime.fromisoformat(deadline) if deadline else None,
|
||||
"initial_linguist_id": initial_linguist_id or None,
|
||||
"initial_reviewer_id": initial_reviewer_id or None,
|
||||
"created_at": datetime.utcnow(),
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
|
|
@ -1274,6 +1278,22 @@ async def get_job_vtt_content(
|
|||
combined = (response.captions_vtt or "") + "|" + (response.audio_description_vtt or "")
|
||||
response.etag = hashlib.sha1(combined.encode()).hexdigest()
|
||||
|
||||
# L-3 WS: broadcast editing-started indicator to other viewers of this job
|
||||
if current_user.role in (UserRole.LINGUIST, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.ADMIN):
|
||||
try:
|
||||
from ...services.websocket import connection_manager
|
||||
await connection_manager.broadcast_to_job(job_id, {
|
||||
"type": "language_editing_started",
|
||||
"job_id": job_id,
|
||||
"data": {
|
||||
"lang": target_language,
|
||||
"user_name": current_user.full_name or current_user.email,
|
||||
"user_id": str(current_user.id),
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ async def get_language_qc(
|
|||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
# Lazy auto-assignment: apply project/job defaults on first open in PENDING_QC
|
||||
await lqc.auto_assign_defaults(db, job_id)
|
||||
states = await lqc.get_all_states(db, job_id)
|
||||
return LanguageQCMapResponse(job_id=job_id, language_qc=states)
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,67 @@ def _deep_link(job_id: str, lang: str) -> str:
|
|||
return f"{base}/admin/qc/{job_id}#lang-{lang}"
|
||||
|
||||
|
||||
# ── Auto-assignment ───────────────────────────────────────────────────────────
|
||||
|
||||
async def auto_assign_defaults(db: AsyncIOMotorDatabase, job_id: str) -> int:
|
||||
"""Apply job.initial_linguist_id / initial_reviewer_id to all unassigned languages.
|
||||
|
||||
Called lazily when the language-QC map is first fetched in PENDING_QC state,
|
||||
so PM assignments made at job-creation time take effect without touching Celery tasks.
|
||||
Returns the number of languages updated.
|
||||
"""
|
||||
job_doc = await db[_JOBS].find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
return 0
|
||||
|
||||
linguist_id: str | None = job_doc.get("initial_linguist_id")
|
||||
reviewer_id: str | None = job_doc.get("initial_reviewer_id")
|
||||
if not linguist_id and not reviewer_id:
|
||||
return 0
|
||||
|
||||
languages: list[str] = (job_doc.get("requested_outputs") or {}).get("languages") or []
|
||||
if not languages:
|
||||
return 0
|
||||
|
||||
linguist_doc = await db.users.find_one({"_id": linguist_id}) if linguist_id else None
|
||||
reviewer_doc = await db.users.find_one({"_id": reviewer_id}) if reviewer_id else None
|
||||
|
||||
now = datetime.utcnow()
|
||||
updated = 0
|
||||
current_qc: dict = job_doc.get("language_qc") or {}
|
||||
|
||||
for lang in languages:
|
||||
lang_state: dict = current_qc.get(lang) or {}
|
||||
already_assigned = bool(lang_state.get("assigned_linguist_id"))
|
||||
if already_assigned:
|
||||
continue
|
||||
|
||||
patch: dict = {}
|
||||
if linguist_doc:
|
||||
patch.update({
|
||||
f"language_qc.{lang}.assigned_linguist_id": linguist_id,
|
||||
f"language_qc.{lang}.assigned_linguist_email": linguist_doc["email"],
|
||||
f"language_qc.{lang}.assigned_linguist_name": linguist_doc.get("full_name", ""),
|
||||
f"language_qc.{lang}.assigned_at": now,
|
||||
f"language_qc.{lang}.assigned_by_user_id": "system",
|
||||
f"language_qc.{lang}.status": lang_state.get("status", LanguageQCStatus.PENDING.value),
|
||||
})
|
||||
if reviewer_doc:
|
||||
patch.update({
|
||||
f"language_qc.{lang}.assigned_reviewer_id": reviewer_id,
|
||||
f"language_qc.{lang}.assigned_reviewer_email": reviewer_doc["email"],
|
||||
f"language_qc.{lang}.assigned_reviewer_name": reviewer_doc.get("full_name", ""),
|
||||
})
|
||||
|
||||
if patch:
|
||||
await db[_JOBS].update_one({"_id": job_id}, {"$set": patch})
|
||||
updated += 1
|
||||
|
||||
if updated:
|
||||
logger.info("auto_assign_defaults: assigned %d languages on job %s", updated, job_id)
|
||||
return updated
|
||||
|
||||
|
||||
# ── Core mutations ────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_state(db: AsyncIOMotorDatabase, job_id: str, lang: str) -> Optional[LanguageQCState]:
|
||||
|
|
@ -826,7 +887,22 @@ async def add_comment(
|
|||
details={"lang": lang},
|
||||
)
|
||||
|
||||
# Fan-out to all other assignees
|
||||
# WS broadcast — live comment indicator for everyone on this job
|
||||
try:
|
||||
await connection_manager.broadcast_to_job(job_id, {
|
||||
"type": "language_qc_comment",
|
||||
"job_id": job_id,
|
||||
"lang": lang,
|
||||
"data": {
|
||||
"author_name": actor.full_name or actor.email,
|
||||
"lang": lang,
|
||||
"comment_id": comment.id,
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fan-out email to all other assignees
|
||||
recipients = _qc_recipients(job_doc, current_state_raw if isinstance(current_state_raw, dict) else {}, exclude_user_id=actor.email)
|
||||
if recipients:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -386,6 +386,20 @@ class ConnectionManager:
|
|||
logger.warning(f"WebSocket send failed: {e}")
|
||||
raise
|
||||
|
||||
async def broadcast_to_job(self, job_id: str, message: dict[str, Any]) -> None:
|
||||
"""Broadcast an arbitrary message to all WS clients subscribed to a job."""
|
||||
async with self.lock:
|
||||
sockets = list(self.job_ws.get(job_id, set()))
|
||||
if sockets:
|
||||
await self._send_to_websockets(sockets, message)
|
||||
|
||||
async def broadcast_to_user(self, user_id: str, message: dict[str, Any]) -> None:
|
||||
"""Broadcast an arbitrary message to all WS connections for a specific user."""
|
||||
async with self.lock:
|
||||
sockets = list(self.user_ws.get(user_id, set()))
|
||||
if sockets:
|
||||
await self._send_to_websockets(sockets, message)
|
||||
|
||||
|
||||
# Global connection manager instance
|
||||
connection_manager = ConnectionManager()
|
||||
|
|
|
|||
|
|
@ -85,13 +85,14 @@ interface VttEditorProps {
|
|||
onCueSave?: (cueIndex: number, vttContent: string) => Promise<void>;
|
||||
onCueInserted?: (insertedIndex: number, totalCues: number) => void;
|
||||
onCueDeleted?: (deletedIndex: number, totalCuesAfterDelete: number) => void;
|
||||
onCuePlay?: (startTime: number) => void;
|
||||
title: string;
|
||||
readOnly?: boolean;
|
||||
glossaryTerms?: GlossaryTerm[];
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, title, readOnly = false, glossaryTerms = [], language = 'en' }: VttEditorProps) {
|
||||
export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCueDeleted, onCuePlay, title, readOnly = false, glossaryTerms = [], language = 'en' }: VttEditorProps) {
|
||||
const [cues, setCues] = useState<VTTCue[]>([]);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [editingCue, setEditingCue] = useState<number | null>(null);
|
||||
|
|
@ -315,6 +316,16 @@ export function VttEditor({ vttContent, onChange, onCueSave, onCueInserted, onCu
|
|||
Saving...
|
||||
</span>
|
||||
)}
|
||||
{/* Play-from-cue button (R-7) */}
|
||||
{onCuePlay && hoveredCue === index && (
|
||||
<button
|
||||
onClick={() => onCuePlay(cue.startTime)}
|
||||
className="ml-1 p-1 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded transition-colors"
|
||||
title="Play from this cue"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ export interface JobStatusUpdate {
|
|||
}
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: 'connection_established' | 'job_status_update' | 'job_list_update';
|
||||
data?: JobStatusUpdate;
|
||||
type: 'connection_established' | 'job_status_update' | 'job_list_update'
|
||||
| 'language_qc_comment' | 'language_editing_started' | 'language_qc_assigned';
|
||||
data?: JobStatusUpdate & Record<string, unknown>;
|
||||
job_id?: string;
|
||||
lang?: string;
|
||||
scope?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
|
@ -65,6 +67,11 @@ interface UseJobStatusWebSocketOptions {
|
|||
* Toast handler for connection status changes
|
||||
*/
|
||||
onConnectionChange?: (status: ConnectionStatus) => void;
|
||||
|
||||
/**
|
||||
* Raw message handler — called for every parsed WS message regardless of type
|
||||
*/
|
||||
onRawMessage?: (msg: WebSocketMessage) => void;
|
||||
}
|
||||
|
||||
interface UseJobStatusWebSocketReturn {
|
||||
|
|
@ -103,7 +110,8 @@ export function useJobStatusWebSocket(
|
|||
reconnectDelay = 1000,
|
||||
debug = false,
|
||||
onStatusUpdate,
|
||||
onConnectionChange
|
||||
onConnectionChange,
|
||||
onRawMessage,
|
||||
} = options;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -228,18 +236,19 @@ export function useJobStatusWebSocket(
|
|||
log('Received message:', message);
|
||||
|
||||
setLastMessage(message);
|
||||
|
||||
onRawMessage?.(message);
|
||||
|
||||
if (message.type === 'job_status_update' || message.type === 'job_list_update') {
|
||||
if (message.data) {
|
||||
setLastUpdate(message.data);
|
||||
updateQueryCache(message.data);
|
||||
handleStatusUpdate(message.data);
|
||||
setLastUpdate(message.data as JobStatusUpdate);
|
||||
updateQueryCache(message.data as JobStatusUpdate);
|
||||
handleStatusUpdate(message.data as JobStatusUpdate);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Failed to parse WebSocket message:', error, 'Raw data:', event.data);
|
||||
}
|
||||
}, [log, updateQueryCache, handleStatusUpdate]);
|
||||
}, [log, onRawMessage, updateQueryCache, handleStatusUpdate]);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
console.log('[WebSocket] Connected successfully!');
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export interface SharedJobSettings {
|
|||
brandContext?: string;
|
||||
projectId?: string;
|
||||
deadline?: string;
|
||||
initialLinguistId?: string;
|
||||
initialReviewerId?: string;
|
||||
}
|
||||
|
||||
interface UseMultiUploadOptions {
|
||||
|
|
@ -112,6 +114,8 @@ export function useMultiUpload(options: UseMultiUploadOptions = {}): UseMultiUpl
|
|||
brand_context: settings.brandContext,
|
||||
project_id: settings.projectId,
|
||||
deadline: settings.deadline,
|
||||
initial_linguist_id: settings.initialLinguistId,
|
||||
initial_reviewer_id: settings.initialReviewerId,
|
||||
},
|
||||
item.file,
|
||||
(progressEvent) => {
|
||||
|
|
|
|||
|
|
@ -202,6 +202,12 @@ class ApiClient {
|
|||
if (data.deadline) {
|
||||
formData.append('deadline', data.deadline);
|
||||
}
|
||||
if (data.initial_linguist_id) {
|
||||
formData.append('initial_linguist_id', data.initial_linguist_id);
|
||||
}
|
||||
if (data.initial_reviewer_id) {
|
||||
formData.append('initial_reviewer_id', data.initial_reviewer_id);
|
||||
}
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await this.client.post('/jobs', formData, {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useJob, useApproveEnglish, useRejectJob, useJobVttContent, useUpdateJobVtt, useJobDownloads, useAdjustVttTiming, useUpdateTTSPreferences, useUpdateJob } from '../../hooks/useJob';
|
||||
|
|
@ -20,6 +20,8 @@ import { useToastContext } from '../../contexts/ToastContext';
|
|||
import { useAuthStore } from '../../lib/auth';
|
||||
import { apiClient } from '../../lib/api';
|
||||
import type { TTSPreferences, VideoSegmentMetadata, PausePointData, LanguageQCStatus, LanguageQCComment } from '../../types/api';
|
||||
import { useJobStatusWebSocket } from '../../hooks/useJobStatusWebSocket';
|
||||
import type { WebSocketMessage } from '../../hooks/useJobStatusWebSocket';
|
||||
|
||||
// ── Status display helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -339,6 +341,35 @@ export function QCDetail() {
|
|||
|
||||
// Refresh edit state when render completes (rendering_qc → pending_qc transition)
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Editing indicator (L-3 WS): "User X is editing [lang]" banner, auto-clears after 5s
|
||||
const [editingIndicator, setEditingIndicator] = useState<{ lang: string; userName: string } | null>(null);
|
||||
const editingIndicatorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleRawWsMessage = useCallback((msg: WebSocketMessage) => {
|
||||
if (msg.type === 'language_qc_comment' && msg.lang) {
|
||||
queryClient.invalidateQueries({ queryKey: ['qc-comments', id, msg.lang] });
|
||||
} else if (msg.type === 'language_editing_started' && msg.data) {
|
||||
const lang = (msg.data as { lang?: string }).lang ?? '';
|
||||
const userName = (msg.data as { user_name?: string }).user_name ?? 'Someone';
|
||||
if (editingIndicatorTimerRef.current) clearTimeout(editingIndicatorTimerRef.current);
|
||||
setEditingIndicator({ lang, userName });
|
||||
editingIndicatorTimerRef.current = setTimeout(() => setEditingIndicator(null), 5000);
|
||||
} else if (msg.type === 'language_qc_assigned') {
|
||||
refetchLangQc();
|
||||
queryClient.invalidateQueries({ queryKey: ['language-qc', id] });
|
||||
toast.toastOnly.success('Language QC assignment updated');
|
||||
}
|
||||
}, [id, queryClient, refetchLangQc, toast.toastOnly]);
|
||||
|
||||
useJobStatusWebSocket(id, { onRawMessage: handleRawWsMessage });
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (editingIndicatorTimerRef.current) clearTimeout(editingIndicatorTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevJobStatusRef.current === 'rendering_qc' &&
|
||||
|
|
@ -712,6 +743,16 @@ export function QCDetail() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleAdCuePlay = useCallback((startTime: number) => {
|
||||
setVideoMode('accessible');
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = startTime;
|
||||
videoRef.current.play().catch(() => { /* autoplay may be blocked */ });
|
||||
}
|
||||
}, 80);
|
||||
}, []);
|
||||
|
||||
const handlePausePointUpdate = async (cueIndex: number, adjustedMs: number) => {
|
||||
if (!id) return;
|
||||
|
||||
|
|
@ -869,6 +910,23 @@ export function QCDetail() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Editing indicator banner (L-3 WS) */}
|
||||
{editingIndicator && (
|
||||
<div className="mb-4 flex items-center justify-between p-3 bg-yellow-50 border border-yellow-300 rounded-md text-yellow-800">
|
||||
<span className="text-sm">
|
||||
<span className="font-medium">{editingIndicator.userName}</span> is editing{' '}
|
||||
<span className="font-mono">{editingIndicator.lang.toUpperCase()}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditingIndicator(null)}
|
||||
className="ml-4 text-yellow-600 hover:text-yellow-800 font-bold"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Confidence Score */}
|
||||
{job.ai?.confidence && (
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
|
|
@ -1612,6 +1670,7 @@ export function QCDetail() {
|
|||
onCueSave={handleAdCueSave}
|
||||
onCueInserted={handleAdCueInserted}
|
||||
onCueDeleted={handleAdCueDeleted}
|
||||
onCuePlay={accessibleVideoUrl ? handleAdCuePlay : undefined}
|
||||
title={`Audio Description (${selectedLanguage.toUpperCase()})`}
|
||||
readOnly={isProcessing}
|
||||
glossaryTerms={glossaryTerms}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
|
@ -57,6 +57,8 @@ export function NewJob() {
|
|||
const { data: reviewerUsersData } = useUsers({ role: 'reviewer', active_only: true });
|
||||
const linguistUsers = linguistUsersData?.users ?? [];
|
||||
const reviewerUsers = reviewerUsersData?.users ?? [];
|
||||
const [teamLinguistId, setTeamLinguistId] = useState('');
|
||||
const [teamReviewerId, setTeamReviewerId] = useState('');
|
||||
const [showVoiceSettings, setShowVoiceSettings] = useState(false);
|
||||
const [ttsPreferences, setTtsPreferences] = useState<TTSPreferences>({
|
||||
provider: 'gemini',
|
||||
|
|
@ -80,6 +82,17 @@ export function NewJob() {
|
|||
const isMultiMode = totalSelectedFiles >= 2;
|
||||
const isUploading = createJobMutation.isPending || multiUpload.isUploading;
|
||||
|
||||
// Warn before tab close/navigate away while upload is in progress (T-15)
|
||||
useEffect(() => {
|
||||
if (!isUploading) return;
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [isUploading]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
|
|
@ -166,6 +179,8 @@ export function NewJob() {
|
|||
brand_context: brandContext.trim() || undefined,
|
||||
project_id: selectedProjectId || undefined,
|
||||
deadline: deadline || undefined,
|
||||
initial_linguist_id: teamLinguistId || undefined,
|
||||
initial_reviewer_id: teamReviewerId || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -257,6 +272,8 @@ export function NewJob() {
|
|||
brandContext: brandContext.trim() || undefined,
|
||||
projectId: selectedProjectId || undefined,
|
||||
deadline: deadline || undefined,
|
||||
initialLinguistId: teamLinguistId || undefined,
|
||||
initialReviewerId: teamReviewerId || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -337,6 +354,8 @@ export function NewJob() {
|
|||
},
|
||||
brandContext: brandContext.trim() || undefined,
|
||||
projectId: selectedProjectId || undefined,
|
||||
initialLinguistId: teamLinguistId || undefined,
|
||||
initialReviewerId: teamReviewerId || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -716,6 +735,45 @@ export function NewJob() {
|
|||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{/* Team Assignment — auto-filled from project defaults, editable per-job */}
|
||||
{selectedProjectId && (
|
||||
<div className="border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Team Assignment
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">(applied to all languages when job enters QC)</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Linguist</label>
|
||||
<select
|
||||
value={teamLinguistId}
|
||||
onChange={e => setTeamLinguistId(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{linguistUsers.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.full_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Reviewer</label>
|
||||
<select
|
||||
value={teamReviewerId}
|
||||
onChange={e => setTeamReviewerId(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{reviewerUsers.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.full_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Translation Mode - Only shown when target languages are selected */}
|
||||
{languages.length > 0 && (
|
||||
<div>
|
||||
|
|
@ -801,12 +859,14 @@ export function NewJob() {
|
|||
} else {
|
||||
setSelectedProjectId(value);
|
||||
setShowCreateProject(false);
|
||||
// W-5: autofill job languages from project defaults
|
||||
// W-5: autofill job languages + team from project defaults
|
||||
if (value) {
|
||||
const proj = projects.find(p => p.id === value);
|
||||
if (proj?.default_languages?.length) {
|
||||
setValue('languages', proj.default_languages);
|
||||
}
|
||||
setTeamLinguistId(proj?.default_linguist_id ?? '');
|
||||
setTeamReviewerId(proj?.default_reviewer_id ?? '');
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -341,6 +341,8 @@ export interface JobCreateRequest {
|
|||
brand_context?: string;
|
||||
project_id?: string;
|
||||
deadline?: string;
|
||||
initial_linguist_id?: string;
|
||||
initial_reviewer_id?: string;
|
||||
}
|
||||
|
||||
export interface JobListResponse {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue