// ============================================================ // Projects Module // ============================================================ let projectsState = { page: 1, search: '', status: '', priority: '' }; async function renderProjects(params = {}) { if (params.id) return renderProjectDetail(params.id); const content = document.getElementById('page-content'); content.innerHTML = `

Projects

Development, testing, and bug tracking

${Auth.can('projects','create') ? `` : ''}
`; await loadProjects(1); } const projSearchDebounced = debounce(val => { projectsState.search = val; loadProjects(1); }, 350); async function loadProjects(page = 1) { projectsState.page = page; const grid = document.getElementById('projects-grid'); if (!grid) return; const res = await API.post('projects/list', { page, limit: 12, search: projectsState.search, status: projectsState.status, priority: projectsState.priority, }); if (!res.success) { grid.innerHTML = `
${res.message}
`; return; } const projects = res.data.projects; if (!projects.length) { grid.innerHTML = `
No projects found
`; return; } grid.innerHTML = `
${projects.map(p => `
${Fmt.statusBadge(p.status)} ${Fmt.priorityBadge(p.priority)}

${p.name}

${p.client_name || 'Internal Project'}

${p.open_todos || 0} tasks ${p.open_bugs || 0} bugs ${p.member_count || 0}
${p.target_date ? `
🗓 Target: ${Fmt.date(p.target_date)}
` : ''}
`).join('')}
`; renderPagination('projects-pagination', res.data.pagination, `p => loadProjects(p)`); } function openAddProjectModal() { Modal.open({ id: 'add-project', title: 'New Project', size: 'modal-lg', body: `
`, footer: ` ` }); } async function submitAddProject() { const btn = document.querySelector('#modal-add-project .btn-primary'); if (btn) btn.classList.add('loading'); const data = getFormData('add-project-form'); const res = await API.post('projects/create', data); if (btn) btn.classList.remove('loading'); if (res.success) { Toast.show('Project created!', 'success'); Modal.close(); Router.navigate('projects', { id: res.data.id }); } else Toast.show(res.message, 'error'); } async function renderProjectDetail(id) { const content = document.getElementById('page-content'); const res = await API.post('projects/get', { id }); if (!res.success) { Toast.show(res.message, 'error'); return; } const p = res.data.project; content.innerHTML = `

${p.name}

${p.client_name || 'Internal'} · ${Fmt.capitalize(p.project_type)} · Managed by ${p.manager_name || '—'}
${Fmt.statusBadge(p.status)} ${Fmt.priorityBadge(p.priority)} ${Auth.can('projects','create') ? `` : ''}
${p.sections?.length ? p.sections.map(section => `

${section.name}

${Auth.can('projects','create') ? `` : ''}
${renderKanbanCol('todo', 'To Do', section.todos, p.id)} ${renderKanbanCol('in_progress', 'In Progress', section.todos, p.id)} ${renderKanbanCol('done', 'Done', section.todos, p.id)} ${renderKanbanCol('blocked', 'Blocked', section.todos, p.id)}
`).join('') : `
No sections yet

Add a section to start organising tasks.

${Auth.can('projects','create') ? `` : ''}
`}
`; } function renderKanbanCol(status, label, todos, projectId) { const filtered = todos?.filter(t => t.status === status) || []; const colColors = { todo: 'var(--info)', in_progress: 'var(--warning)', done: 'var(--success)', blocked: 'var(--danger)' }; return `
${label} ${filtered.length}
${filtered.map(t => `
${Fmt.priorityBadge(t.priority)} ${t.assigned_name ? `${t.assigned_name.split(' ')[0]}` : ''}
${t.title}
${t.due_date ? `
📅 ${Fmt.date(t.due_date)}
` : ''} ${t.estimated_hours ? `
⏱ ${t.estimated_hours}h est.
` : ''}
`).join('') || `
No tasks
`}
`; } function switchProjectTab(tab, btn) { ['kanban','bugs','tests','activity'].forEach(t => { const el = document.getElementById(`proj-tab-${t}`); if (el) el.style.display = t === tab ? '' : 'none'; }); document.querySelectorAll('#proj-tabs .tab-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); } function openAddSectionModal(projectId) { Modal.open({ id: 'add-section', title: 'Add Section', body: `
`, footer: ` ` }); } async function submitAddSection(projectId) { const data = getFormData('add-section-form'); const res = await API.post('projects/section_create', data); if (res.success) { Toast.show('Section added.', 'success'); Modal.close(); renderProjectDetail(projectId); } else Toast.show(res.message, 'error'); } function openAddTodoModal(sectionId, projectId) { Modal.open({ id: 'add-todo', title: 'Add Task', body: `
`, footer: ` ` }); } async function submitAddTodo(projectId) { const data = getFormData('add-todo-form'); const res = await API.post('projects/todo_create', data); if (res.success) { Toast.show('Task added.', 'success'); Modal.close(); renderProjectDetail(projectId); } else Toast.show(res.message, 'error'); } async function openTodoModal(todoId, projectId) { // Quick status update modal Modal.open({ id: 'update-todo', title: 'Update Task', body: `
`, footer: ` ` }); } async function submitTodoUpdate(projectId) { const data = getFormData('update-todo-form'); const res = await API.post('projects/todo_update', data); if (res.success) { Toast.show('Task updated.', 'success'); Modal.close(); renderProjectDetail(projectId); } else Toast.show(res.message, 'error'); } function openTestRequestModal(projectId, sections) { const sectionOpts = sections.map(s => ``).join(''); Modal.open({ id: 'add-test-request', title: 'New Test Request', body: `
`, footer: ` ` }); } async function submitTestRequest(projectId) { const data = getFormData('test-request-form'); data.requested_by = Auth.getCurrentUser()?.id; const res = await API.post('projects/test_request_create', data); if (res.success) { Toast.show('Test request submitted.', 'success'); Modal.close(); renderProjectDetail(projectId); } else Toast.show(res.message, 'error'); } // ============================================================ // PROJECT FTP CONNECTIONS // ============================================================ async function loadProjectFtp(projectId) { const wrap = document.getElementById('proj-ftp-list'); if (!wrap) return; const res = await API.post('projects/ftp', { action:'list', project_id: projectId }); if (!res.success) { wrap.innerHTML = `

${res.message}

`; return; } const conns = res.data.connections; if (!conns.length) { wrap.innerHTML = `
🔌
No FTP connections saved
`; return; } wrap.innerHTML = conns.map(c => `
${c.protocol.toUpperCase()} ${c.label}
Host

${c.host}:${c.port}

Remote Path

${c.remote_path||'/'}

Username

${c.username||'—'}

Password
••••••••
${c.notes ? `

${c.notes}

` : ''}
`).join(''); } function toggleFtpPw(id, plain) { const el = document.getElementById(`ftppw-${id}`); if (!el) return; el.textContent = el.textContent === '••••••••' ? (plain||'(empty)') : '••••••••'; } function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => Toast.show('Copied!', 'success')); } function openAddFtpModal(projectId, conn = null) { const c = conn; Modal.open({ id: c ? 'edit-ftp' : 'add-ftp', title: c ? 'Edit FTP Connection' : 'Add FTP Connection', body: `
${c ? `` : ''}
`, footer: ` ` }); } async function submitFtp(projectId) { const data = getFormData('ftp-form'); const res = await API.post('projects/ftp', data); if (res.success) { Toast.show('Saved.', 'success'); Modal.close(); loadProjectFtp(projectId); } else Toast.show(res.message, 'error'); } async function deleteFtp(id, projectId) { const ok = await Modal.confirm('Delete this connection?'); if (!ok) return; const res = await API.post('projects/ftp', { action:'delete', id, project_id: projectId }); if (res.success) { Toast.show('Deleted.', 'success'); loadProjectFtp(projectId); } else Toast.show(res.message, 'error'); } // ============================================================ // PROJECT TEAM MEMBERS // ============================================================ async function loadProjectTeam(projectId) { const wrap = document.getElementById('proj-team-list'); if (!wrap) return; const res = await API.post('projects/members', { action:'list', project_id: projectId }); if (!res.success) { wrap.innerHTML = `

${res.message}

`; return; } const members = res.data.members; if (!members.length) { wrap.innerHTML = `
👥
No team members added
`; return; } wrap.innerHTML = `
${members.map(m => `
${avatarHTML(m.full_name, 'lg')}
${m.full_name}
${m.role_name||''} · ${m.role}
`).join('')}
`; } function openAddMemberModal(projectId) { Modal.open({ id: 'add-member', title: 'Add Team Member', body: `
`, footer: ` ` }); // Load users into select API.post('auth/users_list', {}).then(res => { const sel = document.getElementById('member-user-select'); if (sel && res.success) { sel.innerHTML = res.data.users.map(u => ``).join(''); } }); } async function submitAddMember(projectId) { const data = getFormData('member-form'); const res = await API.post('projects/members', data); if (res.success) { Toast.show('Member added.', 'success'); Modal.close(); loadProjectTeam(projectId); } else Toast.show(res.message, 'error'); } async function removeMember(userId, projectId) { const ok = await Modal.confirm('Remove this member from the project?'); if (!ok) return; const res = await API.post('projects/members', { action:'remove', user_id: userId, project_id: projectId }); if (res.success) { Toast.show('Removed.', 'success'); loadProjectTeam(projectId); } else Toast.show(res.message, 'error'); }