/* 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 ( <>
{first.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })} 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 (
{users.map((u) => { const on = value.includes(u.id); return ( ); })}
); } function TaskForm({ form, set, clients, users, creating }) { const isStatic = form.type === 'static'; return ( <>
set('title', e.target.value)} />
{!isStatic && (

{(() => { 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`; })()}

)}
set('quantity', Math.max(1, Number(e.target.value) || 1))} />
set('angle', e.target.value)} />
set('due_date', e.target.value)} />
set('delivery_month', e.target.value ? e.target.value + '-01' : null)} />
u.is_active && u.role !== 'client')} value={form.assignee_ids || []} onChange={(v) => set('assignee_ids', v)} />