feat(pm7): bulk assign linguist/reviewer to all languages in one click
- POST /jobs/{job_id}/languages/bulk-assign — assigns linguist (required) and
reviewer (optional) across all or selected languages; supports only_unassigned
flag and optional deadline
- bulkAssignLanguages() added to API client
- QCDetail: "Assign all languages" button in Languages header; opens modal with
linguist/reviewer dropdowns, deadline, and skip-already-assigned checkbox
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1bf0fb9eed
commit
f1a9e6ee46
3 changed files with 212 additions and 0 deletions
|
|
@ -84,6 +84,20 @@ class QueueResponse(BaseModel):
|
|||
total: int
|
||||
|
||||
|
||||
class BulkAssignRequest(BaseModel):
|
||||
linguist_user_id: str
|
||||
reviewer_user_id: Optional[str] = None
|
||||
languages: Optional[list[str]] = None # None = all available languages
|
||||
only_unassigned: bool = False # skip languages that already have an assignment
|
||||
deadline: Optional[datetime] = None
|
||||
|
||||
|
||||
class BulkAssignResponse(BaseModel):
|
||||
assigned: list[str]
|
||||
skipped: list[str]
|
||||
errors: dict[str, str]
|
||||
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/jobs/{job_id}/language-qc", response_model=LanguageQCMapResponse)
|
||||
|
|
@ -175,6 +189,68 @@ async def reassign_reviewer(
|
|||
return LanguageQCStateResponse(lang=lang, state=state)
|
||||
|
||||
|
||||
# ── Bulk assignment ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/jobs/{job_id}/languages/bulk-assign", response_model=BulkAssignResponse)
|
||||
async def bulk_assign_languages(
|
||||
job_id: str,
|
||||
request: BulkAssignRequest,
|
||||
http_request: Request,
|
||||
current_user: User = Depends(require_roles(
|
||||
UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN,
|
||||
)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Assign one linguist (and optionally one reviewer) to multiple languages in one call."""
|
||||
job_doc = await db["jobs"].find_one({"_id": job_id})
|
||||
if not job_doc:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
available = list((job_doc.get("outputs") or {}).keys())
|
||||
target_langs = request.languages if request.languages else available
|
||||
|
||||
assigned: list[str] = []
|
||||
skipped: list[str] = []
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
language_qc = job_doc.get("language_qc") or {}
|
||||
|
||||
for lang in target_langs:
|
||||
if lang not in available:
|
||||
skipped.append(lang)
|
||||
continue
|
||||
|
||||
lang_state = language_qc.get(lang) or {}
|
||||
already_assigned = bool(lang_state.get("assigned_linguist_id"))
|
||||
|
||||
if request.only_unassigned and already_assigned:
|
||||
skipped.append(lang)
|
||||
continue
|
||||
|
||||
try:
|
||||
await lqc.assign_linguist(
|
||||
db, job_id, lang, request.linguist_user_id, current_user,
|
||||
http_request=http_request, deadline=request.deadline,
|
||||
)
|
||||
except Exception as exc:
|
||||
errors[lang] = str(exc)
|
||||
continue
|
||||
|
||||
if request.reviewer_user_id:
|
||||
try:
|
||||
await lqc.assign_reviewer(
|
||||
db, job_id, lang, request.reviewer_user_id, current_user,
|
||||
http_request=http_request, deadline=request.deadline,
|
||||
)
|
||||
except Exception as exc:
|
||||
errors[f"{lang}:reviewer"] = str(exc)
|
||||
|
||||
assigned.append(lang)
|
||||
|
||||
return BulkAssignResponse(assigned=assigned, skipped=skipped, errors=errors)
|
||||
|
||||
|
||||
# ── Workflow transitions ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/jobs/{job_id}/languages/{lang}/start-work", response_model=LanguageQCStateResponse)
|
||||
|
|
|
|||
|
|
@ -788,6 +788,17 @@ class ApiClient {
|
|||
return r.data;
|
||||
}
|
||||
|
||||
async bulkAssignLanguages(jobId: string, payload: {
|
||||
linguist_user_id: string;
|
||||
reviewer_user_id?: string;
|
||||
languages?: string[];
|
||||
only_unassigned?: boolean;
|
||||
deadline?: string;
|
||||
}): Promise<{ assigned: string[]; skipped: string[]; errors: Record<string, string> }> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/languages/bulk-assign`, payload);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async reopenLanguageQC(jobId: string, lang: string, notes?: string): Promise<import('../types/api').LanguageQCStateResponse> {
|
||||
const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/reopen`, { notes });
|
||||
return r.data;
|
||||
|
|
|
|||
|
|
@ -120,6 +120,13 @@ export function QCDetail() {
|
|||
const [assigningUserId, setAssigningUserId] = useState('');
|
||||
const [assignDeadline, setAssignDeadline] = useState('');
|
||||
|
||||
// Bulk assign modal state
|
||||
const [showBulkAssignModal, setShowBulkAssignModal] = useState(false);
|
||||
const [bulkLinguistId, setBulkLinguistId] = useState('');
|
||||
const [bulkReviewerId, setBulkReviewerId] = useState('');
|
||||
const [bulkOnlyUnassigned, setBulkOnlyUnassigned] = useState(true);
|
||||
const [bulkDeadline, setBulkDeadline] = useState('');
|
||||
|
||||
// Comments panel per language
|
||||
const [openCommentLang, setOpenCommentLang] = useState<string | null>(null);
|
||||
const [commentDraft, setCommentDraft] = useState('');
|
||||
|
|
@ -135,6 +142,19 @@ export function QCDetail() {
|
|||
});
|
||||
const assignableUsers = assignableUsersData?.users ?? [];
|
||||
|
||||
const { data: bulkLinguistsData } = useQuery({
|
||||
queryKey: ['users-list', 'linguist'],
|
||||
queryFn: () => apiClient.listUsers({ role: 'linguist', active_only: true, size: 100 }),
|
||||
enabled: showBulkAssignModal,
|
||||
});
|
||||
const { data: bulkReviewersData } = useQuery({
|
||||
queryKey: ['users-list', 'reviewer'],
|
||||
queryFn: () => apiClient.listUsers({ role: 'reviewer', active_only: true, size: 100 }),
|
||||
enabled: showBulkAssignModal,
|
||||
});
|
||||
const bulkLinguists = bulkLinguistsData?.users ?? [];
|
||||
const bulkReviewers = bulkReviewersData?.users ?? [];
|
||||
|
||||
const approveLanguageMutation = useMutation({
|
||||
mutationFn: ({ lang, notes }: { lang: string; notes?: string }) =>
|
||||
apiClient.approveLanguageQC(id!, lang, notes),
|
||||
|
|
@ -178,6 +198,30 @@ export function QCDetail() {
|
|||
onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Assignment failed'),
|
||||
});
|
||||
|
||||
const bulkAssignMutation = useMutation({
|
||||
mutationFn: () => apiClient.bulkAssignLanguages(id!, {
|
||||
linguist_user_id: bulkLinguistId,
|
||||
reviewer_user_id: bulkReviewerId || undefined,
|
||||
only_unassigned: bulkOnlyUnassigned,
|
||||
deadline: bulkDeadline || undefined,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['language-qc', id] });
|
||||
refetchLangQc();
|
||||
setShowBulkAssignModal(false);
|
||||
setBulkLinguistId('');
|
||||
setBulkReviewerId('');
|
||||
setBulkDeadline('');
|
||||
const errCount = Object.keys(result.errors).length;
|
||||
if (errCount > 0) {
|
||||
toast.toastOnly.error(`Assigned ${result.assigned.length} language(s); ${errCount} error(s)`);
|
||||
} else {
|
||||
toast.toastOnly.success(`Assigned ${result.assigned.length} language(s)${result.skipped.length ? `, skipped ${result.skipped.length}` : ''}`);
|
||||
}
|
||||
},
|
||||
onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Bulk assignment failed'),
|
||||
});
|
||||
|
||||
const submitForReviewMutation = useMutation({
|
||||
mutationFn: (lang: string) => apiClient.submitForReview(id!, lang),
|
||||
onSuccess: () => {
|
||||
|
|
@ -793,6 +837,14 @@ export function QCDetail() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canAssign && totalLangs > 1 && (
|
||||
<button
|
||||
onClick={() => { setBulkLinguistId(''); setBulkReviewerId(''); setBulkDeadline(''); setBulkOnlyUnassigned(true); setShowBulkAssignModal(true); }}
|
||||
className="text-xs px-3 py-1.5 bg-indigo-50 text-indigo-700 border border-indigo-200 rounded-lg hover:bg-indigo-100"
|
||||
>
|
||||
Assign all languages
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
|
|
@ -1124,6 +1176,79 @@ export function QCDetail() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk assign modal */}
|
||||
{showBulkAssignModal && (
|
||||
<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 space-y-4">
|
||||
<h3 className="text-lg font-medium">Assign all languages</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Linguist <span className="text-red-500">*</span></label>
|
||||
<select
|
||||
value={bulkLinguistId}
|
||||
onChange={e => setBulkLinguistId(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5"
|
||||
>
|
||||
<option value="">Select linguist…</option>
|
||||
{bulkLinguists.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.full_name} ({u.email})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Reviewer (optional)</label>
|
||||
<select
|
||||
value={bulkReviewerId}
|
||||
onChange={e => setBulkReviewerId(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{bulkReviewers.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.full_name} ({u.email})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Deadline (optional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={bulkDeadline}
|
||||
onChange={e => setBulkDeadline(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkOnlyUnassigned}
|
||||
onChange={e => setBulkOnlyUnassigned(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Skip languages that already have a linguist assigned
|
||||
</label>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowBulkAssignModal(false)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => bulkAssignMutation.mutate()}
|
||||
disabled={!bulkLinguistId || bulkAssignMutation.isPending}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{bulkAssignMutation.isPending ? 'Assigning…' : `Assign ${availableLanguages.length} language(s)`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rendering Status Banner */}
|
||||
{isRendering && (
|
||||
<div className="mb-6 p-4 bg-purple-50 border border-purple-200 rounded-md">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue