enhanced client results and export functionality with coach and supervisor details, added date filters for results

This commit is contained in:
2026-05-20 11:01:15 +02:00
parent 2a11dfd0d1
commit 03291862f7
7 changed files with 163 additions and 10 deletions

View File

@ -56,9 +56,13 @@ foreach ($questionIDs as $qid) {
$sql = " $sql = "
SELECT cl.clientCode, cl.coachID, SELECT cl.clientCode, cl.coachID,
co.username AS coachUsername,
sv.username AS supervisorUsername,
cq.status, cq.sumPoints, cq.startedAt, cq.completedAt cq.status, cq.sumPoints, cq.startedAt, cq.completedAt
FROM client cl FROM client cl
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE $rbacClause WHERE $rbacClause
ORDER BY cl.clientCode ORDER BY cl.clientCode
"; ";
@ -79,7 +83,8 @@ $rows = [];
foreach ($clients as $c) { foreach ($clients as $c) {
$row = [ $row = [
'clientCode' => $c['clientCode'], 'clientCode' => $c['clientCode'],
'coachID' => $c['coachID'], 'coach' => $c['coachUsername'] ?? $c['coachID'],
'supervisor' => $c['supervisorUsername'] ?? '',
'status' => $c['status'], 'status' => $c['status'],
'sumPoints' => $c['sumPoints'], 'sumPoints' => $c['sumPoints'],
'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '', 'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '',
@ -131,7 +136,7 @@ if (!empty($rows)) {
fputcsv($out, array_values($row)); fputcsv($out, array_values($row));
} }
} else { } else {
$header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt']; $header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt'];
foreach ($questions as $q) $header[] = $q['defaultText']; foreach ($questions as $q) $header[] = $q['defaultText'];
fputcsv($out, $header); fputcsv($out, $header);
} }

View File

@ -73,9 +73,13 @@ if ($clientCode) {
// Only clients that have a completed_questionnaire entry for this questionnaire // Only clients that have a completed_questionnaire entry for this questionnaire
$sql = " $sql = "
SELECT cl.clientCode, cl.coachID, SELECT cl.clientCode, cl.coachID,
co.username AS coachUsername,
sv.username AS supervisorUsername,
cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach
FROM client cl FROM client cl
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE $rbacClause WHERE $rbacClause
"; ";
$params = array_merge([':qnid' => $qnID], $rbacParams); $params = array_merge([':qnid' => $qnID], $rbacParams);

View File

@ -39,9 +39,13 @@ foreach ($questionIDs as $qid) {
$sql = " $sql = "
SELECT cl.clientCode, cl.coachID, SELECT cl.clientCode, cl.coachID,
co.username AS coachUsername,
sv.username AS supervisorUsername,
cq.status, cq.sumPoints, cq.startedAt, cq.completedAt cq.status, cq.sumPoints, cq.startedAt, cq.completedAt
FROM client cl FROM client cl
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE $rbacClause WHERE $rbacClause
ORDER BY cl.clientCode ORDER BY cl.clientCode
"; ";
@ -61,7 +65,8 @@ $rows = [];
foreach ($clients as $c) { foreach ($clients as $c) {
$row = [ $row = [
'clientCode' => $c['clientCode'], 'clientCode' => $c['clientCode'],
'coachID' => $c['coachID'], 'coach' => $c['coachUsername'] ?? $c['coachID'],
'supervisor' => $c['supervisorUsername'] ?? '',
'status' => $c['status'], 'status' => $c['status'],
'sumPoints' => $c['sumPoints'], 'sumPoints' => $c['sumPoints'],
'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '', 'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '',

View File

@ -49,9 +49,13 @@ unset($q);
$sql = " $sql = "
SELECT cl.clientCode, cl.coachID, SELECT cl.clientCode, cl.coachID,
co.username AS coachUsername,
sv.username AS supervisorUsername,
cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach
FROM client cl FROM client cl
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE $rbacClause WHERE $rbacClause
"; ";
$params = array_merge([':qnid' => $qnID], $rbacParams); $params = array_merge([':qnid' => $qnID], $rbacParams);

View File

@ -53,10 +53,14 @@ class ResultRepo
$sql = " $sql = "
SELECT cl.clientCode, cl.coachID, SELECT cl.clientCode, cl.coachID,
co.username AS coachUsername,
sv.username AS supervisorUsername,
cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach
FROM client cl FROM client cl
INNER JOIN completed_questionnaire cq INNER JOIN completed_questionnaire cq
ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE $rbacClause WHERE $rbacClause
"; ";
$params = array_merge([':qnid' => $questionnaireID], $rbacParams); $params = array_merge([':qnid' => $questionnaireID], $rbacParams);

View File

@ -7,14 +7,20 @@ let allClients = [];
let questionsDef = []; let questionsDef = [];
let questionnaireMeta = null; let questionnaireMeta = null;
let filterCoach = ''; let filterCoach = '';
let filterSupervisor = '';
let filterStatus = ''; let filterStatus = '';
let filterDateFrom = '';
let filterDateTo = '';
export async function resultsPage(params) { export async function resultsPage(params) {
const app = document.getElementById('app'); const app = document.getElementById('app');
sortCol = null; sortCol = null;
sortDir = 'asc'; sortDir = 'asc';
filterCoach = ''; filterCoach = '';
filterSupervisor = '';
filterStatus = ''; filterStatus = '';
filterDateFrom = '';
filterDateTo = '';
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> <div class="page-header">
@ -44,21 +50,35 @@ function renderResults() {
const container = document.getElementById('resultsContent'); const container = document.getElementById('resultsContent');
// Collect unique coaches and statuses for filters // Collect unique coaches and statuses for filters
const coaches = [...new Set(allClients.map(c => c.coachID))].sort(); const coachOptions = getCoachFilterOptions();
const supervisorOptions = getSupervisorFilterOptions();
const statuses = [...new Set(allClients.map(c => c.status))].filter(Boolean).sort(); const statuses = [...new Set(allClients.map(c => c.status))].filter(Boolean).sort();
container.innerHTML = ` container.innerHTML = `
<div class="card" style="margin-bottom:16px"> <div class="card" style="margin-bottom:16px">
<div class="filter-bar"> <div class="filter-bar">
<label style="font-size:.85rem;font-weight:500">Filter:</label> <label style="font-size:.85rem;font-weight:500">Filter:</label>
<select id="filterSupervisor">
<option value="">All supervisors</option>
${supervisorOptions.map(name => `<option value="${esc(name)}" ${filterSupervisor === name ? 'selected' : ''}>${esc(name)}</option>`).join('')}
</select>
<select id="filterCoach"> <select id="filterCoach">
<option value="">All coaches</option> <option value="">All coaches</option>
${coaches.map(c => `<option value="${esc(c)}" ${filterCoach === c ? 'selected' : ''}>${esc(c)}</option>`).join('')} ${coachOptions.map(([id, label]) => `<option value="${esc(id)}" ${filterCoach === id ? 'selected' : ''}>${esc(label)}</option>`).join('')}
</select> </select>
<select id="filterStatus"> <select id="filterStatus">
<option value="">All statuses</option> <option value="">All statuses</option>
${statuses.map(s => `<option value="${esc(s)}" ${filterStatus === s ? 'selected' : ''}>${esc(s)}</option>`).join('')} ${statuses.map(s => `<option value="${esc(s)}" ${filterStatus === s ? 'selected' : ''}>${esc(s)}</option>`).join('')}
</select> </select>
<label style="font-size:.85rem;display:flex;align-items:center;gap:4px">
Completed from
<input type="date" id="filterDateFrom" value="${esc(filterDateFrom)}">
</label>
<label style="font-size:.85rem;display:flex;align-items:center;gap:4px">
to
<input type="date" id="filterDateTo" value="${esc(filterDateTo)}">
</label>
<button type="button" class="btn btn-sm" id="clearFiltersBtn">Clear filters</button>
<span style="margin-left:auto;font-size:.85rem;color:var(--text-secondary)" id="resultCount"></span> <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> <a id="exportCsvLink" class="btn btn-sm" href="#">Export CSV</a>
</div> </div>
@ -73,6 +93,10 @@ function renderResults() {
</div> </div>
`; `;
document.getElementById('filterSupervisor').addEventListener('change', (e) => {
filterSupervisor = e.target.value;
renderTableRows();
});
document.getElementById('filterCoach').addEventListener('change', (e) => { document.getElementById('filterCoach').addEventListener('change', (e) => {
filterCoach = e.target.value; filterCoach = e.target.value;
renderTableRows(); renderTableRows();
@ -81,6 +105,22 @@ function renderResults() {
filterStatus = e.target.value; filterStatus = e.target.value;
renderTableRows(); renderTableRows();
}); });
document.getElementById('filterDateFrom').addEventListener('change', (e) => {
filterDateFrom = e.target.value;
renderTableRows();
});
document.getElementById('filterDateTo').addEventListener('change', (e) => {
filterDateTo = e.target.value;
renderTableRows();
});
document.getElementById('clearFiltersBtn').addEventListener('click', () => {
filterCoach = '';
filterSupervisor = '';
filterStatus = '';
filterDateFrom = '';
filterDateTo = '';
renderResults();
});
document.getElementById('exportCsvLink').addEventListener('click', (e) => { document.getElementById('exportCsvLink').addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
exportCSV(); exportCSV();
@ -94,7 +134,8 @@ function renderTableHead() {
const head = document.getElementById('resultsHead'); const head = document.getElementById('resultsHead');
const fixedCols = [ const fixedCols = [
{ key: 'clientCode', label: 'Client' }, { key: 'clientCode', label: 'Client' },
{ key: 'coachID', label: 'Coach' }, { key: 'coachUsername', label: 'Coach' },
{ key: 'supervisorUsername', label: 'Supervisor' },
{ key: 'status', label: 'Status' }, { key: 'status', label: 'Status' },
{ key: 'sumPoints', label: 'Score' }, { key: 'sumPoints', label: 'Score' },
{ key: 'completedAt', label: 'Completed' }, { key: 'completedAt', label: 'Completed' },
@ -123,13 +164,58 @@ function renderTableHead() {
}); });
} }
function getCoachFilterOptions() {
const map = new Map();
allClients.forEach(c => {
if (!c.coachID || map.has(c.coachID)) return;
map.set(c.coachID, c.coachUsername || c.coachID);
});
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1]));
}
function getSupervisorFilterOptions() {
return [...new Set(allClients.map(c => c.supervisorUsername).filter(Boolean))].sort((a, b) =>
a.localeCompare(b)
);
}
function coachDisplayName(c) {
return c.coachUsername || c.coachID || '—';
}
function supervisorDisplayName(c) {
return c.supervisorUsername || '—';
}
function getFilteredClients() { function getFilteredClients() {
let clients = allClients; let clients = allClients;
if (filterSupervisor) clients = clients.filter(c => c.supervisorUsername === filterSupervisor);
if (filterCoach) clients = clients.filter(c => c.coachID === filterCoach); if (filterCoach) clients = clients.filter(c => c.coachID === filterCoach);
if (filterStatus) clients = clients.filter(c => c.status === filterStatus); if (filterStatus) clients = clients.filter(c => c.status === filterStatus);
if (filterDateFrom || filterDateTo) {
const fromTs = filterDateFrom ? dateInputToUnixStart(filterDateFrom) : null;
const toTs = filterDateTo ? dateInputToUnixEnd(filterDateTo) : null;
clients = clients.filter(c => {
const ts = c.completedAt;
if (!ts) return false;
if (fromTs !== null && ts < fromTs) return false;
if (toTs !== null && ts > toTs) return false;
return true;
});
}
return clients; return clients;
} }
function dateInputToUnixStart(yyyyMmDd) {
const [y, m, d] = yyyyMmDd.split('-').map(Number);
return Math.floor(new Date(y, m - 1, d).getTime() / 1000);
}
function dateInputToUnixEnd(yyyyMmDd) {
const [y, m, d] = yyyyMmDd.split('-').map(Number);
return Math.floor(new Date(y, m - 1, d, 23, 59, 59, 999).getTime() / 1000);
}
function renderTableRows() { function renderTableRows() {
const body = document.getElementById('resultsBody'); const body = document.getElementById('resultsBody');
let clients = getFilteredClients(); let clients = getFilteredClients();
@ -181,7 +267,8 @@ function renderTableRows() {
return `<tr> return `<tr>
<td>${esc(c.clientCode)}</td> <td>${esc(c.clientCode)}</td>
<td>${esc(c.coachID)}</td> <td>${esc(coachDisplayName(c))}</td>
<td>${esc(supervisorDisplayName(c))}</td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td>${c.sumPoints !== null ? c.sumPoints : '—'}</td> <td>${c.sumPoints !== null ? c.sumPoints : '—'}</td>
<td>${completedDate}</td> <td>${completedDate}</td>
@ -192,7 +279,8 @@ function renderTableRows() {
function getCellValue(client, key) { function getCellValue(client, key) {
if (key === 'clientCode') return client.clientCode; if (key === 'clientCode') return client.clientCode;
if (key === 'coachID') return client.coachID; if (key === 'coachUsername') return coachDisplayName(client);
if (key === 'supervisorUsername') return supervisorDisplayName(client);
if (key === 'status') return client.status; if (key === 'status') return client.status;
if (key === 'sumPoints') return client.sumPoints; if (key === 'sumPoints') return client.sumPoints;
if (key === 'completedAt') return client.completedAt; if (key === 'completedAt') return client.completedAt;
@ -216,12 +304,12 @@ function exportCSV() {
}); });
}); });
const headers = ['clientCode', 'coachID', 'status', 'sumPoints', 'completedAt', const headers = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'completedAt',
...questionsDef.map(q => q.defaultText)]; ...questionsDef.map(q => q.defaultText)];
const rows = clients.map(c => { const rows = clients.map(c => {
const base = [ const base = [
c.clientCode, c.coachID, c.status, c.sumPoints ?? '', c.clientCode, coachDisplayName(c), c.supervisorUsername || '', c.status, c.sumPoints ?? '',
c.completedAt ? new Date(c.completedAt * 1000).toISOString() : '' c.completedAt ? new Date(c.completedAt * 1000).toISOString() : ''
]; ];
const answerCols = questionsDef.map(q => { const answerCols = questionsDef.map(q => {

View File

@ -19,6 +19,7 @@ export async function usersPage() {
<div class="page-header"> <div class="page-header">
<h1>Users</h1> <h1>Users</h1>
<div class="actions"> <div class="actions">
<button class="btn" id="downloadUsersBtn" ${usersList.length ? '' : 'disabled'}>Download CSV</button>
<button class="btn btn-primary" id="showCreateFormBtn">+ Create User</button> <button class="btn btn-primary" id="showCreateFormBtn">+ Create User</button>
</div> </div>
</div> </div>
@ -34,6 +35,7 @@ export async function usersPage() {
hideCreateForm(); hideCreateForm();
} }
}); });
document.getElementById('downloadUsersBtn').addEventListener('click', downloadUsersCSV);
await loadUsers(); await loadUsers();
} }
@ -45,6 +47,8 @@ async function loadUsers() {
supervisorsList = data.supervisors || []; supervisorsList = data.supervisors || [];
callerRole = data.callerRole || callerRole; callerRole = data.callerRole || callerRole;
renderUsers(); renderUsers();
const dlBtn = document.getElementById('downloadUsersBtn');
if (dlBtn) dlBtn.disabled = !usersList.length;
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
document.getElementById('usersContent').innerHTML = document.getElementById('usersContent').innerHTML =
@ -319,6 +323,45 @@ function showError(el, msg) {
el.style.display = ''; el.style.display = '';
} }
function downloadUsersCSV() {
if (!usersList.length) {
showToast('No users to export', 'error');
return;
}
const headers = ['username', 'role', 'location', 'supervisor', 'mustChangePassword', 'createdAt', 'userID'];
const rows = usersList.map(u => [
u.username,
u.role,
u.role === 'coach' ? '' : (u.location || ''),
u.role === 'coach' ? (u.supervisorUsername || u.supervisorID || '') : '',
u.mustChangePassword == 1 ? 'yes' : 'no',
u.createdAt ? new Date(parseInt(u.createdAt, 10) * 1000).toISOString() : '',
u.userID,
]);
let csv = '\uFEFF';
csv += headers.map(csvEsc).join(',') + '\n';
rows.forEach(r => { csv += r.map(csvEsc).join(',') + '\n'; });
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `users_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast('Users exported', 'success');
}
function csvEsc(val) {
const s = String(val ?? '');
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
function esc(s) { function esc(s) {
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s ?? ''; d.textContent = s ?? '';