import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js'; import { isDevTestUsername, testDataRowClassForUser } from '../test-data.js'; // Roles an admin can create; supervisors can only create coaches const CREATEABLE_ROLES = { admin: ['admin', 'supervisor', 'coach'], supervisor: ['coach'], }; let usersList = []; let supervisorsList = []; let callerRole = ''; /** @type {Record} */ let filterSearchByRole = { admin: '', supervisor: '', coach: '' }; let filterTestData = ''; function roleGroupsForCaller() { return callerRole === 'supervisor' ? [{ label: 'Coaches', key: 'coach' }] : [ { label: 'Admins', key: 'admin' }, { label: 'Supervisors', key: 'supervisor' }, { label: 'Coaches', key: 'coach' }, ]; } function searchPlaceholderForRole(roleKey) { return roleKey === 'coach' ? 'Search username or supervisor…' : 'Search username or location…'; } function usersByRoleMap() { const byRole = { admin: [], supervisor: [], coach: [] }; usersList.forEach(u => { if (byRole[u.role]) byRole[u.role].push(u); else (byRole.other = byRole.other || []).push(u); }); return byRole; } export async function usersPage() { callerRole = getRole(); const app = document.getElementById('app'); app.innerHTML = ` ${pageHeaderHTML('Users', ` `)}
`; document.getElementById('showCreateFormBtn').addEventListener('click', () => { const wrapper = document.getElementById('createUserWrapper'); if (wrapper.style.display === 'none') { showCreateForm(); } else { hideCreateForm(); } }); document.getElementById('downloadUsersBtn').addEventListener('click', downloadUsersCSV); await loadUsers(); } async function loadUsers() { try { const data = await apiGet('users.php'); usersList = data.users || []; supervisorsList = data.supervisors || []; callerRole = data.callerRole || callerRole; renderUsers(); const dlBtn = document.getElementById('downloadUsersBtn'); if (dlBtn) dlBtn.disabled = !usersList.length; } catch (e) { showToast(e.message, 'error'); document.getElementById('usersContent').innerHTML = `

${esc(e.message)}

`; } } // ── User table ──────────────────────────────────────────────────────────── function renderUsers() { const container = document.getElementById('usersContent'); const myUsername = getUser(); if (!usersList.length) { container.innerHTML = `

No users found

${callerRole === 'supervisor' ? 'You have no coaches yet.' : 'Create the first user above.'}

`; return; } const byRole = usersByRoleMap(); const groups = roleGroupsForCaller(); const testFilterCard = `
`; container.innerHTML = testFilterCard + groups.map(group => { const allInRole = byRole[group.key] || []; if (!allInRole.length) return ''; return `

${group.label} (0)

${group.key !== 'coach' ? '' : ''}
UsernameLocationSupervisorPassword Change Required Created Actions
`; }).join(''); document.getElementById('userTestFilter')?.addEventListener('change', (e) => { filterTestData = e.target.value; renderUsers(); }); container.addEventListener('input', (e) => { const input = e.target.closest('.user-role-search'); if (!input) return; const roleKey = input.dataset.role; if (!roleKey) return; filterSearchByRole[roleKey] = input.value; updateRoleGroupTable(roleKey, myUsername); }); groups.forEach(group => { if ((byRole[group.key] || []).length) { updateRoleGroupTable(group.key, myUsername); } }); } function filterUsers(users, roleKey) { let list = users; const q = (filterSearchByRole[roleKey] || '').trim().toLowerCase(); if (q) { list = list.filter(u => { const hay = [u.username, u.location, u.supervisorUsername].filter(Boolean).join(' ').toLowerCase(); return hay.includes(q); }); } if (filterTestData === 'test') { list = list.filter(u => isDevTestUsername(u.username)); } else if (filterTestData === 'hide-test') { list = list.filter(u => !isDevTestUsername(u.username)); } return list; } function updateRoleGroupTable(roleKey, myUsername) { const tbody = document.getElementById(`usersBody-${roleKey}`); const countEl = document.querySelector(`[data-role-count="${roleKey}"]`); if (!tbody) return; const byRole = usersByRoleMap(); const allInRole = byRole[roleKey] || []; const users = filterUsers(allInRole, roleKey); const colSpan = 5; if (countEl) { const q = (filterSearchByRole[roleKey] || '').trim(); countEl.textContent = q && users.length !== allInRole.length ? `${users.length} of ${allInRole.length}` : String(users.length); } if (!users.length) { tbody.innerHTML = ` ${allInRole.length ? 'No users match' : 'No users'} `; return; } tbody.innerHTML = users.map(u => userRowHTML(u, myUsername)).join(''); tbody.querySelectorAll('.delete-user-btn').forEach(btn => { btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name)); }); tbody.querySelectorAll('.coach-supervisor-select').forEach(sel => { sel.addEventListener('change', () => reassignCoachSupervisor(sel)); }); } function supervisorSelectHTML(u) { if (!supervisorsList.length) { return 'No supervisors'; } const cur = u.supervisorID || ''; const opts = supervisorsList.map(s => { const label = s.location ? `${s.username} (${s.location})` : s.username; const selected = s.supervisorID === cur ? ' selected' : ''; return ``; }).join(''); return ``; } function userRowHTML(u, myUsername) { const isSelf = u.username === myUsername; let detail; if (u.role === 'coach') { detail = callerRole === 'admin' ? supervisorSelectHTML(u) : esc(u.supervisorUsername || '—'); } else { detail = esc(u.location || '—'); } const created = u.createdAt ? new Date(parseInt(u.createdAt, 10) * 1000).toLocaleDateString() : '—'; const pwBadge = u.mustChangePassword == 1 ? 'Pending' : 'No'; const canDelete = !isSelf && ( callerRole === 'admin' || (callerRole === 'supervisor' && u.role === 'coach') ); return ` ${esc(u.username)} ${isSelf ? 'You' : ''} ${detail} ${pwBadge} ${created} ${canDelete ? `` : ''} `; } async function reassignCoachSupervisor(selectEl) { const userID = selectEl.dataset.userId; const username = selectEl.dataset.username || ''; const prev = selectEl.dataset.prev || ''; const supervisorID = selectEl.value; if (supervisorID === prev) { return; } selectEl.disabled = true; try { const data = await apiPut('users.php', { userID, supervisorID }); const u = usersList.find(x => x.userID === userID); if (u) { u.supervisorID = data.supervisorID ?? supervisorID; u.supervisorUsername = data.supervisorUsername ?? ''; } selectEl.dataset.prev = supervisorID; showToast(`Coach "${username}" assigned to ${data.supervisorUsername || 'supervisor'}`, 'success'); } catch (e) { selectEl.value = prev; showToast(e.message, 'error'); } finally { selectEl.disabled = false; } } async function deleteUser(userID, username) { if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return; try { const data = await apiDelete('users.php', { userID }); if (data.success) { usersList = usersList.filter(u => u.userID !== userID); showToast(`User "${username}" deleted`, 'success'); renderUsers(); } } catch (e) { showToast(e.message, 'error'); } } // ── Create user form ────────────────────────────────────────────────────── function showCreateForm() { const wrapper = document.getElementById('createUserWrapper'); const allowedRoles = CREATEABLE_ROLES[callerRole] || ['coach']; const isSupervisor = callerRole === 'supervisor'; wrapper.style.display = ''; wrapper.innerHTML = `

Create New User

${isSupervisor ? `` : `` }
${isSupervisor ? `` : `` }
`; document.getElementById('cu_username').focus(); // Show/hide location vs supervisor field based on role selection (admin only) if (!isSupervisor) { document.getElementById('cu_role').addEventListener('change', (e) => { const r = e.target.value; document.getElementById('cu_location_row').style.display = r !== 'coach' ? '' : 'none'; document.getElementById('cu_supervisor_row').style.display = r === 'coach' ? '' : 'none'; }); // Set initial state based on default selection const initRole = document.getElementById('cu_role').value; document.getElementById('cu_location_row').style.display = initRole !== 'coach' ? '' : 'none'; document.getElementById('cu_supervisor_row').style.display = initRole === 'coach' ? '' : 'none'; } document.getElementById('cu_submit').addEventListener('click', submitCreateUser); document.getElementById('cu_cancel').addEventListener('click', hideCreateForm); // Enter key on last visible field submits wrapper.querySelectorAll('input').forEach(inp => { inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitCreateUser(); if (e.key === 'Escape') hideCreateForm(); }); }); } function hideCreateForm() { const wrapper = document.getElementById('createUserWrapper'); if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; } } async function submitCreateUser() { const errEl = document.getElementById('cu_error'); errEl.style.display = 'none'; const isSupervisor = callerRole === 'supervisor'; const username = document.getElementById('cu_username').value.trim(); const password = document.getElementById('cu_password').value; const role = isSupervisor ? 'coach' : (document.getElementById('cu_role').value || 'coach'); const mustChange = document.getElementById('cu_mustchange').checked ? 1 : 0; let location = ''; let supervisorID = ''; if (!isSupervisor) { if (role !== 'coach') { location = document.getElementById('cu_location').value.trim(); } else { supervisorID = document.getElementById('cu_supervisor_select').value; } } if (!username) { showError(errEl, 'Username is required'); return; } if (password.length < 6) { showError(errEl, 'Password must be at least 6 characters'); return; } if (role === 'coach' && !isSupervisor && !supervisorID) { showError(errEl, 'Please select a supervisor for this coach'); return; } const btn = document.getElementById('cu_submit'); btn.disabled = true; btn.textContent = 'Creating...'; try { const data = await apiPost('users.php', { username, password, role, location, supervisorID, mustChangePassword: mustChange, }); if (!data.success) throw new Error(data.error || 'Failed to create user'); // Add to local list and re-render usersList.push({ userID: data.userID, username: data.username, role: data.role, entityID: data.entityID, location: data.location || '', supervisorID: data.supervisorID || null, supervisorUsername: data.supervisorUsername || null, mustChangePassword: data.mustChangePassword, createdAt: data.createdAt, }); showToast(`User "${data.username}" created`, 'success'); hideCreateForm(); renderUsers(); } catch (e) { showError(errEl, e.message); btn.disabled = false; btn.textContent = 'Create User'; } } function showError(el, msg) { el.textContent = msg; el.style.display = ''; } function downloadUsersCSV() { if (!usersList.length) { showToast('No users to export', 'error'); return; } const headers = ['username', 'role', 'location', 'supervisor', 'mustChangePassword', 'createdAt', 'userID']; const rows = usersList.map(u => [ u.username, u.role, u.role === 'coach' ? '' : (u.location || ''), u.role === 'coach' ? (u.supervisorUsername || u.supervisorID || '') : '', u.mustChangePassword == 1 ? 'yes' : 'no', u.createdAt ? new Date(parseInt(u.createdAt, 10) * 1000).toISOString() : '', u.userID, ]); let csv = '\uFEFF'; csv += headers.map(csvEsc).join(',') + '\n'; rows.forEach(r => { csv += r.map(csvEsc).join(',') + '\n'; }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `users_${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(url); showToast('Users exported', 'success'); } function csvEsc(val) { const s = String(val ?? ''); if (s.includes(',') || s.includes('"') || s.includes('\n')) { return '"' + s.replace(/"/g, '""') + '"'; } return s; } function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }