feat(w-12): brief workflow UI — list, create, detail, NewJob pre-fill

- 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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-29 20:41:49 +01:00
parent 595897e61a
commit fe608401be
9 changed files with 582 additions and 3 deletions

View file

@ -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() {
</RoleGate>
</AuthenticatedRoute>
} />
<Route path="/briefs" element={
<AuthenticatedRoute>
<BriefsList />
</AuthenticatedRoute>
} />
<Route path="/briefs/new" element={
<AuthenticatedRoute>
<NewBrief />
</AuthenticatedRoute>
} />
<Route path="/briefs/:id" element={
<AuthenticatedRoute>
<BriefDetail />
</AuthenticatedRoute>
} />
<Route path="/qc/queue" element={
<AuthenticatedRoute>
<RoleGate allowedRoles={['linguist', 'reviewer', 'production', 'admin']}>

View file

@ -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',

View file

@ -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],

View file

@ -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<import('../types/api').JobBriefResponse[]> {
const response = await this.client.get('/briefs');
return response.data;
}
async createBrief(data: import('../types/api').JobBriefCreate): Promise<import('../types/api').JobBriefResponse> {
const response = await this.client.post('/briefs', data);
return response.data;
}
async getBrief(id: string): Promise<import('../types/api').JobBriefResponse> {
const response = await this.client.get(`/briefs/${id}`);
return response.data;
}
async submitBrief(id: string): Promise<import('../types/api').JobBriefResponse> {
const response = await this.client.post(`/briefs/${id}/submit`);
return response.data;
}
async approveBrief(id: string): Promise<import('../types/api').JobBriefResponse> {
const response = await this.client.post(`/briefs/${id}/approve`);
return response.data;
}
// User Management endpoints
async listUsers(filters?: {
page?: number;

View file

@ -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<string, string> = {
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 (
<div className="container mx-auto px-4 py-8 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
);
}
if (error || !brief) {
return (
<div className="container mx-auto px-4 py-8">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<p className="text-red-600">Brief not found or access denied.</p>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-3xl">
<div className="flex items-center gap-3 mb-6">
<Link to="/briefs" className="text-gray-500 hover:text-gray-700 text-sm"> Briefs</Link>
<h1 className="text-2xl font-bold text-gray-900 flex-1">{brief.title}</h1>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${STATUS_BADGE[brief.status] ?? 'bg-gray-100 text-gray-700'}`}>
{brief.status}
</span>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-6 space-y-4">
{brief.description && (
<div>
<h3 className="text-sm font-medium text-gray-500">Description</h3>
<p className="text-sm text-gray-800 mt-1">{brief.description}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Languages</span>
<p className="text-gray-800 mt-0.5">{brief.languages.length > 0 ? brief.languages.map(l => l.toUpperCase()).join(', ') : '—'}</p>
</div>
<div>
<span className="text-gray-500">Deadline</span>
<p className="text-gray-800 mt-0.5">{brief.deadline ? new Date(brief.deadline).toLocaleDateString() : '—'}</p>
</div>
<div>
<span className="text-gray-500">Created</span>
<p className="text-gray-800 mt-0.5">{new Date(brief.created_at).toLocaleString()}</p>
</div>
{brief.submitted_at && (
<div>
<span className="text-gray-500">Submitted</span>
<p className="text-gray-800 mt-0.5">{new Date(brief.submitted_at).toLocaleString()}</p>
</div>
)}
{brief.job_id && (
<div>
<span className="text-gray-500">Linked Job</span>
<Link to={`/jobs/${brief.job_id}`} className="text-indigo-600 hover:underline text-sm mt-0.5 block">
View Job
</Link>
</div>
)}
</div>
<div>
<h3 className="text-sm font-medium text-gray-500 mb-2">Requested Outputs</h3>
<div className="flex flex-wrap gap-2 text-xs">
{brief.requested_outputs.captions_vtt && <span className="bg-gray-100 px-2 py-1 rounded">Captions VTT</span>}
{brief.requested_outputs.audio_description_vtt && <span className="bg-gray-100 px-2 py-1 rounded">AD VTT</span>}
{brief.requested_outputs.audio_description_mp3 && <span className="bg-gray-100 px-2 py-1 rounded">AD MP3</span>}
{brief.requested_outputs.sdh_vtt && <span className="bg-gray-100 px-2 py-1 rounded">SDH VTT</span>}
</div>
</div>
</div>
<div className="flex gap-3 mt-6">
{canSubmit && (
<button
onClick={handleSubmit}
disabled={submitMutation.isPending}
className="px-5 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50"
>
{submitMutation.isPending ? 'Submitting...' : 'Submit for Approval'}
</button>
)}
{canApprove && (
<button
onClick={handleApprove}
disabled={approveMutation.isPending}
className="px-5 py-2 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
>
{approveMutation.isPending ? 'Approving...' : 'Approve Brief'}
</button>
)}
{canCreateJob && (
<Link
to={`/jobs/new?brief_id=${brief.id}`}
className="px-5 py-2 bg-indigo-600 text-white text-sm rounded hover:bg-indigo-700"
>
Create Job from Brief
</Link>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,104 @@
import { Link } from 'react-router-dom';
import { useBriefs } from '../../hooks/useJob';
const STATUS_BADGE: Record<string, string> = {
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 (
<div className="container mx-auto px-4 py-8 animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
{[...Array(4)].map((_, i) => <div key={i} className="h-14 bg-gray-200 rounded"></div>)}
</div>
);
}
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<p className="text-red-600">Failed to load briefs.</p>
</div>
</div>
);
}
const open = briefs.filter(b => ['submitted'].includes(b.status)).length;
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Job Briefs</h1>
{open > 0 && (
<p className="text-sm text-blue-600 mt-1">{open} awaiting approval</p>
)}
</div>
<Link
to="/briefs/new"
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded hover:bg-indigo-700"
>
New Brief
</Link>
</div>
{briefs.length === 0 ? (
<div className="bg-gray-50 border border-gray-200 rounded-md p-8 text-center">
<p className="text-gray-500">No briefs yet.</p>
<Link to="/briefs/new" className="text-indigo-600 hover:underline text-sm mt-2 block">
Create your first brief
</Link>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-xs text-gray-500 uppercase">
<tr>
<th className="px-4 py-3 text-left">Title</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">Languages</th>
<th className="px-4 py-3 text-left">Deadline</th>
<th className="px-4 py-3 text-left">Created</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{briefs.map(brief => (
<tr key={brief.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{brief.title}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[brief.status] ?? 'bg-gray-100 text-gray-700'}`}>
{brief.status}
</span>
</td>
<td className="px-4 py-3 text-gray-500">
{brief.languages.length > 0 ? brief.languages.join(', ') : '—'}
</td>
<td className="px-4 py-3 text-gray-500">
{brief.deadline ? new Date(brief.deadline).toLocaleDateString() : '—'}
</td>
<td className="px-4 py-3 text-gray-400 text-xs">
{new Date(brief.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right">
<Link to={`/briefs/${brief.id}`} className="text-indigo-600 hover:text-indigo-800 text-xs">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -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<string[]>([]);
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 (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<div className="flex items-center gap-3 mb-6">
<Link to="/briefs" className="text-gray-500 hover:text-gray-700 text-sm"> Briefs</Link>
<h1 className="text-2xl font-bold text-gray-900">New Job Brief</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6 bg-white border border-gray-200 rounded-lg p-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title *</label>
<input
type="text"
value={title}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500"
placeholder="Optional context for the production team"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Project</label>
<select
value={projectId}
onChange={e => setProjectId(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
>
<option value=""> None </option>
{projects.filter(p => p.is_active).map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Requested Outputs</label>
<div className="space-y-2">
{([
[captionsVtt, setCaptionsVtt, 'Captions (VTT)'],
[adVtt, setAdVtt, 'Audio Descriptions (VTT)'],
[adMp3, setAdMp3, 'Audio Descriptions (MP3)'],
] as [boolean, (v: boolean) => void, string][]).map(([val, setter, label]) => (
<label key={label} className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={val} onChange={e => setter(e.target.checked)} className="rounded" />
{label}
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Languages</label>
<div className="flex flex-wrap gap-2">
{COMMON_LANGUAGES.map(lang => (
<button
type="button"
key={lang}
onClick={() => toggleLang(lang)}
className={`px-2 py-1 text-xs rounded border ${
languages.includes(lang)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white text-gray-600 border-gray-300 hover:border-indigo-400'
}`}
>
{lang.toUpperCase()}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Deadline</label>
<input
type="date"
value={deadline}
onChange={e => setDeadline(e.target.value)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
/>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={createBriefMutation.isPending}
className="px-6 py-2 bg-indigo-600 text-white text-sm rounded hover:bg-indigo-700 disabled:opacity-50"
>
{createBriefMutation.isPending ? 'Creating...' : 'Create Brief'}
</button>
<Link to="/briefs" className="px-6 py-2 text-sm text-gray-600 hover:text-gray-800">
Cancel
</Link>
</div>
</form>
</div>
);
}

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@ -8,7 +8,7 @@ import { VoiceSelector } from '../../components/VoiceSelector';
import { LanguageSelector } from '../../components/LanguageSelector';
import { MultiUploadFileList } from '../../components/MultiUploadFileList';
import { UploadProgressList } from '../../components/UploadProgressList';
import { useCreateJob } from '../../hooks/useJob';
import { useCreateJob, useBrief } from '../../hooks/useJob';
import { useMultiUpload } from '../../hooks/useMultiUpload';
import { useToastContext } from '../../contexts/ToastContext';
import { generateTitleFromFilename } from '../../lib/fileUtils';
@ -30,6 +30,9 @@ const jobSchema = z.object({
type JobFormData = z.infer<typeof jobSchema>;
export function NewJob() {
const [searchParams] = useSearchParams();
const briefId = searchParams.get('brief_id');
// Single upload state (for 1 file)
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
@ -117,6 +120,23 @@ export function NewJob() {
const audioDescriptionMp3 = watch('audio_description_mp3');
const accessibleVideoMp4 = watch('accessible_video_mp4');
// Pre-fill from brief if brief_id query param is present
const { data: briefData } = useBrief(briefId ?? '');
useEffect(() => {
if (!briefData) return;
setValue('languages', briefData.languages ?? []);
setValue('captions_vtt', briefData.requested_outputs.captions_vtt);
setValue('audio_description_vtt', briefData.requested_outputs.audio_description_vtt);
setValue('audio_description_mp3', briefData.requested_outputs.audio_description_mp3);
setValue('sdh_vtt', briefData.requested_outputs.sdh_vtt);
if (briefData.deadline) {
setDeadline(briefData.deadline.substring(0, 10));
}
if (briefData.project_id) {
setSelectedProjectId(briefData.project_id);
}
}, [briefData, setValue]);
// Handle file selection from dropzone
const handleFilesSelect = (files: File[]) => {
if (files.length === 1 && multiUpload.files.length === 0) {
@ -178,6 +198,7 @@ export function NewJob() {
},
brand_context: brandContext.trim() || undefined,
project_id: selectedProjectId || undefined,
brief_id: briefId || undefined,
deadline: deadline || undefined,
initial_linguist_id: teamLinguistId || undefined,
initial_reviewer_id: teamReviewerId || undefined,

View file

@ -352,6 +352,7 @@ export interface JobCreateRequest {
requested_outputs: RequestedOutputs;
brand_context?: string;
project_id?: string;
brief_id?: string;
deadline?: string;
initial_linguist_id?: string;
initial_reviewer_id?: string;
@ -858,4 +859,33 @@ export interface GlossaryUploadRequest {
source_locale_col: string;
description?: string;
change_note?: string;
}
export type BriefStatus = "draft" | "submitted" | "approved" | "rejected" | "fulfilled";
export interface JobBriefResponse {
id: string;
organization_id: string;
project_id?: string;
title: string;
description?: string;
requested_outputs: RequestedOutputs;
languages: string[];
deadline?: string;
status: BriefStatus;
created_by: string;
job_id?: string;
created_at: string;
updated_at: string;
submitted_at?: string;
approved_by?: string;
}
export interface JobBriefCreate {
title: string;
description?: string;
requested_outputs: RequestedOutputs;
languages?: string[];
deadline?: string;
project_id?: string;
}