/* The board: seven pipeline columns, drag a card to move it, filter by person,
client, type and a date window. Also a list view for when you want the whole
pipeline in one table. */
const STATUS_ORDER = [
'AD ASSIGNED',
'AD IN PROGRESS',
'AD SENT INTERNALLY',
'AD SENT TO CLIENT',
'AD REVISION',
'AD CONFIRMED',
'AD PUBLISHED',
];
const DATE_FIELDS = [
{ value: 'due_date', label: 'Deadline' },
{ value: 'created_at', label: 'Created' },
{ value: 'delivery_month', label: 'Delivery month' },
];
const DATE_PRESETS = [
{ value: '', label: 'Any date' },
{ value: 'overdue', label: 'Overdue' },
{ value: 'today', label: 'Today' },
{ value: 'week', label: 'This week' },
{ value: 'next7', label: 'Next 7 days' },
{ value: 'month', label: 'This month' },
{ value: 'last30', label: 'Last 30 days' },
{ value: 'custom', label: 'Custom range…' },
];
/* Il percorso standard su Drive, ricalcolato mentre compili il form. Lo stesso
calcolo che fa il server, così quello che leggi è quello che verrà usato. */
function drivePath(form) {
const m = (form.delivery_month || '').slice(0, 7) || isoMonth(new Date());
if (form.type === 'static') return `Internal / ${m} / Static ads`;
const a = (form.angle || '').trim();
const leaf = a ? a[0].toUpperCase() + a.slice(1).toLowerCase() : 'General';
return `Internal / ${m} / ${leaf}`;
}
function AvatarStack({ users }) {
if (!users.length) return ;
return (
{users.slice(0, 3).map((u) =>
)}
{users.length > 3 &&
+{users.length - 3}
}
);
}
function TaskCard({ task, client, assignees, onOpen, onDragStart, dragging }) {
const due = dueLabel(task.due_date);
return (
onDragStart(e, task)}
onClick={() => onOpen(task.id)}
>
{/* The brand comes first: on a mixed board you look for the client before
you look for the ad. */}
{client ? client.name : 'No client'}
{task.title}
{task.quantity > 1 &&
×{task.quantity} ads }
{task.effort_days > 0 && (
{task.effort_days}{task.effort_days === 1 ? ' day' : ' days'}
)}
{task.angle &&
{task.angle} }
{task.format &&
{task.format} }
{task.version_count > 0 &&
v{task.version_count} }
{task.revision_rounds > 0 &&
R{task.revision_rounds} }
{task.open_comments > 0 && (
{task.open_comments}
)}
{due &&
{due.text}}
{task.delivery_url &&
delivered}
);
}
/* Month grid keyed on the deadline. The point is to see the week ahead the way
the team feels it, not as a list. */
function CalendarView({ month, tasks, directory, onMove, go, onMonth }) {
const [dropDay, setDropDay] = React.useState(null);
const first = new Date(month + '-01T00:00:00');
const startOffset = (first.getDay() + 6) % 7; // Monday-first weeks
const daysInMonth = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate();
const cells = [];
for (let i = 0; i < startOffset; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) {
cells.push(new Date(first.getFullYear(), first.getMonth(), d));
}
while (cells.length % 7) cells.push(null);
const byDay = {};
tasks.forEach((t) => {
if (!t.due_date) return;
(byDay[t.due_date] = byDay[t.due_date] || []).push(t);
});
const undated = tasks.filter((t) => !t.due_date);
const todayIso = iso(new Date());
const shift = (n) => {
const d = new Date(first.getFullYear(), first.getMonth() + n, 1);
onMonth(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
};
return (
<>
shift(-1)}>←
{first.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })}
shift(1)}>→
onMonth(isoMonth(new Date()))}>Today
Drag a task to another day to move its deadline
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((d) => (
{d}
))}
{cells.map((day, i) => {
if (!day) return
;
const key = iso(day);
const items = byDay[key] || [];
const weekend = day.getDay() === 0 || day.getDay() === 6;
return (
{ e.preventDefault(); setDropDay(key); }}
onDragLeave={() => setDropDay((v) => (v === key ? null : v))}
onDrop={(e) => {
e.preventDefault();
setDropDay(null);
onMove(e.dataTransfer.getData('text/plain'), key);
}}
>
{day.getDate()}
{items.map((t) => {
const client = directory.byId[t.client_id];
return (
e.dataTransfer.setData('text/plain', t.id)}
onClick={() => go('task', t.id)}
title={`${client ? client.name : ''} · ${t.title} · ${STATUS_SHORT[t.status]}`}
style={{ borderLeftColor: (client && client.color) || 'var(--border-strong)' }}
>
{client ? client.name : 'No client'}
{t.title}
);
})}
);
})}
{undated.length > 0 && (
No deadline
{undated.length}
{undated.map((t) => {
const client = directory.byId[t.client_id];
return (
e.dataTransfer.setData('text/plain', t.id)}
onClick={() => go('task', t.id)}
style={{ borderLeftColor: (client && client.color) || 'var(--border-strong)', maxWidth: 260 }}>
{client ? client.name : 'No client'}
{t.title}
);
})}
)}
>
);
}
function AssigneePicker({ users, value, onChange }) {
return (
);
}
function TaskForm({ form, set, clients, users, creating }) {
const isStatic = form.type === 'static';
return (
<>
Client
set('client_id', e.target.value)}>
{clients.map((c) => {c.name} )}
Type
set('type', 'video')}>Video ad
set('type', 'static')}>Static ad
Task name
set('title', e.target.value)} />
{!isStatic && (
Length — this is what the editor's day costs
set('video_length', 'short')}>
Short · half a day each
set('video_length', 'long')}>
Long · a full day each
{(() => {
const q = Math.max(1, Number(form.quantity) || 1);
const d = q * ((form.video_length || 'short') === 'long' ? 1 : 0.5);
return `${q} ${q === 1 ? 'ad' : 'ads'} = ${d} ${d === 1 ? 'day' : 'days'} of editing`;
})()}
)}
Assigned to
u.is_active && u.role !== 'client')}
value={form.assignee_ids || []}
onChange={(v) => set('assignee_ids', v)}
/>
Instructions
Script / copy
Source material
set('source_url', e.target.value)} />
Cartella su Drive
{form.drive_target_folder_url ? (
set('drive_target_folder_url', e.target.value)} />
set('drive_target_folder_url', '')}>
Torna allo standard
) : (
{drivePath(form)}
set('drive_target_folder_url', ' ')}>
Cambia cartella
)}
Di standard segue angle e mese di delivery e si aggiorna da solo. Incolla il link
di un'altra cartella se questo set va altrove, tipo Summer Sale.
{isStatic ? 'Figma link' : 'Delivery link (Drive)'}
set('delivery_url', e.target.value)} />
>
);
}
function NewTaskModal({ clients, users, onClose, onCreated }) {
const thisMonth = isoMonth(new Date()) + '-01';
const [form, setForm] = React.useState({
client_id: clients[0] ? clients[0].id : '',
title: '', type: 'video', angle: '', format: '', quantity: 1, video_length: 'short', assignee_ids: [],
delivery_url: '', source_url: '', due_date: '', delivery_month: thisMonth, drive_target_folder_url: '',
description: '', script: '',
});
const [saving, setSaving] = React.useState(false);
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const save = () => {
if (!form.title.trim() || !form.client_id) { toast('Client and task name are required', 'error'); return; }
setSaving(true);
api.post('/api/tasks', {
...form,
due_date: form.due_date || null,
delivery_month: form.delivery_month || null,
angle: form.angle || null,
format: form.format || null,
quantity: Math.max(1, Number(form.quantity) || 1),
video_length: form.type === 'video' ? (form.video_length || 'short') : null,
delivery_url: form.delivery_url || null,
source_url: form.source_url || null,
drive_target_folder_url: (form.drive_target_folder_url || '').trim() || null,
})
.then((t) => { toast('Task created'); onCreated(t); onClose(); })
.catch((e) => toast(e.message, 'error'))
.finally(() => setSaving(false));
};
return (
Cancel
{saving ? 'Creating…' : 'Create task'}
>}
>
The angle and the delivery month become the Drive folders when the ad is confirmed.
);
}
function Board({ go, me, directory, mine }) {
const [tasks, setTasks] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [creating, setCreating] = React.useState(false);
const [dragId, setDragId] = React.useState(null);
const [overStatus, setOverStatus] = React.useState(null);
const saved = JSON.parse(localStorage.getItem('studio.filters') || '{}');
const [calMonth, setCalMonth] = React.useState(isoMonth(new Date()));
const [filters, setFilters] = React.useState({
client_id: saved.client_id || '',
assignee_id: mine ? me.id : (saved.assignee_id || ''),
type: saved.type || '',
status: saved.status || '',
view: saved.view || 'board',
hidePublished: saved.hidePublished !== false,
date_field: saved.date_field || 'due_date',
date_preset: saved.date_preset || '',
date_from: saved.date_from || '',
date_to: saved.date_to || '',
});
const setFilter = (k, v) => {
const next = { ...filters, [k]: v };
// Picking a preset fills the window; "Custom range…" leaves the two inputs
// to the user.
if (k === 'date_preset' && v !== 'custom') {
const range = dateRange(v);
next.date_from = range.from;
next.date_to = range.to;
}
setFilters(next);
const { assignee_id, ...rest } = next;
localStorage.setItem('studio.filters', JSON.stringify(mine ? rest : next));
};
// Filters live in localStorage, so a window set days ago can quietly hide
// everything. This is the way back.
const clearFilters = () => {
const reset = {
client_id: '', assignee_id: mine ? me.id : '', type: '', status: '',
view: filters.view, hidePublished: false,
date_field: 'due_date', date_preset: '', date_from: '', date_to: '',
};
setFilters(reset);
localStorage.removeItem('studio.filters');
};
const load = React.useCallback(() => {
const qs = new URLSearchParams();
if (filters.client_id) qs.set('client_id', filters.client_id);
if (mine) qs.set('assignee_id', me.id);
else if (filters.assignee_id) qs.set('assignee_id', filters.assignee_id);
if (filters.type) qs.set('type', filters.type);
if (filters.view === 'calendar') {
// The visible month IS the window; the date filter would fight it. Undated
// tasks come along too and get their own tray under the grid.
const first = new Date(calMonth + '-01T00:00:00');
const last = new Date(first.getFullYear(), first.getMonth() + 1, 0);
qs.set('date_field', 'due_date');
qs.set('date_from', iso(first));
qs.set('date_to', iso(last));
qs.set('include_undated', 'true');
} else if (filters.date_from || filters.date_to) {
qs.set('date_field', filters.date_field);
if (filters.date_from) qs.set('date_from', filters.date_from);
if (filters.date_to) qs.set('date_to', filters.date_to);
}
setLoading(true);
api.get('/api/tasks?' + qs.toString())
.then((d) => setTasks(d.items))
.catch((e) => toast(e.message, 'error'))
.finally(() => setLoading(false));
}, [filters.client_id, filters.assignee_id, filters.type, filters.date_field,
filters.date_from, filters.date_to, filters.view, calMonth, mine, me.id]);
React.useEffect(load, [load]);
const columns = React.useMemo(() => {
let visible = filters.hidePublished
? STATUS_ORDER.filter((s) => s !== 'AD PUBLISHED')
: STATUS_ORDER;
if (filters.status) visible = [filters.status];
return visible.map((status) => ({ status, items: tasks.filter((t) => t.status === status) }));
}, [tasks, filters.hidePublished, filters.status]);
const listed = React.useMemo(() => {
if (filters.status) return tasks.filter((t) => t.status === filters.status);
if (filters.hidePublished) return tasks.filter((t) => t.status !== 'AD PUBLISHED');
return tasks;
}, [tasks, filters.status, filters.hidePublished]);
const move = (task, status) => {
if (task.status === status) return;
const previous = tasks;
setTasks((list) => list.map((t) => (t.id === task.id ? { ...t, status } : t)));
api.post(`/api/tasks/${task.id}/status`, { status })
.then(() => { if (status === 'AD CONFIRMED') toast('Confirmed — publishing to Drive'); })
.catch((e) => { toast(e.message, 'error'); setTasks(previous); });
};
const onDragStart = (e, task) => {
setDragId(task.id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', task.id);
};
const clientOf = (t) => directory.byId[t.client_id];
const peopleOf = (t) => (t.assignee_ids || []).map((id) => directory.byId[id]).filter(Boolean);
return (
<>
setFilter('client_id', e.target.value)}>
All clients
{directory.clients.map((c) => {c.name} )}
{!mine && (
setFilter('assignee_id', e.target.value)}>
Everyone
Unassigned
{directory.users.filter((u) => u.is_active && u.role !== 'client').map((u) => (
{u.name || u.email}
))}
)}
setFilter('type', '')}>All
setFilter('type', 'video')}>Video
setFilter('type', 'static')}>Static
setFilter('date_field', e.target.value)}>
{DATE_FIELDS.map((f) => {f.label} )}
setFilter('date_preset', e.target.value)}>
{DATE_PRESETS.map((p) => {p.label} )}
{filters.date_preset === 'custom' && (
<>
setFilter('date_from', e.target.value)} />
to
setFilter('date_to', e.target.value)} />
>
)}
{filters.status && (
setFilter('status', '')}>
{STATUS_SHORT[filters.status]}
)}
setFilter('hidePublished', !e.target.checked)} />
Show published
setFilter('view', 'board')}>
setFilter('view', 'list')}>
setFilter('view', 'calendar')}>
{me.role === 'admin' && (
setCreating(true)}>
New task
)}
{!loading && filters.view !== 'calendar' && (
{STATUS_ORDER.map((s) => {
const count = tasks.filter((t) => t.status === s).length;
return (
setFilter('status', filters.status === s ? '' : s)}
>
{count}
{STATUS_SHORT[s]}
);
})}
)}
{loading && Loading…
}
{!loading && tasks.length === 0 && filters.view !== 'calendar' && (
Clear filters
{me.role === 'admin' && (
setCreating(true)}>New task
)}
}
/>
)}
{!loading && tasks.length > 0 && filters.view === 'board' && (
{columns.map((col) => (
{ e.preventDefault(); setOverStatus(col.status); }}
onDragLeave={() => setOverStatus((s) => (s === col.status ? null : s))}
onDrop={(e) => {
e.preventDefault();
setOverStatus(null);
const task = tasks.find((t) => t.id === e.dataTransfer.getData('text/plain'));
if (task) move(task, col.status);
setDragId(null);
}}
>
{STATUS_SHORT[col.status]}
{col.items.length}
{col.items.map((t) => (
go('task', id)}
/>
))}
))}
)}
{!loading && filters.view === 'calendar' && (
{
const previous = tasks;
setTasks((list) => list.map((t) => (t.id === taskId ? { ...t, due_date: day } : t)));
api.patch(`/api/tasks/${taskId}`, { due_date: day })
.catch((e) => { toast(e.message, 'error'); setTasks(previous); });
}}
/>
)}
{!loading && tasks.length > 0 && filters.view === 'list' && (
Ad Client Angle Format
Assigned Deadline Status Delivery Created
{listed.map((t) => {
const due = dueLabel(t.due_date);
return (
go('task', t.id)}>
{t.title}
{(clientOf(t) || {}).name || '—'}
{t.angle || '—'}
{t.format || '—'}
{due ? {due.text} : — }
{t.delivery_url
? e.stopPropagation()}>open
: — }
{ago(t.created_at)}
);
})}
)}
{creating && (
setCreating(false)}
onCreated={() => load()}
/>
)}
>
);
}