/* 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 (
allowed && !isCurrent && onSet(s)}
title={allowed ? `Move to ${s}` : 'Only an admin can set this'}
>
{!isCurrent && }
{STATUS_SHORT[s]}
);
})}
);
}
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' && (
onOpen(version.id)}>Review
)}
{version.status === 'failed' && !version.original_deleted && (
onRetry(version.id)}>Retry
)}
);
}
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 (
);
}
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}
/>
Save changes
{ setEditing(false); setForm({ ...task }); }}>Cancel
>
);
}
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 && setEditing(true)}>Edit task }
{!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 }))} />
Save
)}
)}
)}
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 && (
)}
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' && (
{
if (!confirm('Delete this task and every cut on it?')) return;
api.del(`/api/tasks/${taskId}`).then(() => { toast('Task deleted'); go('board'); })
.catch((e) => toast(e.message, 'error'));
}}>
Delete task
)}
>
);
}