// Tasks — Kanban board screen. (() => { const { Card, Badge, Avatar, AvatarGroup, Tabs } = window.TechyFuelOSDesignSystem_be0222; function addRecurrenceInterval(dateStr, interval) { const d = new Date((dateStr || new Date().toISOString().slice(0, 10)) + 'T00:00:00Z'); if (interval === 'weekly') d.setUTCDate(d.getUTCDate() + 7); else if (interval === 'daily') d.setUTCDate(d.getUTCDate() + 1); else d.setUTCMonth(d.getUTCMonth() + 1); // 'monthly' default return d.toISOString().slice(0, 10); } const TF_PRIORITY = { urgent: { color: 'var(--red-600)', bg: 'var(--red-50)', label: 'Urgent', icon: 'chevrons-up' }, high: { color: 'var(--amber-600)', bg: 'var(--amber-50)', label: 'High', icon: 'chevron-up' }, medium: { color: 'var(--blue-600)', bg: 'var(--blue-50)', label: 'Medium', icon: 'equal' }, low: { color: 'var(--slate-500)', bg: 'var(--slate-100)',label: 'Low', icon: 'chevron-down' }, }; const COLUMN_CONFIG = [ { id: 'backlog', label: 'Backlog', dot: 'var(--slate-400)', dbStatus: null }, { id: 'todo', label: 'To do', dot: 'var(--blue-500)', dbStatus: 'todo' }, { id: 'in_progress', label: 'In progress', dot: 'var(--violet-500)', dbStatus: 'in_progress' }, { id: 'review', label: 'Review', dot: 'var(--amber-500)', dbStatus: 'review' }, { id: 'done', label: 'Completed', dot: 'var(--green-500)', dbStatus: 'done' }, ]; const EMPTY_TASKS = { backlog: [], todo: [], in_progress: [], review: [], done: [] }; function fmtDue(ds) { if (!ds) return '—'; const d = new Date(ds); const t = new Date(); t.setHours(0,0,0,0); const diff = Math.round((d - t) / 86400000); if (diff === 0) return 'Today'; if (diff === 1) return 'Tomorrow'; return d.toLocaleDateString('en', { month: 'short', day: 'numeric' }); } function fmtDateFull(ds) { if (!ds) return '—'; return new Date(ds).toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' }); } // A task's discussion thread — the assignee, creator and client can talk here. function CommentThread({ taskId }) { const [comments, setComments] = React.useState([]); const [loading, setLoading] = React.useState(true); const [text, setText] = React.useState(''); // Internal (team only) vs Client (surfaced in the client portal). Internal is // the default so nothing reaches a client by accident. const [visibility, setVisibility] = React.useState('internal'); const [sending, setSending] = React.useState(false); const [pendingImg, setPendingImg] = React.useState(null); // {file, preview} const imgRef = React.useRef(); const endRef = React.useRef(); const load = React.useCallback(async () => { if (!window.API || !taskId) { setLoading(false); return; } try { const { data } = await window.API.getTaskComments(taskId); setComments(Array.isArray(data) ? data : []); } catch {} setLoading(false); }, [taskId]); React.useEffect(() => { setLoading(true); load(); }, [load]); React.useEffect(() => { if (endRef.current) endRef.current.scrollIntoView(); }, [comments.length]); function pickImage(e) { const file = (e.target.files || [])[0]; e.target.value = ''; if (!file) return; if (!file.type.startsWith('image/')) { window.alert('Please choose an image.'); return; } if (file.size > 10 * 1048576) { window.alert('Image must be under 10MB.'); return; } setPendingImg({ file, preview: URL.createObjectURL(file) }); } async function send() { const body = text.trim(); if ((!body && !pendingImg) || sending) return; setSending(true); try { let image_path = null; if (pendingImg) { try { const fileData = await window.API.uploadFile('files', null, pendingImg.file, { task_id: taskId }); image_path = fileData && fileData.file_path; } catch (err) { window.alert('Image upload failed: ' + (err.message || 'try again')); setSending(false); return; } } const { data, error } = await window.API.addTaskComment(taskId, { body: body || null, image_path, visibility }); if (!error && data) { setComments(prev => [...prev, data]); setText(''); setPendingImg(null); } } catch {} setSending(false); } async function remove(id) { if (!window.confirm('Delete this comment?')) return; try { await window.API.deleteTaskComment(id); setComments(prev => prev.filter(c => c.id !== id)); } catch {} } const when = d => d ? new Date(String(d).replace(' ', 'T')).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) : ''; const myId = window.TFMyMemberId; return (
{loading &&
Loading…
} {!loading && !comments.length &&
No comments yet. Start the discussion below.
} {comments.map(c => (
{c.author_name || 'Someone'} {when(c.created_at)} {c.visibility === 'client' && ( Client )} {(c.author_id === myId) && ( )}
{c.image_path && } {c.body &&
{c.body}
}
))}
{pendingImg && (
{pendingImg.file.name}
)} {/* Who is this comment for? Internal never reaches the client portal. */}
{[['internal','Internal','Team only'],['client','Client','Visible in the client portal']].map(([v,label,tip]) => ( ))}
setText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }} placeholder="Write a comment…" style={{ flex: 1, height: 36, padding: '0 12px', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)', fontFamily: 'var(--font-sans)', outline: 'none' }} />
); } function TaskCard({ task, onEdit, onDragStart, onDragEnd, dragging, isTimerRunning, onApprove, onReject }) { const [hover, setHover] = React.useState(false); const p = TF_PRIORITY[task.priority] || TF_PRIORITY.medium; const dueStr = fmtDue(task.due_date); const isOverdue = task.due_date && new Date(task.due_date) < new Date() && task.status !== 'done'; const canReview = task.approval_status === 'pending' && task.created_by === window.TFMyMemberId; return (
setHover(true)} onMouseLeave={() => setHover(false)} onClick={() => onEdit && onEdit(task)} draggable onDragStart={e => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', task.id); onDragStart && onDragStart(task); }} onDragEnd={() => onDragEnd && onDragEnd()} style={{ background: 'var(--slate-0)', border: `1px solid ${hover ? 'var(--slate-200)' : 'var(--border-subtle)'}`, borderRadius: 'var(--radius-lg)', padding: 12, boxShadow: hover ? 'var(--shadow-md)' : 'var(--shadow-xs)', cursor: dragging ? 'grabbing' : 'pointer', opacity: dragging ? 0.4 : 1, transform: hover && !dragging ? 'translateY(-1px)' : 'none', transition: 'all var(--dur-fast) var(--ease-out)' }}>
{task.project_name && {task.project_name}} {p.label} {task.client_id && Client} {task.is_recurring && } {isTimerRunning && Live} {task.approval_status === 'pending' && Pending review} {hover && }
{task.title}
{dueStr}
{canReview && (
)}
); } function StatusDot({ status }) { const cfg = COLUMN_CONFIG.find(c => c.id === status) || COLUMN_CONFIG[1]; return ; } function TaskListView({ allTasks, onAdd, onEdit, onToggle }) { const thStyle = { textAlign: 'left', padding: '10px 12px', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-bold)', color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)', whiteSpace: 'nowrap' }; const tdStyle = { padding: '10px 12px', fontSize: 'var(--text-sm)', borderBottom: '1px solid var(--border-subtle)', verticalAlign: 'middle' }; if (!allTasks.length) return (

No tasks yet

{onAdd && }
); return (
{/* Horizontal scroll wrapper — on a phone the 6-column table is wider than the screen; let it scroll inside its own box instead of pushing the page. */}
{allTasks.map((t, i) => { const p = TF_PRIORITY[t.priority] || TF_PRIORITY.medium; const cfg = COLUMN_CONFIG.find(c => c.id === t.status) || COLUMN_CONFIG[1]; const isOverdue = t.due_date && new Date(t.due_date) < new Date() && t.status !== 'done'; return ( onEdit && onEdit(t)} style={{ cursor: 'pointer', transition: 'background var(--dur-fast)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--blue-50)'} onMouseLeave={e => e.currentTarget.style.background = ''}> ); })}
Title Status Priority Assignee Due date Project
{ e.stopPropagation(); onToggle && onToggle(t); }} title={t.done ? 'Mark as not done' : 'Mark as done'} style={{ width: 14, height: 14, borderRadius: 3, border: `2px solid ${t.done ? 'var(--green-400)' : 'var(--border-strong)'}`, background: t.done ? 'var(--green-400)' : 'transparent', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, cursor: 'pointer' }}> {t.done && } {t.title}
{cfg.label} {p.label} {t.assigned_to_name ?
{t.assigned_to_name}
: }
{fmtDateFull(t.due_date)} {t.project_name ? {t.project_name} : }
); } function TaskCalView({ allTasks, onAdd, onEdit }) { const today = new Date(); const [year, setYear] = React.useState(today.getFullYear()); const [month, setMonth] = React.useState(today.getMonth()); const monthName = new Date(year, month, 1).toLocaleDateString('en', { month: 'long', year: 'numeric' }); // Build days grid (Mon-Sun, 6 rows max) const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); let startOffset = firstDay.getDay() - 1; // Mon=0 if (startOffset < 0) startOffset = 6; const days = []; for (let i = 0; i < startOffset; i++) days.push(null); for (let d = 1; d <= lastDay.getDate(); d++) days.push(d); while (days.length % 7 !== 0) days.push(null); // Group tasks by date string const tasksByDate = {}; allTasks.forEach(t => { if (!t.due_date) return; const key = t.due_date.slice(0, 10); if (!tasksByDate[key]) tasksByDate[key] = []; tasksByDate[key].push(t); }); function prev() { if (month === 0) { setYear(y => y - 1); setMonth(11); } else setMonth(m => m - 1); } function next() { if (month === 11) { setYear(y => y + 1); setMonth(0); } else setMonth(m => m + 1); } function goToday() { setYear(today.getFullYear()); setMonth(today.getMonth()); } const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; return (
{/* Nav — wraps on a phone so the Add-task button never overflows the screen. */}
{monthName} {onAdd && }
{/* Grid */}
{/* DOW headers */}
{DOW.map(d => (
{d}
))}
{/* Day cells */}
{days.map((day, i) => { if (!day) return
; const dateStr = `${year}-${String(month + 1).padStart(2,'0')}-${String(day).padStart(2,'0')}`; const dayTasks = tasksByDate[dateStr] || []; const isToday = day === today.getDate() && month === today.getMonth() && year === today.getFullYear(); return (
{day}
{dayTasks.slice(0, 3).map((t, ti) => { const p = TF_PRIORITY[t.priority] || TF_PRIORITY.medium; return (
{ e.stopPropagation(); onEdit && onEdit(t); }} style={{ fontSize: 11, fontWeight: 'var(--fw-semibold)', color: p.color, background: p.bg, borderRadius: 3, padding: '2px 5px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', cursor: 'pointer' }}> {t.title}
); })} {dayTasks.length > 3 && (
+{dayTasks.length - 3} more
)}
); })}
); } function AttachArea({ files, onChange }) { const ref = React.useRef(); function add(e) { const picked = Array.from(e.target.files || []); onChange(prev => [...prev, ...picked]); e.target.value = ''; } function remove(name) { onChange(prev => prev.filter(f => f.name !== name)); } return (
{files.length > 0 && (
{files.map(f => (
{f.name} {f.size > 1048576 ? (f.size/1048576).toFixed(1)+' MB' : Math.round(f.size/1024)+' KB'}
))}
)}
); } /** * Download an attachment to disk. * * The plain `download` attribute does NOT work here: the files live on * api.techyfuel.com while the app runs on os.techyfuel.com, and browsers * silently ignore `download` cross-origin — the file just opened in a new tab * instead of saving. So fetch the bytes and save them from a blob: URL, which * is same-origin and honours the filename. Falls back to opening the file if * the fetch is blocked for any reason. */ async function downloadAttachment(file, setBusy) { if (!file || !file.url) return; try { if (setBusy) setBusy(file.id); // Cache-bust the request. The edge cache still holds responses stored // BEFORE the storage CORS headers were added, and a cached response with no // Access-Control-Allow-Origin fails the cross-origin fetch outright // (MissingAllowOriginHeader) even though the origin now sends the header. // A unique query string forces a fresh, CORS-correct response. const bust = file.url + (file.url.indexOf('?') === -1 ? '?' : '&') + 'dl=' + Date.now(); const res = await fetch(bust, { mode: 'cors', credentials: 'omit', cache: 'no-store' }); if (!res.ok) throw new Error('HTTP ' + res.status); const blob = await res.blob(); const href = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = href; a.download = file.name || 'attachment'; document.body.appendChild(a); a.click(); a.remove(); // Revoke a tick later — Safari needs the URL alive during the click. setTimeout(() => URL.revokeObjectURL(href), 4000); } catch (e) { window.open(file.url, '_blank', 'noopener'); } finally { if (setBusy) setBusy(null); } } // Human file size for the attachment rows. function fmtFileSize(n) { const b = Number(n) || 0; if (!b) return ''; if (b < 1024) return b + ' B'; if (b < 1024 * 1024) return (b / 1024).toFixed(0) + ' KB'; return (b / 1024 / 1024).toFixed(1) + ' MB'; } // Upload each attachment and link it to the task in ONE call (uploadFile // stores the file and its DB record together). Returns any error messages so // the caller can tell the user instead of silently hanging on "Saving…". const MAX_UPLOAD_MB = 50; async function uploadTaskFiles(taskId, files) { const errors = []; if (!window.API || !files || !files.length) return errors; for (const file of files) { if (file.size > MAX_UPLOAD_MB * 1048576) { errors.push(`${file.name} is over ${MAX_UPLOAD_MB}MB and was skipped.`); continue; } try { await window.API.uploadFile('files', null, file, { task_id: taskId }); } catch (e) { errors.push(`${file.name}: ${e.message || 'upload failed'}`); } } return errors; } function TasksBoard() { useLucide(); // Plain members can view their tasks but not create or delete them. // Whitelist, not "!== member": before the role resolves TFMyRole is null and // "null !== member" would briefly show Add/Delete to a member. Only these // three roles ever manage the board. const canManageTasks = ['owner', 'admin', 'manager'].includes(window.TFMyRole); const [activeTab, setActiveTab] = React.useState('kanban'); const [taskMap, setTaskMap] = React.useState(EMPTY_TASKS); const [allTasks, setAllTasks] = React.useState([]); const [totalOpen, setTotalOpen] = React.useState(0); const [loading, setLoading] = React.useState(true); const [modalOpen, setModalOpen] = React.useState(false); const [saving, setSaving] = React.useState(false); const [addTaskError, setAddTaskError] = React.useState(''); const [team, setTeam] = React.useState([]); const [projects, setProjects] = React.useState([]); const [clients, setClients] = React.useState([]); const [form, setForm] = React.useState({ title: '', description: '', priority: 'medium', status: 'todo', due_date: '', assigned_to: '', project_id: '', milestone_id: '', client_id: '', is_recurring: false, recurrence_interval: 'weekly' }); // Milestones of the project currently selected in the New-task form. A task may // sit under one of its project's milestones, or under none at all. const [formMilestones, setFormMilestones] = React.useState([]); // Same, for the edit-task modal (an existing task can be moved between milestones). const [editMilestones, setEditMilestones] = React.useState([]); const [attachments, setAttachments] = React.useState([]); // Edit task state const [editTask, setEditTask] = React.useState(null); const [editForm, setEditForm] = React.useState({}); const [editSaving, setEditSaving] = React.useState(false); const [editAttachments, setEditAttachments] = React.useState([]); // NEW files being added const [editExistingFiles, setEditExistingFiles] = React.useState([]); // files already saved on this task const [dlBusyId, setDlBusyId] = React.useState(null); // attachment currently downloading const [dlAll, setDlAll] = React.useState(false); // bulk download in progress const [editFilesLoading, setEditFilesLoading] = React.useState(false); const [newSubtask, setNewSubtask] = React.useState(''); async function addSubtask() { if (!newSubtask.trim() || !editTask || !window.API) return; try { const { data } = await window.API.createTask({ title: newSubtask.trim(), parent_task_id: editTask.id, status: 'todo', priority: 'medium', project_id: editTask.project_id || null }); if (data) setAllTasks(prev => [...prev, data]); setNewSubtask(''); } catch { alert('Could not add subtask.'); } } async function toggleSubtask(st) { const next = st.status === 'done' ? 'todo' : 'done'; setAllTasks(prev => prev.map(t => t.id===st.id ? { ...t, status: next } : t)); try { await window.API.updateTask(st.id, { status: next }); } catch {} } const [reviewFeedback, setReviewFeedback] = React.useState(null); // latest approval_requests row, once resolved // Time tracking — the one entry (if any) currently running for me, and // this task's logged total, both refreshed whenever the edit modal opens. const [runningEntry, setRunningEntry] = React.useState(null); const [taskTotalSeconds, setTaskTotalSeconds] = React.useState(0); const [timerBusy, setTimerBusy] = React.useState(false); const [timerTick, setTimerTick] = React.useState(0); const [timerError, setTimerError] = React.useState(''); React.useEffect(() => { if (!runningEntry) return; const t = setInterval(() => setTimerTick(n => n + 1), 1000); return () => clearInterval(t); }, [runningEntry]); // Kanban drag-and-drop const [draggedId, setDraggedId] = React.useState(null); const [dragOverCol, setDragOverCol] = React.useState(null); // Approve/Send back feedback prompt: { task, approved } while open, else null const [reviewPrompt, setReviewPrompt] = React.useState(null); const [reviewComment, setReviewComment] = React.useState(''); const [reviewSaving, setReviewSaving] = React.useState(false); async function confirmReviewDecision() { if (!reviewPrompt) return; setReviewSaving(true); try { await resolveTaskApproval(reviewPrompt.task, reviewPrompt.approved, reviewComment.trim()); setReviewPrompt(null); setReviewComment(''); } finally { setReviewSaving(false); } } async function loadTaskFiles(taskId) { if (!window.API || !window.API.getFiles || !taskId || String(taskId).startsWith('f')) { setEditExistingFiles([]); return; } setEditFilesLoading(true); try { const { data } = await window.API.getFiles({ taskId }); setEditExistingFiles(Array.isArray(data) ? data : []); } catch { setEditExistingFiles([]); } setEditFilesLoading(false); } async function removeTaskFile(fileId) { if (!window.confirm('Remove this attachment?')) return; setEditExistingFiles(prev => prev.filter(f => f.id !== fileId)); try { await window.API.deleteFile?.(fileId); } catch {} } function openEdit(task) { setEditTask(task); setEditForm({ title: task.title, description: task.description || '', priority: task.priority || 'medium', status: task.status || 'todo', due_date: task.due_date || '', assigned_to: task.assigned_to || '', client_id: task.client_id || '', task_type: task.task_type || 'task', progress: task.progress ?? 0, start_date: task.start_date ? String(task.start_date).slice(0,10) : '', estimated_hours: task.estimated_hours ?? '', actual_hours: task.actual_hours ?? '', project_id: task.project_id || '', milestone_id: task.milestone_id || '', checklist: Array.isArray(task.checklist) ? task.checklist : [], tags: Array.isArray(task.tags) ? task.tags : [], billable: !!task.billable, watchers: Array.isArray(task.watchers) ? task.watchers : [], blocked_by: Array.isArray(task.blocked_by) ? task.blocked_by : [], task_code: task.task_code || '', reminder_at: task.reminder_at ? String(task.reminder_at).replace(' ','T').slice(0,16) : '' }); // Load the milestones of whatever project this task is on, so the picker can // show its current milestone instead of an empty list. setEditMilestones([]); if (task.project_id && window.API && window.API.getMilestones) { window.API.getMilestones(task.project_id) .then(r => { if (r && r.data) setEditMilestones(r.data); }) .catch(() => {}); } setEditAttachments([]); setEditExistingFiles([]); loadTaskFiles(task.id); setTimerError(''); setReviewFeedback(null); refreshTimeTracking(task.id); if (task.approval_status === 'approved' || task.approval_status === 'rejected') { window.API?.getLatestApprovalForTask?.(task.id).then(({ data }) => { if (data) setReviewFeedback(data); }).catch(() => {}); } } function setEF(k, v) { setEditForm(f => ({ ...f, [k]: v })); } async function refreshTimeTracking(taskId) { if (!window.API || !window.API.getTimeEntriesForTask) return; try { const [{ data: entries }, { data: running }] = await Promise.all([ window.API.getTimeEntriesForTask(taskId), window.API.getRunningTimeEntry ? window.API.getRunningTimeEntry(window.TFMyMemberId) : Promise.resolve({ data: null }), ]); setTaskTotalSeconds((entries || []).reduce((s, e) => s + (e.duration_seconds || 0), 0)); setRunningEntry(running || null); } catch {} } async function handleStartTimer(taskId) { if (timerBusy) return; if (!window.API || !window.TFMyMemberId) { setTimerError('Could not identify your account. Please refresh the page and try again.'); return; } setTimerBusy(true); setTimerError(''); try { const { data, error } = await window.API.startTimeEntry(taskId, window.TFMyMemberId); if (error) { setTimerError(error.message || 'Could not start the timer. Please try again.'); return; } if (data) setRunningEntry(data); } finally { setTimerBusy(false); } } async function handleStopTimer() { if (!window.API || !runningEntry || timerBusy) return; setTimerBusy(true); setTimerError(''); try { const { data, error } = await window.API.stopTimeEntry(runningEntry.id); if (error) { setTimerError(error.message || 'Could not stop the timer. Please try again.'); return; } setRunningEntry(null); if (data && editTask && data.task_id === editTask.id) { setTaskTotalSeconds(s => s + (data.duration_seconds || 0)); } } finally { setTimerBusy(false); } } // Marking a task done while its timer is still running would otherwise // keep counting silently in the background with no way to stop it once // the task leaves the board's "in progress" view -- so completing a task // always stops its own timer first. async function stopTimerIfRunningOnTask(taskId) { if (!window.API || !runningEntry || runningEntry.task_id !== taskId) return; try { const { data } = await window.API.stopTimeEntry(runningEntry.id); setRunningEntry(null); if (data && editTask && data.task_id === editTask.id) { setTaskTotalSeconds(s => s + (data.duration_seconds || 0)); } } catch {} } function fmtDuration(totalSeconds) { const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); if (h === 0) return `${m}m`; return `${h}h ${m}m`; } // Second-level ticking clock (unlike fmtDuration, which rounds to minutes // and would look frozen at "0m" for the timer's entire first minute). function fmtClock(totalSeconds) { const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); const s = totalSeconds % 60; const pad = n => String(n).padStart(2, '0'); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`; } async function moveTask(task, newStatus) { if (!task || task.status === newStatus) return; const updated = { ...task, status: newStatus, done: newStatus === 'done' }; setAllTasks(prev => prev.map(t => t.id === task.id ? updated : t)); setTaskMap(prev => { const next = {}; COLUMN_CONFIG.forEach(c => { next[c.id] = (prev[c.id] || []).filter(t => t.id !== task.id); }); next[newStatus] = [...(next[newStatus] || []), updated]; return next; }); const wasOpen = task.status !== 'done', isOpen = newStatus !== 'done'; if (wasOpen && !isOpen) setTotalOpen(prev => Math.max(0, prev - 1)); if (!wasOpen && isOpen) setTotalOpen(prev => prev + 1); if (newStatus === 'done') await stopTimerIfRunningOnTask(task.id); if (window.API && task.id && !String(task.id).startsWith('f')) { try { await window.API.updateTask(task.id, { status: newStatus }); } catch {} } if (newStatus === 'review') await submitForReview(task); } function toggleTaskDone(task) { return moveTask(task, task.status === 'done' ? 'todo' : 'done'); } function patchTaskLocal(taskId, changes) { setAllTasks(prev => prev.map(t => t.id === taskId ? { ...t, ...changes } : t)); setTaskMap(prev => { const next = {}; COLUMN_CONFIG.forEach(c => { next[c.id] = (prev[c.id] || []).map(t => t.id === taskId ? { ...t, ...changes } : t); }); return next; }); } // Marks a task as awaiting sign-off from whoever created/assigned it — // this is the actual "submission" a team member does: moving a task to // Review creates a real approval_requests row + notifies the creator, // instead of the status change silently going nowhere. async function submitForReview(task) { if (!window.API || !task.created_by || task.created_by === window.TFMyMemberId) return; try { await window.API.createTaskApproval({ task_id: task.id, requested_by: window.TFMyMemberId, approver_id: task.created_by, status: 'pending' }); await window.API.updateTask(task.id, { requires_approval: true, approval_status: 'pending' }); patchTaskLocal(task.id, { approval_status: 'pending' }); const me = team.find(m => m.id === window.TFMyMemberId); if (window.API.createNotification) { await window.API.createNotification({ recipient_id: task.created_by, type: 'approval', title: `${me?.name || 'A team member'} submitted "${task.title}" for review`, body: 'Tap to review and approve, or send it back.', link_screen: 'tasks', link_id: task.id, }); } } catch {} } async function resolveTaskApproval(task, approved, comment) { if (!window.API) return; try { const { data: pending } = await window.API.getPendingApprovalForTask(task.id); if (!pending) return; const newStatus = approved ? 'done' : 'in_progress'; await window.API.resolveApproval(pending.id, approved ? 'approved' : 'rejected', comment || '', task.id, newStatus); if (approved) await stopTimerIfRunningOnTask(task.id); patchTaskLocal(task.id, { status: newStatus, done: approved, approval_status: approved ? 'approved' : 'rejected' }); setTaskMap(prev => { const next = {}; COLUMN_CONFIG.forEach(c => { next[c.id] = (prev[c.id] || []).filter(t => t.id !== task.id); }); next[newStatus] = [...(next[newStatus] || []), { ...task, status: newStatus, done: approved, approval_status: approved ? 'approved' : 'rejected' }]; return next; }); if (pending.requested_by && window.API.createNotification) { const me = team.find(m => m.id === window.TFMyMemberId); const verb = approved ? 'approved' : 'sent back'; await window.API.createNotification({ recipient_id: pending.requested_by, type: 'approval', title: `${me?.name || 'A reviewer'} ${verb} "${task.title}"`, body: comment || (approved ? 'No additional feedback.' : 'Take another look and resubmit when ready.'), link_screen: 'tasks', link_id: task.id, }); } } catch {} } async function handleUpdateTask() { if (!editTask || !editForm.title?.trim()) return; setEditSaving(true); try { const changes = { title: editForm.title, description: editForm.description || null, priority: editForm.priority, status: editForm.status, due_date: editForm.due_date || null, assigned_to: editForm.assigned_to || null, client_id: editForm.client_id || null, task_type: editForm.task_type || 'task', progress: Number(editForm.progress) || 0, start_date: editForm.start_date || null, estimated_hours: editForm.estimated_hours === '' ? null : Number(editForm.estimated_hours), actual_hours: editForm.actual_hours === '' ? null : Number(editForm.actual_hours), project_id: editForm.project_id || null, milestone_id: editForm.milestone_id || null, checklist: editForm.checklist || [], tags: editForm.tags || [], billable: !!editForm.billable, watchers: editForm.watchers || [], blocked_by: editForm.blocked_by || [], reminder_at: editForm.reminder_at || null }; // Auto-set completed date + 100% when marked done. if (changes.status === 'done') { changes.progress = 100; changes.completed_date = new Date().toISOString().slice(0,10); } if (changes.status === 'done') await stopTimerIfRunningOnTask(editTask.id); if (window.API && editTask.id && !editTask.id.startsWith('f')) { const { error } = await window.API.updateTask(editTask.id, changes); if (error) { setEditSaving(false); return; } } // Update local state const assigneeName = team.find(m => m.id === changes.assigned_to)?.name || editTask.assigned_to_name || null; const updated = { ...editTask, ...changes, done: changes.status === 'done', assigned_to_name: assigneeName }; setAllTasks(prev => prev.map(t => t.id === editTask.id ? updated : t)); setTaskMap(prev => { const next = {}; COLUMN_CONFIG.forEach(c => { next[c.id] = (prev[c.id] || []).filter(t => t.id !== editTask.id); }); const col = updated.status || 'todo'; next[col] = [...(next[col] || []), updated]; return next; }); const openCount = t => t.status !== 'done'; setTotalOpen(prev => { const wasOpen = openCount(editTask); const isOpen = openCount(updated); if (wasOpen && !isOpen) return prev - 1; if (!wasOpen && isOpen) return prev + 1; return prev; }); const upErr = await uploadTaskFiles(editTask.id, editAttachments); if (upErr.length) window.alert('Task saved, but some files failed:\n' + upErr.join('\n')); setEditAttachments([]); setEditTask(null); if (changes.status === 'review' && editTask.status !== 'review') await submitForReview(updated); } finally { setEditSaving(false); } } async function handleDeleteTask() { if (!editTask) return; if (!window.confirm(`Delete "${editTask.title}"? This cannot be undone.`)) return; setEditSaving(true); try { // Only call the API for real (server) tasks; local-only drafts start 'f'. if (window.API && editTask.id && !editTask.id.startsWith('f')) { const { error } = await window.API.deleteTask(editTask.id); if (error) { setEditSaving(false); return; } } // Remove from every local structure. setAllTasks(prev => prev.filter(t => t.id !== editTask.id)); setTaskMap(prev => { const next = {}; COLUMN_CONFIG.forEach(c => { next[c.id] = (prev[c.id] || []).filter(t => t.id !== editTask.id); }); return next; }); if (editTask.status !== 'done') setTotalOpen(prev => Math.max(0, prev - 1)); setEditTask(null); } finally { setEditSaving(false); } } function set(k, v) { setForm(f => ({ ...f, [k]: v })); } React.useEffect(() => { if (!window.API) { setLoading(false); return; } (async () => { try { const { data } = await window.API.getTasks(); if (data) { const map = { backlog: [], todo: [], in_progress: [], review: [], done: [] }; const flat = []; data.forEach(t => { const key = t.status || 'todo'; if (!map[key]) map[key] = []; const task = { id: t.id, title: t.title, description: t.description || '', priority: t.priority, due_date: t.due_date, status: t.status || 'todo', done: t.status === 'done', assigned_to: t.assigned_to, client_id: t.client_id || null, is_recurring: t.is_recurring || false, assigned_to_name: t.assignee ? t.assignee.name : (t.team_members ? t.team_members.name : null), project_name: t.project ? t.project.name : (t.projects ? t.projects.name : null), created_by: t.created_by || null, approval_status: t.approval_status || null }; map[key].push(task); flat.push(task); }); setTaskMap(map); setAllTasks(flat); setTotalOpen(map.todo.length + map.in_progress.length + map.review.length + map.backlog.length); } } catch {} setLoading(false); })(); (async () => { try { const { data } = await window.API.getTeam(); if (data) setTeam(data); } catch {} })(); (async () => { try { const { data } = await window.API.getProjects(); if (data) setProjects(data); } catch {} })(); (async () => { try { const { data } = await window.API.getClients(); if (data) setClients(data); } catch {} })(); (async () => { if (!window.API.getRunningTimeEntry || !window.TFMyMemberId) return; try { const { data } = await window.API.getRunningTimeEntry(window.TFMyMemberId); setRunningEntry(data || null); } catch {} })(); }, []); async function handleAddTask() { if (!form.title.trim()) return; setSaving(true); setAddTaskError(''); try { const payload = { title: form.title, description: form.description || null, priority: form.priority, status: form.status }; if (form.due_date) payload.due_date = form.due_date; if (form.assigned_to) payload.assigned_to = form.assigned_to; if (form.project_id) payload.project_id = form.project_id; if (form.milestone_id) payload.milestone_id = form.milestone_id; if (form.client_id) payload.client_id = form.client_id; payload.is_recurring = !!form.is_recurring; payload.recurrence_interval = form.is_recurring ? form.recurrence_interval : null; payload.next_run_date = form.is_recurring ? addRecurrenceInterval(form.due_date, form.recurrence_interval) : null; if (window.API) { const { data, error } = await window.API.createTask(payload); if (error) { setAddTaskError(error.message || 'Could not create the task. Please try again.'); return; } if (data) { const assigneeName = team.find(m => m.id === form.assigned_to)?.name || null; const projectName = projects.find(p => p.id === form.project_id)?.name || null; const clientName = clients.find(c => c.id === form.client_id)?.company || clients.find(c => c.id === form.client_id)?.name || null; const newTask = { id: data.id, title: data.title, priority: data.priority, due_date: data.due_date, status: data.status || 'todo', done: false, assigned_to: form.assigned_to || null, client_id: form.client_id || null, client_name: clientName, assigned_to_name: assigneeName, project_name: projectName, attachment_count: attachments.length, created_by: data.created_by || null, approval_status: null }; setTaskMap(prev => ({ ...prev, [newTask.status]: [...(prev[newTask.status] || []), newTask] })); setAllTasks(prev => [...prev, newTask]); if (newTask.status !== 'done') setTotalOpen(prev => prev + 1); const upErrors = await uploadTaskFiles(data.id, attachments); if (upErrors.length) window.alert('Task saved, but some files failed:\n' + upErrors.join('\n')); if (newTask.status === 'review') await submitForReview(newTask); } } setModalOpen(false); setForm({ title: '', description: '', priority: 'medium', status: 'todo', due_date: '', assigned_to: '', project_id: '', milestone_id: '', client_id: '', is_recurring: false, recurrence_interval: 'weekly' }); setAttachments([]); } finally { setSaving(false); } } return (

Tasks

All projects · {totalOpen} open tasks

{canManageTasks && ( )}
}, { id: 'kanban', label: 'Board', icon: , count: totalOpen }, { id: 'calendar', label: 'Calendar', icon: }, ]} />
{loading &&
Loading…
} {!loading && activeTab === 'kanban' && (
{COLUMN_CONFIG.map(col => { const colTasks = taskMap[col.id] || []; return (
{col.label} {colTasks.length} {canManageTasks && { set('status', col.id === 'backlog' ? 'todo' : col.id); setModalOpen(true); }} style={{ color: 'var(--text-subtle)', marginLeft: 'auto', cursor: 'pointer' }} />}
{ e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dragOverCol !== col.id) setDragOverCol(col.id); }} onDragLeave={() => setDragOverCol(prev => prev === col.id ? null : prev)} onDrop={e => { e.preventDefault(); const taskId = e.dataTransfer.getData('text/plain'); const task = allTasks.find(t => t.id === taskId); moveTask(task, col.id); setDragOverCol(null); setDraggedId(null); }} style={{ display: 'flex', flexDirection: 'column', gap: 10, borderRadius: 'var(--radius-xl)', padding: 10, minHeight: 120, background: dragOverCol === col.id ? 'var(--blue-50)' : 'var(--slate-100)', outline: dragOverCol === col.id ? '2px dashed var(--blue-300)' : '2px dashed transparent', outlineOffset: -2, transition: 'background var(--dur-fast), outline-color var(--dur-fast)' }}> {colTasks.map((t, i) => ( setDraggedId(t.id)} onDragEnd={() => { setDraggedId(null); setDragOverCol(null); }} onApprove={t2 => setReviewPrompt({ task: t2, approved: true })} onReject={t2 => setReviewPrompt({ task: t2, approved: false })} /> ))} {canManageTasks && ( )}
); })}
)} {!loading && activeTab === 'list' && ( setModalOpen(true) : null} onEdit={openEdit} onToggle={toggleTaskDone} /> )} {!loading && activeTab === 'calendar' && ( setModalOpen(true) : null} onEdit={openEdit} /> )} setEditTask(null)} title="Edit task" onSubmit={handleUpdateTask} loading={editSaving} submitLabel="Save changes"> {reviewFeedback && (
{(reviewFeedback.approver?.name || reviewFeedback.team_members?.name) || 'Reviewer'} {reviewFeedback.status === 'approved' ? 'approved this' : 'sent this back'}
{reviewFeedback.comment &&
{reviewFeedback.comment}
}
)} setEF('title', e.target.value)} />