From efa23955275ff9d25aae8d34b03fb5ee625dae31 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 30 Apr 2026 17:52:43 +0100 Subject: [PATCH] feat: inline title rename in JobDetail and QCDetail Click the pencil icon next to the job title to rename it inline. Enter saves, Escape or blur cancels. Available for admin/production/PM. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/admin/QCDetail.tsx | 41 ++++++++++++++++++++- frontend/src/routes/jobs/JobDetail.tsx | 50 +++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index ea6ff69..b4d253e 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -308,6 +308,8 @@ export function QCDetail() { const [showDownloads, setShowDownloads] = useState(false); const [showRetranslateDialog, setShowRetranslateDialog] = useState(false); const [pendingVttSave, setPendingVttSave] = useState<{ captions?: string; ad?: string } | null>(null); + const [editingTitle, setEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(''); const [adVttUploaded, setAdVttUploaded] = useState(false); const [renderJustCompleted, setRenderJustCompleted] = useState(false); const captionsFileInputRef = useRef(null); @@ -532,6 +534,18 @@ export function QCDetail() { }; // Cmd+S full-VTT save (hotkey handler) + const handleTitleSave = async () => { + if (!id || !titleDraft.trim() || titleDraft.trim() === job?.title) { setEditingTitle(false); return; } + try { + await updateJobMutation.mutateAsync({ id, data: { title: titleDraft.trim() } as never }); + toast.toastOnly.success('Renamed'); + } catch { + toast.toastOnly.error('Failed to rename'); + } finally { + setEditingTitle(false); + } + }; + const _doSaveVtt = async (retranslate: boolean, captions?: string, ad?: string) => { if (!id) return; try { @@ -890,7 +904,32 @@ export function QCDetail() { {/* Header */}
-

{job.title}

+
+ {editingTitle ? ( + setTitleDraft(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleTitleSave(); if (e.key === 'Escape') setEditingTitle(false); }} + onBlur={handleTitleSave} + className="text-3xl font-bold text-gray-900 border-b-2 border-indigo-500 focus:outline-none bg-transparent w-full" + autoFocus + /> + ) : ( +

{job.title}

+ )} + {canAssign && !editingTitle && ( + + )} +
Source: {job.source.filename} diff --git a/frontend/src/routes/jobs/JobDetail.tsx b/frontend/src/routes/jobs/JobDetail.tsx index ddfd20c..1470215 100644 --- a/frontend/src/routes/jobs/JobDetail.tsx +++ b/frontend/src/routes/jobs/JobDetail.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useParams, Link } from 'react-router-dom'; import { formatDistanceToNow } from 'date-fns'; -import { useJob, useJobDownloads, useJobVttContent, useRetryJob, useReturnToQC } from '../../hooks/useJob'; +import { useJob, useJobDownloads, useJobVttContent, useRetryJob, useReturnToQC, useUpdateJob } from '../../hooks/useJob'; import { useAuthStore } from '../../lib/auth'; import type { JobFailure } from '../../types/api'; import { StatusBadge } from '../../components/StatusBadge'; @@ -136,7 +136,10 @@ export function JobDetail() { // Retry mutations const retryJobMutation = useRetryJob(); + const updateJobMutation = useUpdateJob(); const toast = useToastContext(); + const [editingTitle, setEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(''); // Return to QC const { user } = useAuthStore(); @@ -148,6 +151,20 @@ export function JobDetail() { (user.role === 'production' || user.role === 'admin') && job?.status && RETURN_TO_QC_ELIGIBLE_STATUSES.includes(job.status); + const canEditTitle = user && ['admin', 'production', 'project_manager'].includes(user.role || ''); + + const handleTitleSave = async () => { + if (!id || !titleDraft.trim() || titleDraft.trim() === job?.title) { setEditingTitle(false); return; } + try { + await updateJobMutation.mutateAsync({ id, data: { title: titleDraft.trim() } as never }); + toast.toastOnly.success('Renamed'); + } catch { + toast.toastOnly.error('Failed to rename'); + } finally { + setEditingTitle(false); + } + }; + const handleReturnToQC = async () => { if (!id || !returnToQCNotes.trim()) return; try { @@ -436,11 +453,34 @@ export function JobDetail() {
-

- {job.title} -

+
+ {editingTitle ? ( + setTitleDraft(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleTitleSave(); if (e.key === 'Escape') setEditingTitle(false); }} + onBlur={handleTitleSave} + className="text-3xl font-bold text-gray-900 border-b-2 border-indigo-500 focus:outline-none bg-transparent w-full" + autoFocus + /> + ) : ( +

{job.title}

+ )} + {canEditTitle && !editingTitle && ( + + )} +

- {job.source?.original_filename} • + {job.source?.original_filename} • Created {formatDistanceToNow(new Date(job.created_at))} ago