From fe608401bea0bab5bcf7449260277dfe3882a549 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Wed, 29 Apr 2026 20:41:49 +0100 Subject: [PATCH] =?UTF-8?q?feat(w-12):=20brief=20workflow=20UI=20=E2=80=94?= =?UTF-8?q?=20list,=20create,=20detail,=20NewJob=20pre-fill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BriefsList.tsx: table with status badge, submitted badge count - NewBrief.tsx: form with title, description, outputs, language picker, deadline, project selector; calls POST /briefs - BriefDetail.tsx: status actions — Submit (DRAFT), Approve (SUBMITTED, admin/PM), Create Job link (?brief_id=) for APPROVED briefs - NewJob.tsx: reads ?brief_id, fetches brief via useBrief, pre-fills languages/outputs/deadline/project_id; sends brief_id in FormData - Sidebar: Briefs link (client/production/admin/PM) with submitted-count badge from useBriefs() - JobCreateRequest type: brief_id optional field - briefs API methods: listBriefs, createBrief, getBrief, submitBrief, approveBrief; hooks: useBriefs, useBrief, useCreateBrief, useSubmitBrief, useApproveBrief Co-Authored-By: Claude Opus 4.7 --- frontend/src/App.tsx | 18 +++ frontend/src/components/Layout/Sidebar.tsx | 12 +- frontend/src/hooks/useJob.ts | 46 ++++++ frontend/src/lib/api.ts | 28 ++++ frontend/src/routes/briefs/BriefDetail.tsx | 155 +++++++++++++++++++ frontend/src/routes/briefs/BriefsList.tsx | 104 +++++++++++++ frontend/src/routes/briefs/NewBrief.tsx | 167 +++++++++++++++++++++ frontend/src/routes/jobs/NewJob.tsx | 25 ++- frontend/src/types/api.ts | 30 ++++ 9 files changed, 582 insertions(+), 3 deletions(-) create mode 100644 frontend/src/routes/briefs/BriefDetail.tsx create mode 100644 frontend/src/routes/briefs/BriefsList.tsx create mode 100644 frontend/src/routes/briefs/NewBrief.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0765b4e..23f943d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,9 @@ import { GlossaryUpload } from './routes/admin/glossaries/GlossaryUpload'; import { GlossaryDetail } from './routes/admin/glossaries/GlossaryDetail'; import { AuditLog } from './routes/admin/AuditLog'; import { FailuresList } from './routes/admin/FailuresList'; +import { BriefsList } from './routes/briefs/BriefsList'; +import { NewBrief } from './routes/briefs/NewBrief'; +import { BriefDetail } from './routes/briefs/BriefDetail'; import { LinguistQueue } from './routes/jobs/LinguistQueue'; import { Downloads } from './routes/Downloads'; import { ShareView } from './routes/ShareView'; @@ -190,6 +193,21 @@ function AppContent() { } /> + + + + } /> + + + + } /> + + + + } /> diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index f303728..373b221 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { Link, useLocation, useParams } from 'react-router-dom'; import { useAuthStore } from '../../lib/auth'; import { useMyMemberships } from '../../hooks/useClients'; -import { useJobs } from '../../hooks/useJob'; +import { useJobs, useBriefs } from '../../hooks/useJob'; interface SidebarItem { label: string; @@ -38,9 +38,12 @@ export function Sidebar({ onMobileClose }: SidebarProps) { { enabled: isAdminOrProduction } ); + const { data: allBriefs = [] } = useBriefs(); + const qcBadge = isQCRole ? (qcData?.total || 0) : 0; const finalBadge = isPMOrAdmin ? (finalData?.total || 0) : 0; const failuresBadge = isAdminOrProduction ? (failuresData?.total || 0) : 0; + const briefsBadge = allBriefs.filter(b => b.status === 'submitted').length; // Determine current org from route params or first membership const currentOrgSlug = @@ -96,6 +99,13 @@ export function Sidebar({ onMobileClose }: SidebarProps) { icon: '🏢', roles: ['admin', 'project_manager'], }, + { + label: 'Briefs', + href: '/briefs', + icon: '📄', + roles: ['client', 'production', 'admin', 'project_manager'], + badge: briefsBadge || undefined, + }, { label: 'Failures', href: '/admin/failures', diff --git a/frontend/src/hooks/useJob.ts b/frontend/src/hooks/useJob.ts index 5157837..179646a 100644 --- a/frontend/src/hooks/useJob.ts +++ b/frontend/src/hooks/useJob.ts @@ -335,6 +335,52 @@ export function useBulkReturnToQC() { }); } +export function useBriefs() { + return useQuery({ + queryKey: ['briefs'], + queryFn: () => apiClient.listBriefs(), + refetchInterval: 60_000, + }); +} + +export function useBrief(id: string) { + return useQuery({ + queryKey: ['briefs', id], + queryFn: () => apiClient.getBrief(id), + enabled: !!id, + }); +} + +export function useCreateBrief() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: import('../types/api').JobBriefCreate) => apiClient.createBrief(data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['briefs'] }), + }); +} + +export function useSubmitBrief() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiClient.submitBrief(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: ['briefs', id] }); + queryClient.invalidateQueries({ queryKey: ['briefs'] }); + }, + }); +} + +export function useApproveBrief() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiClient.approveBrief(id), + onSuccess: (_, id) => { + queryClient.invalidateQueries({ queryKey: ['briefs', id] }); + queryClient.invalidateQueries({ queryKey: ['briefs'] }); + }, + }); +} + export function useFailures(filters?: { step?: string; org_id?: string; limit?: number; skip?: number }) { return useQuery({ queryKey: ['failures', filters], diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 64b5358..5aff5d4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -208,6 +208,9 @@ class ApiClient { if (data.initial_reviewer_id) { formData.append('initial_reviewer_id', data.initial_reviewer_id); } + if (data.brief_id) { + formData.append('brief_id', data.brief_id); + } formData.append('file', file); const response = await this.client.post('/jobs', formData, { @@ -377,6 +380,31 @@ class ApiClient { return response.data as { retried: string[]; skipped: string[]; errors: Array<{ job_id: string; error: string }> }; } + async listBriefs(): Promise { + const response = await this.client.get('/briefs'); + return response.data; + } + + async createBrief(data: import('../types/api').JobBriefCreate): Promise { + const response = await this.client.post('/briefs', data); + return response.data; + } + + async getBrief(id: string): Promise { + const response = await this.client.get(`/briefs/${id}`); + return response.data; + } + + async submitBrief(id: string): Promise { + const response = await this.client.post(`/briefs/${id}/submit`); + return response.data; + } + + async approveBrief(id: string): Promise { + const response = await this.client.post(`/briefs/${id}/approve`); + return response.data; + } + // User Management endpoints async listUsers(filters?: { page?: number; diff --git a/frontend/src/routes/briefs/BriefDetail.tsx b/frontend/src/routes/briefs/BriefDetail.tsx new file mode 100644 index 0000000..90d7b90 --- /dev/null +++ b/frontend/src/routes/briefs/BriefDetail.tsx @@ -0,0 +1,155 @@ +import { useParams, Link, useNavigate } from 'react-router-dom'; +import { useBrief, useSubmitBrief, useApproveBrief } from '../../hooks/useJob'; +import { useAuthStore } from '../../lib/auth'; +import { useToastContext } from '../../contexts/ToastContext'; + +const STATUS_BADGE: Record = { + draft: 'bg-gray-100 text-gray-700', + submitted: 'bg-blue-100 text-blue-700', + approved: 'bg-green-100 text-green-700', + rejected: 'bg-red-100 text-red-700', + fulfilled: 'bg-purple-100 text-purple-700', +}; + +export function BriefDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { user } = useAuthStore(); + const toast = useToastContext(); + + const { data: brief, isLoading, error } = useBrief(id!); + const submitMutation = useSubmitBrief(); + const approveMutation = useApproveBrief(); + + const canSubmit = brief?.status === 'draft'; + const canApprove = brief?.status === 'submitted' && ['admin', 'project_manager'].includes(user?.role || ''); + const canCreateJob = brief?.status === 'approved'; + + const handleSubmit = async () => { + if (!id) return; + try { + await submitMutation.mutateAsync(id); + toast.toastOnly.success('Brief submitted for approval'); + } catch { + toast.toastOnly.error('Failed to submit brief'); + } + }; + + const handleApprove = async () => { + if (!id) return; + try { + await approveMutation.mutateAsync(id); + toast.toastOnly.success('Brief approved'); + } catch { + toast.toastOnly.error('Failed to approve brief'); + } + }; + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + if (error || !brief) { + return ( +
+
+

Brief not found or access denied.

+
+
+ ); + } + + return ( +
+
+ ← Briefs +

{brief.title}

+ + {brief.status} + +
+ +
+ {brief.description && ( +
+

Description

+

{brief.description}

+
+ )} + +
+
+ Languages +

{brief.languages.length > 0 ? brief.languages.map(l => l.toUpperCase()).join(', ') : '—'}

+
+
+ Deadline +

{brief.deadline ? new Date(brief.deadline).toLocaleDateString() : '—'}

+
+
+ Created +

{new Date(brief.created_at).toLocaleString()}

+
+ {brief.submitted_at && ( +
+ Submitted +

{new Date(brief.submitted_at).toLocaleString()}

+
+ )} + {brief.job_id && ( +
+ Linked Job + + View Job → + +
+ )} +
+ +
+

Requested Outputs

+
+ {brief.requested_outputs.captions_vtt && Captions VTT} + {brief.requested_outputs.audio_description_vtt && AD VTT} + {brief.requested_outputs.audio_description_mp3 && AD MP3} + {brief.requested_outputs.sdh_vtt && SDH VTT} +
+
+
+ +
+ {canSubmit && ( + + )} + {canApprove && ( + + )} + {canCreateJob && ( + + Create Job from Brief + + )} +
+
+ ); +} diff --git a/frontend/src/routes/briefs/BriefsList.tsx b/frontend/src/routes/briefs/BriefsList.tsx new file mode 100644 index 0000000..331af6f --- /dev/null +++ b/frontend/src/routes/briefs/BriefsList.tsx @@ -0,0 +1,104 @@ +import { Link } from 'react-router-dom'; +import { useBriefs } from '../../hooks/useJob'; + +const STATUS_BADGE: Record = { + draft: 'bg-gray-100 text-gray-700', + submitted: 'bg-blue-100 text-blue-700', + approved: 'bg-green-100 text-green-700', + rejected: 'bg-red-100 text-red-700', + fulfilled: 'bg-purple-100 text-purple-700', +}; + +export function BriefsList() { + const { data: briefs = [], isLoading, error } = useBriefs(); + + if (isLoading) { + return ( +
+
+ {[...Array(4)].map((_, i) =>
)} +
+ ); + } + + if (error) { + return ( +
+
+

Failed to load briefs.

+
+
+ ); + } + + const open = briefs.filter(b => ['submitted'].includes(b.status)).length; + + return ( +
+
+
+

Job Briefs

+ {open > 0 && ( +

{open} awaiting approval

+ )} +
+ + New Brief + +
+ + {briefs.length === 0 ? ( +
+

No briefs yet.

+ + Create your first brief + +
+ ) : ( +
+ + + + + + + + + + + + + {briefs.map(brief => ( + + + + + + + + + ))} + +
TitleStatusLanguagesDeadlineCreated
{brief.title} + + {brief.status} + + + {brief.languages.length > 0 ? brief.languages.join(', ') : '—'} + + {brief.deadline ? new Date(brief.deadline).toLocaleDateString() : '—'} + + {new Date(brief.created_at).toLocaleDateString()} + + + View + +
+
+ )} +
+ ); +} diff --git a/frontend/src/routes/briefs/NewBrief.tsx b/frontend/src/routes/briefs/NewBrief.tsx new file mode 100644 index 0000000..bce5bfb --- /dev/null +++ b/frontend/src/routes/briefs/NewBrief.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useCreateBrief } from '../../hooks/useJob'; +import { useToastContext } from '../../contexts/ToastContext'; +import { useProjects } from '../../hooks/useClients'; + +const COMMON_LANGUAGES = ['en', 'fr', 'de', 'es', 'it', 'pt', 'nl', 'pl', 'sv', 'da', 'nb', 'fi']; + +export function NewBrief() { + const navigate = useNavigate(); + const toast = useToastContext(); + const createBriefMutation = useCreateBrief(); + + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [languages, setLanguages] = useState([]); + const [deadline, setDeadline] = useState(''); + const [projectId, setProjectId] = useState(''); + const [captionsVtt, setCaptionsVtt] = useState(true); + const [adVtt, setAdVtt] = useState(true); + const [adMp3, setAdMp3] = useState(true); + + const { data: projects = [] } = useProjects(''); + + const toggleLang = (lang: string) => { + setLanguages(prev => + prev.includes(lang) ? prev.filter(l => l !== lang) : [...prev, lang] + ); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) { + toast.toastOnly.error('Title is required'); + return; + } + try { + const brief = await createBriefMutation.mutateAsync({ + title: title.trim(), + description: description.trim() || undefined, + requested_outputs: { + captions_vtt: captionsVtt, + audio_description_vtt: adVtt, + audio_description_mp3: adMp3, + accessible_video_mp4: false, + sdh_vtt: false, + languages, + transcreation: [], + translation_mode: 'video_native', + }, + languages, + deadline: deadline || undefined, + project_id: projectId || undefined, + }); + toast.toastOnly.success('Brief created'); + navigate(`/briefs/${brief.id}`); + } catch { + toast.toastOnly.error('Failed to create brief'); + } + }; + + return ( +
+
+ ← Briefs +

New Job Brief

+
+ +
+
+ + setTitle(e.target.value)} + className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500" + placeholder="e.g. Product launch video — EN/FR/DE" + /> +
+ +
+ +