// 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) && (
remove(c.id)} title="Delete" style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-subtle)', padding: 0, display: 'inline-flex' }}>
)}
{c.image_path &&
}
{c.body &&
{c.body}
}
))}
{pendingImg && (
{pendingImg.file.name}
setPendingImg(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-subtle)', padding: 0, display: 'inline-flex' }}>
)}
{/* 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]) => (
setVisibility(v)}
style={{ height: 26, padding: '0 10px', borderRadius: 'var(--radius-sm)', cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-2xs)', fontWeight: 'var(--fw-bold)',
border: '1px solid ' + (visibility===v ? 'var(--blue-600)' : 'var(--border-subtle)'),
background: visibility===v ? 'var(--blue-50)' : 'transparent',
color: visibility===v ? 'var(--blue-700)' : 'var(--text-muted)' }}>{label}
))}
imgRef.current.click()} title="Attach image"
style={{ height: 36, width: 36, flexShrink: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', color: 'var(--text-muted)', cursor: 'pointer' }}>
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' }} />
{sending ? '…' : 'Send'}
);
}
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}
{canReview && (
{ e.stopPropagation(); onApprove && onApprove(task); }}
style={{ flex: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5, height: 28, background: 'var(--green-600)', color: '#fff', border: 'none', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-semibold)', cursor: 'pointer' }}>
Approve
{ e.stopPropagation(); onReject && onReject(task); }}
style={{ flex: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5, height: 28, background: 'transparent', color: 'var(--red-600)', border: '1px solid var(--red-200)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-semibold)', cursor: 'pointer' }}>
Send back
)}
);
}
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 &&
Add task
}
);
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. */}
Title
Status
Priority
Assignee
Due date
Project
{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 = ''}>
{ 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
?
: —
}
{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}
Today
{onAdd &&
Add task
}
{/* Grid */}
{/* DOW headers */}
{/* 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 (
ref.current.click()}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, height: 32, padding: '0 12px', border: '1px dashed var(--border-strong)', borderRadius: 'var(--radius-md)', background: 'transparent', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-semibold)', color: 'var(--text-muted)', cursor: 'pointer' }}>
Attach files
{files.length > 0 && (
{files.map(f => (
{f.name}
{f.size > 1048576 ? (f.size/1048576).toFixed(1)+' MB' : Math.round(f.size/1024)+' KB'}
remove(f.name)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-subtle)', padding: 0, display: 'inline-flex' }}>
))}
)}
);
}
/**
* 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 && (
setModalOpen(true)} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, height: 36, padding: '0 14px', background: 'var(--blue-600)', color: '#fff', border: 'none', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-brand)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', fontWeight: 'var(--fw-semibold)', cursor: 'pointer' }}>
Add task
)}
},
{ 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 && (
{ set('status', col.id === 'backlog' ? 'todo' : col.id); setModalOpen(true); }}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, padding: '8px 0', background: 'transparent', border: '1px dashed var(--border-strong)', borderRadius: 'var(--radius-md)', color: 'var(--text-muted)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-xs)', fontWeight: 'var(--fw-semibold)', cursor: 'pointer' }}>
Add task
)}
);
})}
)}
{!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)} />
setEF('status', e.target.value)}>
Backlog
To do
In progress
Review
Done
setEF('priority', e.target.value)}>
Low
Medium
High
Urgent
setEF('task_type', e.target.value)}>
Task
Bug
Feature
Improvement
setEF('progress', Number(e.target.value))} style={{ width: '100%' }} />
setEF('due_date', e.target.value)} />
setEF('assigned_to', e.target.value)}>
Unassigned
{team.map(m => {m.name} )}
{/* Editing the project clears the milestone: a milestone belongs to one
project, so keeping the old one would point at another project's work. */}
{
setEF('project_id', e.target.value);
setEF('milestone_id', '');
setEditMilestones([]);
if (e.target.value && window.API && window.API.getMilestones) {
window.API.getMilestones(e.target.value)
.then(r => { if (r && r.data) setEditMilestones(r.data); })
.catch(() => {});
}
}}>
No project
{projects.map(p => {p.name} )}
setEF('milestone_id', e.target.value)}>
{!editForm.project_id ? 'Pick a project first' : (editMilestones.length ? 'No milestone' : 'No milestones yet')}
{editMilestones.map(m => {m.title} )}
setEF('start_date', e.target.value)} />
setEF('estimated_hours', e.target.value)} placeholder="e.g. 8" />
setEF('tags', e.target.value.split(',').map(s => s.trim()).filter(Boolean))} placeholder="Comma separated — e.g. design, urgent" />
{(editForm.checklist || []).map((item, i) => (
{ const cl = [...editForm.checklist]; cl[i] = { ...cl[i], done: e.target.checked }; setEF('checklist', cl); }} />
{ const cl = [...editForm.checklist]; cl[i] = { ...cl[i], text: e.target.value }; setEF('checklist', cl); }} placeholder="Checklist item…" />
setEF('checklist', editForm.checklist.filter((_, j) => j !== i))} style={{ width: 26, height: 26, border: '1px solid var(--red-200)', borderRadius: 'var(--radius-sm)', background: 'transparent', color: 'var(--red-600)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
))}
setEF('checklist', [...(editForm.checklist || []), { text: '', done: false }])} style={{ alignSelf: 'flex-start', display: 'inline-flex', alignItems: 'center', gap: 5, height: 30, padding: '0 10px', border: '1px dashed var(--border-default)', borderRadius: 'var(--radius-sm)', background: 'var(--slate-0)', cursor: 'pointer', fontSize: 'var(--text-xs)', color: 'var(--text-body)', fontFamily: 'var(--font-sans)' }}> Add item
{editForm.task_code && (
Task ID: {editForm.task_code}
)}
setEF('billable', e.target.checked)} /> Mark this task as billable
{team.map(m => {
const on = (editForm.watchers || []).includes(m.id);
return setEF('watchers', on ? editForm.watchers.filter(x=>x!==m.id) : [...(editForm.watchers||[]), m.id])} style={{ height: 28, padding: '0 10px', borderRadius: 'var(--radius-full)', border: '1px solid ' + (on?'var(--blue-300)':'var(--border-default)'), background: on?'var(--blue-50)':'var(--slate-0)', color: on?'var(--blue-700)':'var(--text-muted)', cursor: 'pointer', fontSize: 'var(--text-xs)', fontFamily: 'var(--font-sans)' }}>{on?'✓ ':''}{m.name} ;
})}
{team.length === 0 && No team members. }
{(editForm.blocked_by || []).map((bid, i) => {
const bt = allTasks.find(t => t.id === bid);
return
{bt ? bt.title : 'Task'}
setEF('blocked_by', editForm.blocked_by.filter(x=>x!==bid))} style={{ background:'none', border:'none', cursor:'pointer', color:'var(--red-500)' }}>
;
})}
{ if (e.target.value && !(editForm.blocked_by||[]).includes(e.target.value)) setEF('blocked_by', [...(editForm.blocked_by||[]), e.target.value]); }}>
+ Add a blocking task…
{allTasks.filter(t => t.id !== editTask?.id && !(editForm.blocked_by||[]).includes(t.id)).slice(0,50).map(t => {t.title} )}
setEF('reminder_at', e.target.value)} />
{editTask && (
{allTasks.filter(t => t.parent_task_id === editTask.id).map(st => (
toggleSubtask(st)} style={{ width: 18, height: 18, borderRadius: 4, border: '2px solid ' + (st.status==='done'?'var(--green-600)':'var(--border-default)'), background: st.status==='done'?'var(--green-600)':'transparent', cursor: 'pointer', display:'inline-flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}>{st.status==='done' && }
{st.title}
))}
setNewSubtask(e.target.value)} onKeyDown={e => { if (e.key==='Enter'){ e.preventDefault(); addSubtask(); } }} placeholder="New subtask…" style={{ flex: 1, height: 34, padding: '0 10px', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-sm)', fontSize: 'var(--text-sm)', fontFamily: 'var(--font-sans)', outline: 'none' }} />
Add
)}
setEF('client_id', e.target.value)}>
Agency only
{clients.map(c => {c.company || c.name} )}
{(() => {
const runningOnThis = runningEntry && editTask && runningEntry.task_id === editTask.id;
const runningOnOther = runningEntry && editTask && runningEntry.task_id !== editTask.id;
const liveSeconds = runningOnThis ? Math.floor((Date.now() - new Date(runningEntry.started_at)) / 1000) : 0;
return (
{runningOnThis && (
{fmtClock(liveSeconds)}
this session
)}
{runningOnThis ? (
Stop
) : (
handleStartTimer(editTask.id)} disabled={timerBusy || runningOnOther} title={runningOnOther ? 'Stop your timer on the other task first' : ''} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, height: 34, padding: '0 14px', background: runningOnOther ? 'var(--slate-100)' : 'var(--blue-50)', color: runningOnOther ? 'var(--text-subtle)' : 'var(--blue-600)', border: `1px solid ${runningOnOther ? 'var(--border-subtle)' : 'var(--blue-100)'}`, borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-sm)', fontWeight: 'var(--fw-semibold)', cursor: (timerBusy || runningOnOther) ? 'not-allowed' : 'pointer' }}>
Start timer
)}
{runningOnOther ? 'Timer running on another task' : `${fmtDuration(taskTotalSeconds + liveSeconds)} logged total`}
);
})()}
{timerError && (
{timerError}
)}
{/* Files already saved on this task (fetched on open) — this is what
was missing: uploads persisted but never loaded back after refresh. */}
{editFilesLoading && Loading attachments…
}
{!editFilesLoading && editExistingFiles.length > 0 && (
{/* One click for the whole set — tasks routinely carry 5-6 posts. */}
{editExistingFiles.length > 1 && (
{
setDlAll(true);
// Sequential with a small gap: browsers drop simultaneous
// programmatic downloads, and this keeps the API calm.
for (const f of editExistingFiles) {
await downloadAttachment(f, setDlBusyId);
await new Promise(r => setTimeout(r, 400));
}
setDlAll(false);
}} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, height: 26, padding: '0 10px', background: 'transparent', border: 'none', cursor: dlAll ? 'default' : 'pointer', color: 'var(--blue-700)', fontSize: 'var(--text-2xs)', fontWeight: 'var(--fw-bold)', fontFamily: 'var(--font-sans)' }}>
{dlAll ? 'Downloading…' : `Download all (${editExistingFiles.length})`}
)}
{editExistingFiles.map(f => {
const isImg = (f.file_type || f.mime_type || '').startsWith('image');
const size = fmtFileSize(f.file_size);
const busy = dlBusyId === f.id;
return (
{isImg && f.url
?
:
}
{f.name}{size ? · {size} : null}
{/* Explicit Download button — the old row only had a delete
✕, and the filename's `download` attribute was a no-op
cross-origin, so there was no way to save a file at all. */}
downloadAttachment(f, setDlBusyId)} title="Download"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, height: 26, padding: '0 9px', background: 'var(--slate-0)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-sm)', cursor: busy ? 'default' : 'pointer', color: 'var(--blue-700)', fontSize: 'var(--text-2xs)', fontWeight: 'var(--fw-semibold)', fontFamily: 'var(--font-sans)', flexShrink: 0, opacity: busy ? 0.6 : 1 }}>
{busy ? '…' : 'Download'}
removeTaskFile(f.id)} title="Remove" style={{ width: 24, height: 24, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--red-500)', flexShrink: 0 }}>
);
})}
)}
{editTask && editTask.id && !String(editTask.id).startsWith('f') && (
)}
{/* Danger zone — delete this task. Owner/admin/manager only; members
can view and update their tasks but not delete them. */}
{canManageTasks && (
Delete task
)}
{ setReviewPrompt(null); setReviewComment(''); }}
title={reviewPrompt?.approved ? 'Approve task' : 'Send task back'}
onSubmit={confirmReviewDecision} loading={reviewSaving}
submitLabel={reviewSaving ? 'Saving…' : (reviewPrompt?.approved ? 'Approve' : 'Send back')}>
{ setModalOpen(false); setAddTaskError(''); }} title="Add task" onSubmit={handleAddTask} loading={saving} submitLabel="Add task">
{addTaskError && (
{addTaskError}
)}
set('title', e.target.value)} />
set('priority', e.target.value)}>
Low
Medium
High
Urgent
set('status', e.target.value)}>
To do
In progress
Review
Done
set('due_date', e.target.value)} />
set('assigned_to', e.target.value)}>
Unassigned
{team.map(m => {m.name} )}
{
set('project_id', e.target.value);
// Changing project invalidates any milestone already picked.
set('milestone_id', '');
setFormMilestones([]);
if (e.target.value && window.API && window.API.getMilestones) {
window.API.getMilestones(e.target.value)
.then(r => { if (r && r.data) setFormMilestones(r.data); })
.catch(() => {});
}
}}>
No project
{projects.map(p => {p.name} )}
{/* Optional: a task without a milestone shows under "Direct Project Tasks". */}
set('milestone_id', e.target.value)}>
{!form.project_id ? 'Pick a project first' : (formMilestones.length ? 'No milestone' : 'No milestones yet')}
{formMilestones.map(m => {m.title} )}
set('client_id', e.target.value)}>
Agency only
{clients.map(c => {c.company || c.name} )}
set('is_recurring', e.target.checked)} />
Auto-create the next task
{form.is_recurring && (
set('recurrence_interval', e.target.value)}>
Daily
Weekly
Monthly
)}
);
}
Object.assign(window, { TasksBoard });
})();