new system prototype
This commit is contained in:
93
website/js/api.js
Normal file
93
website/js/api.js
Normal file
@ -0,0 +1,93 @@
|
||||
const API_BASE = '../api';
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem('qdb_token');
|
||||
}
|
||||
|
||||
async function apiFetch(endpoint, options = {}) {
|
||||
const token = getToken();
|
||||
const headers = options.headers || {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json; charset=UTF-8';
|
||||
}
|
||||
|
||||
// Strip .php suffix for clean URLs
|
||||
const cleanEndpoint = endpoint.replace(/\.php(\?|$)/, '$1');
|
||||
const res = await fetch(`${API_BASE}/${cleanEndpoint}`, { ...options, headers });
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
const errMsg = json.error?.message || json.error || '';
|
||||
if (res.status === 401 || errMsg === 'Invalid or expired token') {
|
||||
localStorage.removeItem('qdb_token');
|
||||
localStorage.removeItem('qdb_user');
|
||||
localStorage.removeItem('qdb_role');
|
||||
window.location.hash = '#/login';
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
throw new Error(errMsg || 'Permission denied');
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the unified response envelope:
|
||||
* {"ok": true, "data": ...} -> returns { success: true, ...data }
|
||||
* {"ok": false, "error": ...} -> returns { success: false, error: message }
|
||||
* Also supports legacy format for backward compat.
|
||||
*/
|
||||
function unwrap(json) {
|
||||
if (typeof json.ok === 'boolean') {
|
||||
if (json.ok) {
|
||||
const d = json.data || {};
|
||||
return { success: true, ...(Array.isArray(d) ? { items: d } : d) };
|
||||
}
|
||||
return { success: false, error: json.error?.message || 'Unknown error' };
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function apiGet(endpoint) {
|
||||
const res = await apiFetch(endpoint);
|
||||
return unwrap(await res.json());
|
||||
}
|
||||
|
||||
export async function apiPost(endpoint, body) {
|
||||
const res = await apiFetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return unwrap(await res.json());
|
||||
}
|
||||
|
||||
export async function apiPut(endpoint, body) {
|
||||
const res = await apiFetch(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return unwrap(await res.json());
|
||||
}
|
||||
|
||||
export async function apiPatch(endpoint, body) {
|
||||
const res = await apiFetch(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return unwrap(await res.json());
|
||||
}
|
||||
|
||||
export async function apiDelete(endpoint, body) {
|
||||
const res = await apiFetch(endpoint, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return unwrap(await res.json());
|
||||
}
|
||||
|
||||
export function apiDownloadUrl(endpoint) {
|
||||
const token = getToken();
|
||||
const cleanEndpoint = endpoint.replace(/\.php(\?|$)/, '$1');
|
||||
return `${API_BASE}/${cleanEndpoint}${cleanEndpoint.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
103
website/js/app.js
Normal file
103
website/js/app.js
Normal file
@ -0,0 +1,103 @@
|
||||
import { addRoute, startRouter, navigate } from './router.js';
|
||||
import { loginPage } from './pages/login.js';
|
||||
import { dashboardPage } from './pages/dashboard.js';
|
||||
import { editorPage } from './pages/editor.js';
|
||||
import { resultsPage } from './pages/results.js';
|
||||
import { exportPage } from './pages/export.js';
|
||||
import { usersPage } from './pages/users.js';
|
||||
import { assignmentsPage } from './pages/assignments.js';
|
||||
|
||||
// Auth state
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem('qdb_token');
|
||||
}
|
||||
export function getRole() {
|
||||
return localStorage.getItem('qdb_role') || '';
|
||||
}
|
||||
export function getUser() {
|
||||
return localStorage.getItem('qdb_user') || '';
|
||||
}
|
||||
export function canEdit() {
|
||||
const r = getRole();
|
||||
return r === 'admin' || r === 'supervisor';
|
||||
}
|
||||
|
||||
export function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
requestAnimationFrame(() => toast.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function authGuard(handler) {
|
||||
return (params) => {
|
||||
if (!isLoggedIn()) { navigate('#/login'); return; }
|
||||
return handler(params);
|
||||
};
|
||||
}
|
||||
|
||||
function updateNav() {
|
||||
const nav = document.getElementById('mainNav');
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
if (isLoggedIn()) {
|
||||
nav.style.display = '';
|
||||
const role = getRole();
|
||||
userInfo.textContent = `${getUser()} (${role})`;
|
||||
|
||||
// Show/hide role-gated nav items
|
||||
document.querySelectorAll('[data-nav-roles]').forEach(li => {
|
||||
const allowed = li.dataset.navRoles.split(' ');
|
||||
li.style.display = allowed.includes(role) ? '' : 'none';
|
||||
});
|
||||
|
||||
// Highlight active nav link
|
||||
const hash = window.location.hash.slice(1) || '/';
|
||||
document.querySelectorAll('.sidebar-nav a').forEach(a => {
|
||||
const href = a.getAttribute('href').slice(1); // strip leading #
|
||||
a.classList.toggle('active', hash === href || (href !== '/' && hash.startsWith(href)));
|
||||
});
|
||||
} else {
|
||||
nav.style.display = 'none';
|
||||
userInfo.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function roleGuard(roles, handler) {
|
||||
return (params) => {
|
||||
if (!isLoggedIn()) { navigate('#/login'); return; }
|
||||
if (!roles.includes(getRole())) { navigate('#/'); return; }
|
||||
return handler(params);
|
||||
};
|
||||
}
|
||||
|
||||
// Routes
|
||||
addRoute('/login', loginPage);
|
||||
addRoute('/', authGuard(dashboardPage));
|
||||
addRoute('/dashboard', authGuard(dashboardPage));
|
||||
addRoute('/questionnaire/new', authGuard(editorPage));
|
||||
addRoute('/questionnaire/:id/results', authGuard(resultsPage));
|
||||
addRoute('/questionnaire/:id', authGuard(editorPage));
|
||||
addRoute('/export', authGuard(exportPage));
|
||||
addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
|
||||
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
|
||||
|
||||
// Nav link handling & logout
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('logoutBtn').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
localStorage.removeItem('qdb_token');
|
||||
localStorage.removeItem('qdb_user');
|
||||
localStorage.removeItem('qdb_role');
|
||||
navigate('#/login');
|
||||
});
|
||||
|
||||
window.addEventListener('hashchange', updateNav);
|
||||
updateNav();
|
||||
startRouter();
|
||||
});
|
||||
260
website/js/pages/assignments.js
Normal file
260
website/js/pages/assignments.js
Normal file
@ -0,0 +1,260 @@
|
||||
import { apiGet, apiPost } from '../api.js';
|
||||
import { showToast } from '../app.js';
|
||||
|
||||
let coaches = [];
|
||||
let clients = [];
|
||||
let selectedCoachID = null;
|
||||
|
||||
export async function assignmentsPage() {
|
||||
selectedCoachID = null;
|
||||
const app = document.getElementById('app');
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Assign Clients</h1>
|
||||
<div class="actions"><a href="#/" class="btn">← Dashboard</a></div>
|
||||
</div>
|
||||
<div id="assignContent"><div class="spinner"></div></div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const data = await apiGet('assignments.php');
|
||||
coaches = data.coaches || [];
|
||||
clients = data.clients || [];
|
||||
renderAssignments();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
document.getElementById('assignContent').innerHTML =
|
||||
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssignments() {
|
||||
const container = document.getElementById('assignContent');
|
||||
|
||||
if (!coaches.length) {
|
||||
container.innerHTML = `
|
||||
<div class="card empty-state">
|
||||
<h3>No coaches found</h3>
|
||||
<p>Create coaches first before assigning clients.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group clients by coachID for the filter badge counts
|
||||
const clientsByCoach = {};
|
||||
const unassigned = [];
|
||||
clients.forEach(c => {
|
||||
if (c.coachID) {
|
||||
(clientsByCoach[c.coachID] = clientsByCoach[c.coachID] || []).push(c);
|
||||
} else {
|
||||
unassigned.push(c);
|
||||
}
|
||||
});
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="assign-layout">
|
||||
|
||||
<!-- Left: Coach list -->
|
||||
<div class="assign-coaches card">
|
||||
<h3 style="margin-bottom:12px">Coaches</h3>
|
||||
<ul class="coach-list" id="coachList">
|
||||
${coaches.map(c => {
|
||||
const count = (clientsByCoach[c.coachID] || []).length;
|
||||
return `
|
||||
<li class="coach-list-item" data-id="${c.coachID}">
|
||||
<div class="coach-name">${esc(c.username)}</div>
|
||||
${c.supervisorUsername
|
||||
? `<div class="coach-supervisor">${esc(c.supervisorUsername)}</div>`
|
||||
: ''}
|
||||
<span class="coach-client-count">${count} client${count === 1 ? '' : 's'}</span>
|
||||
</li>`;
|
||||
}).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Right: Client list + assign panel -->
|
||||
<div class="assign-right">
|
||||
<div class="card assign-clients-card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap;gap:8px">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<h3>Clients</h3>
|
||||
<div class="filter-bar" style="margin:0">
|
||||
<select id="filterByCoach">
|
||||
<option value="">All coaches</option>
|
||||
<option value="__unassigned__">Unassigned</option>
|
||||
${coaches.map(c =>
|
||||
`<option value="${c.coachID}">${esc(c.username)}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<input type="text" id="clientSearch" placeholder="Search client code..." style="width:160px">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn btn-sm" id="selectAllBtn">Select all</button>
|
||||
<button class="btn btn-sm" id="clearSelBtn">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper" style="max-height:420px;overflow-y:auto">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:36px"><input type="checkbox" id="selectAllCb" title="Toggle all visible"></th>
|
||||
<th>Client Code</th>
|
||||
<th>Current Coach</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="clientTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="selectionInfo" class="selection-info" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Assign action card -->
|
||||
<div class="card assign-action-card" id="assignActionCard" style="display:none">
|
||||
<h4>Assign selected clients to:</h4>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:10px">
|
||||
<select id="targetCoachSelect" style="flex:1;min-width:180px;padding:8px 12px;border:1px solid var(--border);border-radius:6px">
|
||||
<option value="">— select coach —</option>
|
||||
${coaches.map(c =>
|
||||
`<option value="${c.coachID}">${esc(c.username)}${c.supervisorUsername ? ` (${esc(c.supervisorUsername)})` : ''}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<button class="btn btn-primary" id="assignBtn">Assign</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire coach list click → filter clients
|
||||
document.querySelectorAll('.coach-list-item').forEach(li => {
|
||||
li.addEventListener('click', () => {
|
||||
document.querySelectorAll('.coach-list-item').forEach(x => x.classList.remove('active'));
|
||||
li.classList.add('active');
|
||||
selectedCoachID = li.dataset.id;
|
||||
document.getElementById('filterByCoach').value = selectedCoachID;
|
||||
renderClientRows();
|
||||
});
|
||||
});
|
||||
|
||||
// Wire filters
|
||||
document.getElementById('filterByCoach').addEventListener('change', renderClientRows);
|
||||
document.getElementById('clientSearch').addEventListener('input', renderClientRows);
|
||||
|
||||
// Wire select all / clear
|
||||
document.getElementById('selectAllBtn').addEventListener('click', () => {
|
||||
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => cb.checked = true);
|
||||
updateSelectionInfo();
|
||||
});
|
||||
document.getElementById('clearSelBtn').addEventListener('click', () => {
|
||||
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => cb.checked = false);
|
||||
updateSelectionInfo();
|
||||
});
|
||||
document.getElementById('selectAllCb').addEventListener('change', (e) => {
|
||||
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => cb.checked = e.target.checked);
|
||||
updateSelectionInfo();
|
||||
});
|
||||
|
||||
// Wire assign button
|
||||
document.getElementById('assignBtn').addEventListener('click', doAssign);
|
||||
|
||||
renderClientRows();
|
||||
}
|
||||
|
||||
function renderClientRows() {
|
||||
const body = document.getElementById('clientTableBody');
|
||||
const coachFilter = document.getElementById('filterByCoach').value;
|
||||
const search = document.getElementById('clientSearch').value.trim().toLowerCase();
|
||||
|
||||
let visible = clients;
|
||||
if (coachFilter === '__unassigned__') {
|
||||
visible = clients.filter(c => !c.coachID);
|
||||
} else if (coachFilter) {
|
||||
visible = clients.filter(c => c.coachID === coachFilter);
|
||||
}
|
||||
if (search) {
|
||||
visible = visible.filter(c => c.clientCode.toLowerCase().includes(search));
|
||||
}
|
||||
|
||||
if (!visible.length) {
|
||||
body.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match the current filter</td></tr>`;
|
||||
updateSelectionInfo();
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = visible.map(c => `
|
||||
<tr>
|
||||
<td><input type="checkbox" class="client-cb" data-code="${esc(c.clientCode)}" value="${esc(c.clientCode)}"></td>
|
||||
<td><strong>${esc(c.clientCode)}</strong></td>
|
||||
<td>${c.coachUsername ? esc(c.coachUsername) : '<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
body.querySelectorAll('.client-cb').forEach(cb => {
|
||||
cb.addEventListener('change', updateSelectionInfo);
|
||||
});
|
||||
|
||||
updateSelectionInfo();
|
||||
}
|
||||
|
||||
function getSelectedCodes() {
|
||||
return [...document.querySelectorAll('#clientTableBody .client-cb:checked')]
|
||||
.map(cb => cb.value);
|
||||
}
|
||||
|
||||
function updateSelectionInfo() {
|
||||
const selected = getSelectedCodes();
|
||||
const info = document.getElementById('selectionInfo');
|
||||
const card = document.getElementById('assignActionCard');
|
||||
|
||||
if (!selected.length) {
|
||||
info.style.display = 'none';
|
||||
card.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
info.style.display = '';
|
||||
info.innerHTML = `<strong>${selected.length}</strong> client${selected.length === 1 ? '' : 's'} selected`;
|
||||
card.style.display = '';
|
||||
}
|
||||
|
||||
async function doAssign() {
|
||||
const selected = getSelectedCodes();
|
||||
const coachID = document.getElementById('targetCoachSelect').value;
|
||||
|
||||
if (!selected.length) { showToast('Select at least one client', 'error'); return; }
|
||||
if (!coachID) { showToast('Select a target coach', 'error'); return; }
|
||||
|
||||
const coachName = coaches.find(c => c.coachID === coachID)?.username || coachID;
|
||||
if (!confirm(`Assign ${selected.length} client(s) to ${coachName}?`)) return;
|
||||
|
||||
const btn = document.getElementById('assignBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Assigning...';
|
||||
|
||||
try {
|
||||
const data = await apiPost('assignments.php', { clientCodes: selected, coachID });
|
||||
if (data.success) {
|
||||
// Update local data
|
||||
selected.forEach(code => {
|
||||
const c = clients.find(x => x.clientCode === code);
|
||||
if (c) {
|
||||
c.coachID = coachID;
|
||||
c.coachUsername = coachName;
|
||||
}
|
||||
});
|
||||
showToast(`${data.assigned} client(s) assigned to ${coachName}`, 'success');
|
||||
renderAssignments();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Assign';
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s ?? '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
150
website/js/pages/dashboard.js
Normal file
150
website/js/pages/dashboard.js
Normal file
@ -0,0 +1,150 @@
|
||||
import { apiGet, apiPut, apiDelete } from '../api.js';
|
||||
import { canEdit, showToast, getRole } from '../app.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
export async function dashboardPage() {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Questionnaires</h1>
|
||||
<div class="actions" id="headerActions"></div>
|
||||
</div>
|
||||
<div id="qGrid" class="q-grid"><div class="spinner"></div></div>
|
||||
`;
|
||||
|
||||
if (canEdit()) {
|
||||
document.getElementById('headerActions').innerHTML = `
|
||||
<a href="#/questionnaire/new" class="btn btn-primary">+ New Questionnaire</a>
|
||||
`;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiGet('questionnaires.php');
|
||||
renderGrid(data.questionnaires || []);
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
document.getElementById('qGrid').innerHTML = `<p class="error-text">${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderGrid(questionnaires) {
|
||||
const grid = document.getElementById('qGrid');
|
||||
|
||||
if (!questionnaires.length) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state" style="grid-column:1/-1">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 12h6m-3-3v6m-7 4h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||
<h3>No questionnaires yet</h3>
|
||||
<p>Create your first questionnaire to get started.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = questionnaires.map(q => {
|
||||
const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`;
|
||||
const showPts = parseInt(q.showPoints || 0, 10);
|
||||
return `
|
||||
<div class="q-card" data-id="${q.questionnaireID}" draggable="${canEdit()}">
|
||||
<div class="q-card-header">
|
||||
<div class="q-card-title">
|
||||
${canEdit() ? '<span class="drag-handle" title="Drag to reorder" style="margin-right:6px;cursor:grab;color:var(--text-secondary)">☰</span>' : ''}
|
||||
${esc(q.name)}
|
||||
</div>
|
||||
<span class="badge ${stateClass}">${esc(q.state || 'draft')}</span>
|
||||
</div>
|
||||
<div class="q-card-meta">
|
||||
<span>v${esc(q.version || '—')}</span>
|
||||
<span>${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'}</span>
|
||||
<span>#${q.orderIndex ?? '—'}</span>
|
||||
${showPts ? '<span class="badge badge-active" style="font-size:.7rem;padding:1px 6px">pts</span>' : ''}
|
||||
</div>
|
||||
<div class="q-card-actions">
|
||||
<a href="#/questionnaire/${q.questionnaireID}" class="btn btn-sm">Edit</a>
|
||||
<a href="#/questionnaire/${q.questionnaireID}/results" class="btn btn-sm">Results</a>
|
||||
${getRole() === 'admin' ? `<button class="btn btn-sm btn-danger delete-q-btn" data-id="${q.questionnaireID}">Delete</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
grid.querySelectorAll('.q-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.q-card-actions') || e.target.closest('.drag-handle')) return;
|
||||
navigate(`#/questionnaire/${card.dataset.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
grid.querySelectorAll('.delete-q-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm('Delete this questionnaire and all its data?')) return;
|
||||
try {
|
||||
await apiDelete('questionnaires.php', { questionnaireID: btn.dataset.id });
|
||||
showToast('Questionnaire deleted', 'success');
|
||||
btn.closest('.q-card').remove();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (canEdit()) initGridDrag(questionnaires);
|
||||
}
|
||||
|
||||
function initGridDrag(questionnaires) {
|
||||
const grid = document.getElementById('qGrid');
|
||||
if (!grid) return;
|
||||
let dragItem = null;
|
||||
|
||||
grid.addEventListener('dragstart', (e) => {
|
||||
const card = e.target.closest('.q-card');
|
||||
if (!card || !e.target.closest('.drag-handle')) { e.preventDefault(); return; }
|
||||
dragItem = card;
|
||||
card.style.opacity = '0.5';
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
|
||||
grid.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (!dragItem) return;
|
||||
const cards = [...grid.querySelectorAll('.q-card:not([style*="opacity"])')];
|
||||
const afterCard = cards.reduce((closest, child) => {
|
||||
const box = child.getBoundingClientRect();
|
||||
const offset = e.clientY - box.top - box.height / 2;
|
||||
if (offset < 0 && offset > closest.offset) return { offset, element: child };
|
||||
return closest;
|
||||
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
||||
|
||||
if (afterCard) grid.insertBefore(dragItem, afterCard);
|
||||
else grid.appendChild(dragItem);
|
||||
});
|
||||
|
||||
grid.addEventListener('dragend', async () => {
|
||||
if (!dragItem) return;
|
||||
dragItem.style.opacity = '';
|
||||
const newOrder = [...grid.querySelectorAll('.q-card')].map(el => el.dataset.id);
|
||||
dragItem = null;
|
||||
|
||||
for (let i = 0; i < newOrder.length; i++) {
|
||||
const id = newOrder[i];
|
||||
const q = questionnaires.find(q => q.questionnaireID === id);
|
||||
if (q && parseInt(q.orderIndex, 10) !== i) {
|
||||
try {
|
||||
await apiPut('questionnaires.php', { questionnaireID: id, orderIndex: i });
|
||||
q.orderIndex = i;
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
showToast('Order saved', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s ?? '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
1272
website/js/pages/editor.js
Normal file
1272
website/js/pages/editor.js
Normal file
File diff suppressed because it is too large
Load Diff
104
website/js/pages/export.js
Normal file
104
website/js/pages/export.js
Normal file
@ -0,0 +1,104 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import { showToast } from '../app.js';
|
||||
|
||||
export async function exportPage() {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Export Data</h1>
|
||||
<div class="actions"><a href="#/" class="btn">← Dashboard</a></div>
|
||||
</div>
|
||||
<div class="card" id="exportContent"><div class="spinner"></div></div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const data = await apiGet('questionnaires.php');
|
||||
renderExport(data.questionnaires || []);
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
document.getElementById('exportContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderExport(questionnaires) {
|
||||
const container = document.getElementById('exportContent');
|
||||
|
||||
if (!questionnaires.length) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No questionnaires</h3>
|
||||
<p>Create questionnaires first to export data.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<p style="margin-bottom:16px;color:var(--text-secondary)">
|
||||
Select a questionnaire and click Export to download a CSV file with all client responses (filtered by your role's visibility).
|
||||
</p>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Questionnaire</th>
|
||||
<th>Version</th>
|
||||
<th>State</th>
|
||||
<th>Questions</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${questionnaires.map(q => `
|
||||
<tr>
|
||||
<td><strong>${esc(q.name)}</strong></td>
|
||||
<td>${esc(q.version || '—')}</td>
|
||||
<td><span class="badge badge-${(q.state || 'draft').toLowerCase()}">${esc(q.state || 'draft')}</span></td>
|
||||
<td>${q.questionCount || 0}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary export-btn" data-id="${q.questionnaireID}" data-name="${esc(q.name)}" data-version="${esc(q.version)}">
|
||||
Export CSV
|
||||
</button>
|
||||
<a href="#/questionnaire/${q.questionnaireID}/results" class="btn btn-sm">View Results</a>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.querySelectorAll('.export-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Downloading...';
|
||||
try {
|
||||
const token = localStorage.getItem('qdb_token');
|
||||
const res = await fetch(`../api/export.php?questionnaireID=${encodeURIComponent(btn.dataset.id)}&format=csv`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
||||
throw new Error(err.error);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${btn.dataset.name}_v${btn.dataset.version}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Download started', 'success');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Export CSV';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s ?? '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
124
website/js/pages/login.js
Normal file
124
website/js/pages/login.js
Normal file
@ -0,0 +1,124 @@
|
||||
import { navigate } from '../router.js';
|
||||
import { isLoggedIn, showToast } from '../app.js';
|
||||
|
||||
export function loginPage() {
|
||||
if (isLoggedIn()) { navigate('#/'); return; }
|
||||
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card card">
|
||||
<h1>BW Schützt</h1>
|
||||
<p class="subtitle">Questionnaire Management</p>
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Log in</button>
|
||||
<p id="loginError" class="error-text" style="display:none"></p>
|
||||
</form>
|
||||
<div id="changePwSection" style="display:none">
|
||||
<hr>
|
||||
<p>You must change your password before continuing.</p>
|
||||
<form id="changePwForm">
|
||||
<div class="form-group">
|
||||
<label for="newPw">New password</label>
|
||||
<input type="password" id="newPw" autocomplete="new-password" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newPwConfirm">Confirm password</label>
|
||||
<input type="password" id="newPwConfirm" autocomplete="new-password" required minlength="6">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Change & continue</button>
|
||||
<p id="changePwError" class="error-text" style="display:none"></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let pendingUsername = '';
|
||||
let pendingTempToken = '';
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('loginError');
|
||||
errEl.style.display = 'none';
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
try {
|
||||
const res = await fetch('../api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) {
|
||||
errEl.textContent = json.error?.message || 'Login failed';
|
||||
errEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
const d = json.data;
|
||||
if (d.mustChangePassword) {
|
||||
pendingUsername = username;
|
||||
pendingTempToken = d.token;
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
document.getElementById('changePwSection').style.display = '';
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('qdb_token', d.token);
|
||||
localStorage.setItem('qdb_user', d.user);
|
||||
localStorage.setItem('qdb_role', d.role);
|
||||
navigate('#/');
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('changePwForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('changePwError');
|
||||
errEl.style.display = 'none';
|
||||
const newPw = document.getElementById('newPw').value;
|
||||
const confirm = document.getElementById('newPwConfirm').value;
|
||||
if (newPw !== confirm) {
|
||||
errEl.textContent = 'Passwords do not match';
|
||||
errEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
const oldPw = document.getElementById('password').value;
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (pendingTempToken) {
|
||||
headers['Authorization'] = `Bearer ${pendingTempToken}`;
|
||||
}
|
||||
const res = await fetch('../api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ username: pendingUsername, old_password: oldPw, new_password: newPw }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) {
|
||||
errEl.textContent = json.error?.message || 'Password change failed';
|
||||
errEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
const d = json.data;
|
||||
localStorage.setItem('qdb_token', d.token);
|
||||
localStorage.setItem('qdb_user', d.user);
|
||||
localStorage.setItem('qdb_role', d.role);
|
||||
showToast('Password changed successfully', 'success');
|
||||
navigate('#/');
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.style.display = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
264
website/js/pages/results.js
Normal file
264
website/js/pages/results.js
Normal file
@ -0,0 +1,264 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import { showToast } from '../app.js';
|
||||
|
||||
let sortCol = null;
|
||||
let sortDir = 'asc';
|
||||
let allClients = [];
|
||||
let questionsDef = [];
|
||||
let questionnaireMeta = null;
|
||||
let filterCoach = '';
|
||||
let filterStatus = '';
|
||||
|
||||
export async function resultsPage(params) {
|
||||
const app = document.getElementById('app');
|
||||
sortCol = null;
|
||||
sortDir = 'asc';
|
||||
filterCoach = '';
|
||||
filterStatus = '';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 id="resultsTitle">Results</h1>
|
||||
<div class="actions">
|
||||
<a href="#/questionnaire/${params.id}" class="btn">← Back to Editor</a>
|
||||
<a href="#/" class="btn">Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="resultsContent"><div class="spinner"></div></div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const data = await apiGet(`results.php?questionnaireID=${encodeURIComponent(params.id)}`);
|
||||
questionnaireMeta = data.questionnaire;
|
||||
questionsDef = data.questions || [];
|
||||
allClients = data.clients || [];
|
||||
document.getElementById('resultsTitle').textContent = `Results: ${questionnaireMeta.name} v${questionnaireMeta.version}`;
|
||||
renderResults();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
document.getElementById('resultsContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
const container = document.getElementById('resultsContent');
|
||||
|
||||
// Collect unique coaches and statuses for filters
|
||||
const coaches = [...new Set(allClients.map(c => c.coachID))].sort();
|
||||
const statuses = [...new Set(allClients.map(c => c.status))].filter(Boolean).sort();
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="filter-bar">
|
||||
<label style="font-size:.85rem;font-weight:500">Filter:</label>
|
||||
<select id="filterCoach">
|
||||
<option value="">All coaches</option>
|
||||
${coaches.map(c => `<option value="${esc(c)}" ${filterCoach === c ? 'selected' : ''}>${esc(c)}</option>`).join('')}
|
||||
</select>
|
||||
<select id="filterStatus">
|
||||
<option value="">All statuses</option>
|
||||
${statuses.map(s => `<option value="${esc(s)}" ${filterStatus === s ? 'selected' : ''}>${esc(s)}</option>`).join('')}
|
||||
</select>
|
||||
<span style="margin-left:auto;font-size:.85rem;color:var(--text-secondary)" id="resultCount"></span>
|
||||
<a id="exportCsvLink" class="btn btn-sm" href="#">Export CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table" id="resultsTable">
|
||||
<thead><tr id="resultsHead"></tr></thead>
|
||||
<tbody id="resultsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('filterCoach').addEventListener('change', (e) => {
|
||||
filterCoach = e.target.value;
|
||||
renderTableRows();
|
||||
});
|
||||
document.getElementById('filterStatus').addEventListener('change', (e) => {
|
||||
filterStatus = e.target.value;
|
||||
renderTableRows();
|
||||
});
|
||||
document.getElementById('exportCsvLink').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
exportCSV();
|
||||
});
|
||||
|
||||
renderTableHead();
|
||||
renderTableRows();
|
||||
}
|
||||
|
||||
function renderTableHead() {
|
||||
const head = document.getElementById('resultsHead');
|
||||
const fixedCols = [
|
||||
{ key: 'clientCode', label: 'Client' },
|
||||
{ key: 'coachID', label: 'Coach' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'sumPoints', label: 'Score' },
|
||||
{ key: 'completedAt', label: 'Completed' },
|
||||
];
|
||||
const questionCols = questionsDef.map(q => ({
|
||||
key: `q_${q.questionID}`,
|
||||
label: q.defaultText,
|
||||
questionID: q.questionID,
|
||||
}));
|
||||
const allCols = [...fixedCols, ...questionCols];
|
||||
|
||||
head.innerHTML = allCols.map(col => {
|
||||
let cls = 'sortable';
|
||||
if (sortCol === col.key) cls += sortDir === 'asc' ? ' sort-asc' : ' sort-desc';
|
||||
return `<th class="${cls}" data-key="${col.key}" title="${esc(col.label)}">${esc(col.label)}</th>`;
|
||||
}).join('');
|
||||
|
||||
head.querySelectorAll('th.sortable').forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const key = th.dataset.key;
|
||||
if (sortCol === key) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
else { sortCol = key; sortDir = 'asc'; }
|
||||
renderTableHead();
|
||||
renderTableRows();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getFilteredClients() {
|
||||
let clients = allClients;
|
||||
if (filterCoach) clients = clients.filter(c => c.coachID === filterCoach);
|
||||
if (filterStatus) clients = clients.filter(c => c.status === filterStatus);
|
||||
return clients;
|
||||
}
|
||||
|
||||
function renderTableRows() {
|
||||
const body = document.getElementById('resultsBody');
|
||||
let clients = getFilteredClients();
|
||||
|
||||
// Sort
|
||||
if (sortCol) {
|
||||
clients = [...clients].sort((a, b) => {
|
||||
let va = getCellValue(a, sortCol);
|
||||
let vb = getCellValue(b, sortCol);
|
||||
if (va == null) va = '';
|
||||
if (vb == null) vb = '';
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
return sortDir === 'asc' ? va - vb : vb - va;
|
||||
}
|
||||
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('resultCount').textContent = `${clients.length} client${clients.length === 1 ? '' : 's'}`;
|
||||
|
||||
if (!clients.length) {
|
||||
body.innerHTML = `<tr><td colspan="99" style="text-align:center;color:var(--text-secondary);padding:30px">No results found</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Build option text lookup
|
||||
const optionMap = {};
|
||||
questionsDef.forEach(q => {
|
||||
(q.answerOptions || []).forEach(o => {
|
||||
optionMap[o.answerOptionID] = o.defaultText;
|
||||
});
|
||||
});
|
||||
|
||||
body.innerHTML = clients.map(c => {
|
||||
const completedDate = c.completedAt ? new Date(c.completedAt * 1000).toLocaleDateString() : '—';
|
||||
const statusBadge = c.status ? `<span class="badge badge-${c.status.toLowerCase().replace(/\s+/g,'_')}">${esc(c.status)}</span>` : '—';
|
||||
|
||||
const questionCells = questionsDef.map(q => {
|
||||
const ans = c.answers && c.answers[q.questionID];
|
||||
if (!ans) return '<td>—</td>';
|
||||
if (ans.answerOptionID && optionMap[ans.answerOptionID]) {
|
||||
return `<td>${esc(optionMap[ans.answerOptionID])}</td>`;
|
||||
}
|
||||
if (ans.freeTextValue) return `<td>${esc(ans.freeTextValue)}</td>`;
|
||||
if (ans.numericValue !== null && ans.numericValue !== undefined) return `<td>${ans.numericValue}</td>`;
|
||||
return '<td>—</td>';
|
||||
}).join('');
|
||||
|
||||
return `<tr>
|
||||
<td>${esc(c.clientCode)}</td>
|
||||
<td>${esc(c.coachID)}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${c.sumPoints !== null ? c.sumPoints : '—'}</td>
|
||||
<td>${completedDate}</td>
|
||||
${questionCells}
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getCellValue(client, key) {
|
||||
if (key === 'clientCode') return client.clientCode;
|
||||
if (key === 'coachID') return client.coachID;
|
||||
if (key === 'status') return client.status;
|
||||
if (key === 'sumPoints') return client.sumPoints;
|
||||
if (key === 'completedAt') return client.completedAt;
|
||||
if (key.startsWith('q_')) {
|
||||
const qid = key.substring(2);
|
||||
const ans = client.answers && client.answers[qid];
|
||||
if (!ans) return null;
|
||||
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
|
||||
if (ans.freeTextValue) return ans.freeTextValue;
|
||||
return ans.answerOptionID || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
const clients = getFilteredClients();
|
||||
const optionMap = {};
|
||||
questionsDef.forEach(q => {
|
||||
(q.answerOptions || []).forEach(o => {
|
||||
optionMap[o.answerOptionID] = o.defaultText;
|
||||
});
|
||||
});
|
||||
|
||||
const headers = ['clientCode', 'coachID', 'status', 'sumPoints', 'completedAt',
|
||||
...questionsDef.map(q => q.defaultText)];
|
||||
|
||||
const rows = clients.map(c => {
|
||||
const base = [
|
||||
c.clientCode, c.coachID, c.status, c.sumPoints ?? '',
|
||||
c.completedAt ? new Date(c.completedAt * 1000).toISOString() : ''
|
||||
];
|
||||
const answerCols = questionsDef.map(q => {
|
||||
const ans = c.answers && c.answers[q.questionID];
|
||||
if (!ans) return '';
|
||||
if (ans.answerOptionID && optionMap[ans.answerOptionID]) return optionMap[ans.answerOptionID];
|
||||
if (ans.freeTextValue) return ans.freeTextValue;
|
||||
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
|
||||
return '';
|
||||
});
|
||||
return [...base, ...answerCols];
|
||||
});
|
||||
|
||||
let csv = '\uFEFF'; // BOM
|
||||
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 = `${questionnaireMeta.name}_v${questionnaireMeta.version}_results.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('CSV 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;
|
||||
}
|
||||
326
website/js/pages/users.js
Normal file
326
website/js/pages/users.js
Normal file
@ -0,0 +1,326 @@
|
||||
import { apiGet, apiPost, apiDelete } from '../api.js';
|
||||
import { getRole, getUser, showToast } from '../app.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 = '';
|
||||
|
||||
export async function usersPage() {
|
||||
callerRole = getRole();
|
||||
const app = document.getElementById('app');
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Users</h1>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="showCreateFormBtn">+ Create User</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="createUserWrapper" style="display:none"></div>
|
||||
<div id="usersContent"><div class="spinner"></div></div>
|
||||
`;
|
||||
|
||||
document.getElementById('showCreateFormBtn').addEventListener('click', () => {
|
||||
const wrapper = document.getElementById('createUserWrapper');
|
||||
if (wrapper.style.display === 'none') {
|
||||
showCreateForm();
|
||||
} else {
|
||||
hideCreateForm();
|
||||
}
|
||||
});
|
||||
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const data = await apiGet('users.php');
|
||||
usersList = data.users || [];
|
||||
supervisorsList = data.supervisors || [];
|
||||
callerRole = data.callerRole || callerRole;
|
||||
renderUsers();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
document.getElementById('usersContent').innerHTML =
|
||||
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── User table ────────────────────────────────────────────────────────────
|
||||
|
||||
function renderUsers() {
|
||||
const container = document.getElementById('usersContent');
|
||||
const myUsername = getUser();
|
||||
|
||||
if (!usersList.length) {
|
||||
container.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="empty-state">
|
||||
<h3>No users found</h3>
|
||||
<p>${callerRole === 'supervisor' ? 'You have no coaches yet.' : 'Create the first user above.'}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by role for a cleaner display
|
||||
const byRole = { admin: [], supervisor: [], coach: [] };
|
||||
usersList.forEach(u => (byRole[u.role] || (byRole.other = byRole.other || [])).push(u));
|
||||
|
||||
// Which role groups to show
|
||||
const groups = callerRole === 'supervisor'
|
||||
? [{ label: 'Coaches', key: 'coach' }]
|
||||
: [
|
||||
{ label: 'Admins', key: 'admin' },
|
||||
{ label: 'Supervisors', key: 'supervisor' },
|
||||
{ label: 'Coaches', key: 'coach' },
|
||||
];
|
||||
|
||||
container.innerHTML = groups.map(group => {
|
||||
const users = byRole[group.key] || [];
|
||||
if (!users.length) return '';
|
||||
return `
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h3 style="margin-bottom:12px">${group.label} (${users.length})</h3>
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
${group.key !== 'coach' ? '<th>Location</th>' : '<th>Supervisor</th>'}
|
||||
<th>Password Change Required</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${users.map(u => userRowHTML(u, myUsername)).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.delete-user-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name));
|
||||
});
|
||||
}
|
||||
|
||||
function userRowHTML(u, myUsername) {
|
||||
const isSelf = u.username === myUsername;
|
||||
const detail = u.role === 'coach'
|
||||
? esc(u.supervisorUsername || '—')
|
||||
: esc(u.location || '—');
|
||||
const created = u.createdAt
|
||||
? new Date(parseInt(u.createdAt, 10) * 1000).toLocaleDateString()
|
||||
: '—';
|
||||
const pwBadge = u.mustChangePassword == 1
|
||||
? '<span class="badge badge-pending">Pending</span>'
|
||||
: '<span style="color:var(--text-secondary);font-size:.8rem">No</span>';
|
||||
|
||||
const canDelete = !isSelf && (
|
||||
callerRole === 'admin' ||
|
||||
(callerRole === 'supervisor' && u.role === 'coach')
|
||||
);
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<strong>${esc(u.username)}</strong>
|
||||
${isSelf ? '<span class="badge badge-you">You</span>' : ''}
|
||||
</td>
|
||||
<td>${detail}</td>
|
||||
<td>${pwBadge}</td>
|
||||
<td>${created}</td>
|
||||
<td>
|
||||
${canDelete
|
||||
? `<button class="btn btn-sm btn-danger delete-user-btn" data-id="${u.userID}" data-name="${esc(u.username)}">Delete</button>`
|
||||
: ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="inline-form-card" style="margin-bottom:20px">
|
||||
<h4>Create New User</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:2">
|
||||
<label>Username</label>
|
||||
<input type="text" id="cu_username" autocomplete="off" placeholder="Enter username">
|
||||
</div>
|
||||
<div class="form-group" style="flex:2">
|
||||
<label>Password</label>
|
||||
<input type="password" id="cu_password" autocomplete="new-password" placeholder="Min 6 characters">
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;min-width:160px">
|
||||
<label>Role</label>
|
||||
${isSupervisor
|
||||
? `<input type="text" value="Coach" disabled style="background:#f8fafc">`
|
||||
: `<select id="cu_role">
|
||||
${allowedRoles.map(r =>
|
||||
`<option value="${r}">${r.charAt(0).toUpperCase() + r.slice(1)}</option>`
|
||||
).join('')}
|
||||
</select>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cu_location_row" class="form-group" ${isSupervisor ? 'style="display:none"' : ''}>
|
||||
<label>Location <span style="font-weight:400;color:var(--text-secondary)">(for admin/supervisor)</span></label>
|
||||
<input type="text" id="cu_location" placeholder="e.g. Stuttgart">
|
||||
</div>
|
||||
|
||||
<div id="cu_supervisor_row" class="form-group" style="${isSupervisor ? '' : 'display:none'}">
|
||||
<label>Supervisor</label>
|
||||
${isSupervisor
|
||||
? `<input type="text" value="You (auto-assigned)" disabled style="background:#f8fafc">`
|
||||
: `<select id="cu_supervisor_select">
|
||||
<option value="">— select supervisor —</option>
|
||||
${supervisorsList.map(s =>
|
||||
`<option value="${s.supervisorID}">${esc(s.username)}${s.location ? ` (${esc(s.location)})` : ''}</option>`
|
||||
).join('')}
|
||||
</select>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<label class="checkbox-label" style="margin-bottom:14px;display:inline-flex">
|
||||
<input type="checkbox" id="cu_mustchange" checked> Require password change on first login
|
||||
</label>
|
||||
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-primary btn-sm" id="cu_submit">Create User</button>
|
||||
<button class="btn btn-sm" id="cu_cancel">Cancel</button>
|
||||
</div>
|
||||
<p id="cu_error" class="error-text" style="display:none;margin-top:8px"></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s ?? '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
56
website/js/router.js
Normal file
56
website/js/router.js
Normal file
@ -0,0 +1,56 @@
|
||||
const routes = [];
|
||||
let currentCleanup = null;
|
||||
|
||||
export function addRoute(pattern, handler) {
|
||||
let regex;
|
||||
const paramNames = [];
|
||||
if (pattern instanceof RegExp) {
|
||||
regex = pattern;
|
||||
} else {
|
||||
const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => {
|
||||
paramNames.push(name);
|
||||
return '/([^/]+)';
|
||||
});
|
||||
regex = new RegExp('^' + parts + '$');
|
||||
}
|
||||
routes.push({ regex, paramNames, handler });
|
||||
}
|
||||
|
||||
export function navigate(hash) {
|
||||
window.location.hash = hash;
|
||||
}
|
||||
|
||||
export function currentHash() {
|
||||
return window.location.hash.slice(1) || '/';
|
||||
}
|
||||
|
||||
async function resolve() {
|
||||
if (typeof currentCleanup === 'function') {
|
||||
currentCleanup();
|
||||
currentCleanup = null;
|
||||
}
|
||||
|
||||
const path = currentHash();
|
||||
for (const route of routes) {
|
||||
const match = path.match(route.regex);
|
||||
if (match) {
|
||||
const params = {};
|
||||
route.paramNames.forEach((name, i) => {
|
||||
params[name] = decodeURIComponent(match[i + 1]);
|
||||
});
|
||||
try {
|
||||
currentCleanup = await route.handler(params) || null;
|
||||
} catch (e) {
|
||||
console.error('Route handler error:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// fallback: dashboard
|
||||
navigate('#/');
|
||||
}
|
||||
|
||||
export function startRouter() {
|
||||
window.addEventListener('hashchange', resolve);
|
||||
resolve();
|
||||
}
|
||||
Reference in New Issue
Block a user