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:
parent
595897e61a
commit
fe608401be
9 changed files with 582 additions and 3 deletions
|
|
@ -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']}>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
155
frontend/src/routes/briefs/BriefDetail.tsx
Normal file
155
frontend/src/routes/briefs/BriefDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
frontend/src/routes/briefs/BriefsList.tsx
Normal file
104
frontend/src/routes/briefs/BriefsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
frontend/src/routes/briefs/NewBrief.tsx
Normal file
167
frontend/src/routes/briefs/NewBrief.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue