new system prototype

This commit is contained in:
2026-04-15 10:19:42 +02:00
parent e805f225bc
commit 034b108c7e
80 changed files with 12212 additions and 890 deletions

View 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">&larr; 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;
}

View 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)">&#x2630;</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

File diff suppressed because it is too large Load Diff

104
website/js/pages/export.js Normal file
View 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">&larr; 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
View 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&uuml;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 &amp; 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
View 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">&larr; 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
View 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;
}