265 lines
9.7 KiB
JavaScript
265 lines
9.7 KiB
JavaScript
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;
|
|
}
|