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:
Vadym Samoilenko 2026-04-29 18:53:14 +01:00
parent 1bf0fb9eed
commit f1a9e6ee46
3 changed files with 212 additions and 0 deletions

View file

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

View file

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

View file

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