/* Task detail: the brief, the pipeline stepper, and whatever the deliverable is — uploaded cuts for a video ad, a Figma link for a static. */ const MEMBER_STATUSES = ['AD ASSIGNED', 'AD IN PROGRESS', 'AD SENT INTERNALLY', 'AD REVISION']; function Stepper({ status, canSet, onSet }) { const current = STATUS_ORDER.indexOf(status); return (
{STATUS_ORDER.map((s, i) => { const color = STATUS_COLORS[s]; const isCurrent = i === current; const allowed = canSet(s); return ( ); })}
); } function VersionRow({ version, onOpen, onRetry }) { const processing = version.status === 'processing'; return (
{version.poster_url ? :
}
v{version.version_number} {version.label && {version.label}} {version.status === 'ready' && version.duration_ms != null && ( {timecode(version.duration_ms)} · {version.width}×{version.height} {version.fps ? ` · ${version.fps.toFixed(2)} fps` : ''} )} {version.status === 'failed' && failed} {version.drive_file_url && on Drive} {version.original_deleted && !version.drive_file_url && source cleared}
{version.original_filename} · {bytes(version.original_size_bytes)} · {ago(version.created_at)}
{processing && (
Preparing for review… {version.processing_progress || 0}%
)} {version.status === 'failed' && (
{version.error}
)}
{version.status === 'ready' && ( )} {version.status === 'failed' && !version.original_deleted && ( )}
); } function UploadZone({ taskId, batch, onDone }) { const [busy, setBusy] = React.useState(false); const [progress, setProgress] = React.useState(0); const [over, setOver] = React.useState(false); const [label, setLabel] = React.useState(''); const inputRef = React.useRef(null); const start = (file) => { if (!file) return; if (!/^video\//.test(file.type) && !/\.(mp4|mov|m4v|webm|avi|mkv)$/i.test(file.name)) { toast('That is not a video file', 'error'); return; } setBusy(true); setProgress(0); uploadVideo(taskId, file, { onProgress: setProgress, label: label.trim() || null }) .then((version) => { toast(`v${version.version_number} uploaded, preparing for review`); setLabel(''); onDone(version); }) .catch((e) => toast(e.message || 'Upload failed', 'error')) .finally(() => { setBusy(false); setProgress(0); }); }; if (busy) { return (
Uploading… {progress}% Safe to keep working — a dropped connection resumes.
); } return ( <> {batch > 1 && (
setLabel(e.target.value)} /> This task covers {batch} ads — naming each cut keeps the notes apart.
)}
inputRef.current.click()} onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} onDrop={(e) => { e.preventDefault(); setOver(false); start(e.dataTransfer.files[0]); }} > { start(e.target.files[0]); e.target.value = ''; }} />
Drop a cut here, or click to choose
Uploads resume by themselves if the connection drops
); } function DetailRow({ label, children }) { return (
{label}
{children}
); } function TaskScreen({ taskId, go, me, directory }) { const [data, setData] = React.useState(null); const [editing, setEditing] = React.useState(false); const [form, setForm] = React.useState({}); const load = React.useCallback(() => { api.get(`/api/tasks/${taskId}`) .then((d) => { setData(d); setForm({ ...d.task }); }) .catch((e) => toast(e.message, 'error')); }, [taskId]); React.useEffect(load, [load]); // While a version is transcoding, poll so the bar actually moves. React.useEffect(() => { if (!data) return; const processing = data.versions.filter((v) => v.status === 'processing'); if (!processing.length) return; const t = setInterval(() => { Promise.all(processing.map((v) => api.get(`/api/videos/${v.id}`).catch(() => null))) .then((rows) => { const fresh = rows.filter(Boolean); if (!fresh.length) return; setData((d) => ({ ...d, versions: d.versions.map((v) => fresh.find((f) => f.id === v.id) || v), })); }); }, 2500); return () => clearInterval(t); }, [data]); if (!data) return
Loading…
; const task = data.task; const client = directory.byId[task.client_id]; const people = (task.assignee_ids || []).map((id) => directory.byId[id]).filter(Boolean); const isMine = (task.assignee_ids || []).includes(me.id); const canEdit = me.role === 'admin' || isMine; const canSet = (s) => me.role === 'admin' || (isMine && MEMBER_STATUSES.includes(s)); const due = dueLabel(task.due_date); const isStatic = task.type === 'static'; const setStatus = (status) => { api.post(`/api/tasks/${taskId}/status`, { status }) .then(() => { if (status === 'AD CONFIRMED') toast('Confirmed — publishing the final cut to Drive'); load(); }) .catch((e) => toast(e.message, 'error')); }; const save = () => { const patch = { title: form.title, video_length: task.type === 'video' ? (form.video_length || 'short') : null, description: form.description, script: form.script, angle: form.angle || null, format: form.format || null, delivery_url: form.delivery_url || null, source_url: form.source_url || null, due_date: form.due_date || null, delivery_month: form.delivery_month || null, drive_target_folder_url: (form.drive_target_folder_url || '').trim() || null, }; if (me.role === 'admin') { patch.assignee_ids = form.assignee_ids || []; patch.client_id = form.client_id; patch.type = form.type; } api.patch(`/api/tasks/${taskId}`, patch) .then(() => { toast('Saved'); setEditing(false); load(); }) .catch((e) => toast(e.message, 'error')); }; if (editing) { return ( <>

Edit task

setForm((f) => ({ ...f, [k]: v }))} clients={directory.clients} users={directory.users} />
); } return ( <>
{client ? client.name : '—'} {isStatic ? 'Static ad' : 'Video ad'} {task.quantity > 1 && ×{task.quantity} ads} {task.angle && {task.angle}} {task.format && {task.format}} {due && {due.text}} {task.clickup_url && ( ClickUp )}

{task.title}

{canEdit && }
{!isStatic ? ( <>
Cuts {data.versions.length} version{data.versions.length === 1 ? '' : 's'}
{data.versions.length === 0 && (

No cut uploaded yet.

)} {data.versions.map((v) => ( go('review', id)} onRetry={(id) => api.post(`/api/videos/${id}/retry`).then(load).catch((e) => toast(e.message, 'error'))} /> ))}
) : (
Figma deliverable
{task.delivery_url ? ( <> Open in Figma

{task.delivery_url}

Review comments stay in Figma. Move the task along the pipeline here.

) : (

No Figma link yet.

{canEdit && (
setForm((f) => ({ ...f, delivery_url: e.target.value }))} />
)}
)}
)}
Instructions

{task.description || 'No instructions written.'}

{task.script && (
Script / copy

{task.script}

)}
{people.length ? (
{people.map((u) => (
{u.name || u.email}
{u.job_title || u.role}
))}
) : Nobody yet}
{task.due_date ? {new Date(task.due_date + 'T00:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} {due.tone ? ` · ${due.text}` : ''} : Not set} {task.effort_days > 0 && ( {task.effort_days} {task.effort_days === 1 ? 'day' : 'days'} · {task.quantity}× {task.video_length === 'long' ? 'long' : 'short'} )} {task.delivery_month ? monthLabel(task.delivery_month) : Not set} {new Date(task.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} · {ago(task.created_at)} {task.delivery_url ? {isStatic ? 'Open in Figma' : 'Open on Drive'} : {isStatic ? 'Not delivered yet' : 'Filled when the ad is confirmed'}} {task.drive_target_folder_url ? Cartella scelta a mano : {task.drive_path}}
Ci finisce quando approvi, non prima: fino ad allora il file sta solo qui.
{task.source_url ? Open folder : Not set}
{task.drive_folder_url && (
Published
Open the Drive folder
)}
History
{data.history.map((h) => { const who = directory.byId[h.changed_by_id]; return (
{STATUS_SHORT[h.to_status] || h.to_status}
{who ? (who.name || who.email) : 'Studio'} · {ago(h.created_at)}
{h.note &&
{h.note}
}
); })}
{me.role === 'admin' && ( )}
); }