UX improvements in data views
This commit is contained in:
@ -1,9 +1,9 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import { showToast } from '../app.js';
|
||||
import {
|
||||
buildResultColumns,
|
||||
isDevTestClientCode,
|
||||
questionDisplayKey,
|
||||
questionGermanLabel,
|
||||
resultColumnCellValue,
|
||||
testDataRowClassForClient,
|
||||
} from '../test-data.js';
|
||||
|
||||
@ -13,6 +13,7 @@ let sortCol = null;
|
||||
let sortDir = 'asc';
|
||||
let allClients = [];
|
||||
let questionsDef = [];
|
||||
let resultColumns = [];
|
||||
let questionnaireMeta = null;
|
||||
let filterCoach = '';
|
||||
let filterSupervisor = '';
|
||||
@ -64,6 +65,7 @@ export async function resultsPage(params) {
|
||||
|
||||
function renderResults() {
|
||||
const container = document.getElementById('resultsContent');
|
||||
resultColumns = buildResultColumns(questionsDef);
|
||||
|
||||
// Collect unique coaches and statuses for filters
|
||||
const coachOptions = getCoachFilterOptions();
|
||||
@ -107,7 +109,7 @@ function renderResults() {
|
||||
<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>
|
||||
<p class="data-toolbar-hint" style="margin-top:8px">Question columns show stable keys (e.g. country_of_origin); hover for German text. Test rows (DEV-CL-*) have a yellow background.</p>
|
||||
<p class="data-toolbar-hint" style="margin-top:8px">Each question has its own column; glass-scale symptoms are separate columns. Hover headers for context. Test rows (DEV-CL-*) are highlighted.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="table-wrapper" id="resultsTableWrap">
|
||||
@ -121,8 +123,8 @@ function renderResults() {
|
||||
`;
|
||||
|
||||
const qKeyList = document.getElementById('questionKeyList');
|
||||
qKeyList.innerHTML = questionsDef
|
||||
.map(q => `<option value="${esc(questionDisplayKey(q))}"></option>`)
|
||||
qKeyList.innerHTML = resultColumns
|
||||
.map(col => `<option value="${esc(col.label)}"></option>`)
|
||||
.join('');
|
||||
|
||||
document.getElementById('filterSupervisor').addEventListener('change', (e) => {
|
||||
@ -202,18 +204,11 @@ function renderTableHead() {
|
||||
{ key: 'sumPoints', label: 'Score' },
|
||||
{ key: 'completedAt', label: 'Completed' },
|
||||
];
|
||||
const questionCols = questionsDef.map(q => {
|
||||
const qKey = questionDisplayKey(q);
|
||||
const german = questionGermanLabel(q);
|
||||
return {
|
||||
key: `q_${q.questionID}`,
|
||||
label: qKey,
|
||||
title: german || qKey,
|
||||
questionID: q.questionID,
|
||||
questionKey: qKey,
|
||||
};
|
||||
});
|
||||
const allCols = [...fixedCols, ...questionCols];
|
||||
const allCols = [...fixedCols, ...resultColumns.map(col => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
title: col.title,
|
||||
}))];
|
||||
|
||||
head.innerHTML = allCols.map((col, idx) => {
|
||||
let cls = 'sortable';
|
||||
@ -389,19 +384,11 @@ function renderTableRows() {
|
||||
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 (q.type === 'glass_scale_question') {
|
||||
const text = formatGlassAnswer(ans.freeTextValue);
|
||||
return `<td>${esc(text || '—')}</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>';
|
||||
const questionCells = resultColumns.map(col => {
|
||||
const ans = c.answers && c.answers[col.questionID];
|
||||
const val = resultColumnCellValue(col, ans, optionMap);
|
||||
if (val == null || val === '') return '<td>—</td>';
|
||||
return `<td>${esc(String(val))}</td>`;
|
||||
}).join('');
|
||||
|
||||
return `<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
|
||||
@ -423,17 +410,16 @@ function getCellValue(client, key) {
|
||||
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 q = questionsDef.find(qq => qq.questionID === qid);
|
||||
const ans = client.answers && client.answers[qid];
|
||||
if (!ans) return null;
|
||||
if (q?.type === 'glass_scale_question') {
|
||||
return formatGlassAnswer(ans.freeTextValue) || null;
|
||||
}
|
||||
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
|
||||
if (ans.freeTextValue) return ans.freeTextValue;
|
||||
return ans.answerOptionID || null;
|
||||
const col = resultColumns.find(c => c.key === key);
|
||||
if (col) {
|
||||
const ans = client.answers && client.answers[col.questionID];
|
||||
const optionMap = {};
|
||||
questionsDef.forEach(q => {
|
||||
(q.answerOptions || []).forEach(o => {
|
||||
optionMap[o.answerOptionID] = o.defaultText;
|
||||
});
|
||||
});
|
||||
return resultColumnCellValue(col, ans, optionMap);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -448,23 +434,17 @@ function exportCSV() {
|
||||
});
|
||||
|
||||
const headers = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'completedAt',
|
||||
...questionsDef.map(q => questionDisplayKey(q))];
|
||||
...resultColumns.map(col => col.label)];
|
||||
|
||||
const rows = clients.map(c => {
|
||||
const base = [
|
||||
c.clientCode, coachDisplayName(c), c.supervisorUsername || '', 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 (q.type === 'glass_scale_question') {
|
||||
return formatGlassAnswer(ans.freeTextValue);
|
||||
}
|
||||
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 '';
|
||||
const answerCols = resultColumns.map(col => {
|
||||
const ans = c.answers && c.answers[col.questionID];
|
||||
const val = resultColumnCellValue(col, ans, optionMap);
|
||||
return val != null ? val : '';
|
||||
});
|
||||
return [...base, ...answerCols];
|
||||
});
|
||||
@ -491,22 +471,6 @@ function csvEsc(val) {
|
||||
return s;
|
||||
}
|
||||
|
||||
function formatGlassAnswer(raw) {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const o = JSON.parse(raw);
|
||||
if (o && typeof o === 'object' && !Array.isArray(o)) {
|
||||
return Object.entries(o)
|
||||
.filter(([, v]) => v != null && String(v).trim() !== '')
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('; ');
|
||||
}
|
||||
} catch {
|
||||
/* plain text fallback */
|
||||
}
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s ?? '';
|
||||
|
||||
@ -59,6 +59,80 @@ export function questionGermanLabel(q) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** One symptom value from parent glass-scale JSON answer. */
|
||||
export function glassSymptomAnswerValue(raw, symptomKey) {
|
||||
if (!raw || !symptomKey) return '';
|
||||
try {
|
||||
const o = JSON.parse(raw);
|
||||
if (o && typeof o === 'object' && !Array.isArray(o)) {
|
||||
const v = o[symptomKey];
|
||||
return v != null ? String(v).trim() : '';
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat result/export columns: glass_scale questions → one column per symptom.
|
||||
*/
|
||||
export function buildResultColumns(questions) {
|
||||
const cols = [];
|
||||
for (const q of questions || []) {
|
||||
const cfg = parseQuestionConfig(q);
|
||||
const symptoms = cfg.symptoms;
|
||||
if (q.type === 'glass_scale_question' && Array.isArray(symptoms) && symptoms.length > 0) {
|
||||
const parentKey = questionDisplayKey(q);
|
||||
for (const sk of symptoms) {
|
||||
const symptomKey = String(sk).trim();
|
||||
if (!symptomKey) continue;
|
||||
cols.push({
|
||||
key: `glass_${q.questionID}_${symptomKey}`,
|
||||
questionID: q.questionID,
|
||||
symptomKey,
|
||||
label: symptomKey,
|
||||
title: parentKey && parentKey !== symptomKey
|
||||
? `${parentKey} · ${symptomKey}`
|
||||
: symptomKey,
|
||||
isGlassSymptom: true,
|
||||
question: q,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const label = questionDisplayKey(q);
|
||||
cols.push({
|
||||
key: `q_${q.questionID}`,
|
||||
questionID: q.questionID,
|
||||
symptomKey: null,
|
||||
label,
|
||||
title: questionGermanLabel(q) || label,
|
||||
isGlassSymptom: false,
|
||||
question: q,
|
||||
});
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
/** Display text for one results table cell. */
|
||||
export function resultColumnCellValue(col, answer, optionMap = {}) {
|
||||
if (!answer) return null;
|
||||
if (col.isGlassSymptom) {
|
||||
const v = glassSymptomAnswerValue(answer.freeTextValue, col.symptomKey);
|
||||
return v || null;
|
||||
}
|
||||
const q = col.question;
|
||||
if (answer.answerOptionID && optionMap[answer.answerOptionID]) {
|
||||
return optionMap[answer.answerOptionID];
|
||||
}
|
||||
if (answer.freeTextValue) return answer.freeTextValue;
|
||||
if (answer.numericValue !== null && answer.numericValue !== undefined) {
|
||||
return answer.numericValue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** header_order.json column id → question key (questionnaire_x-y → y). */
|
||||
export function headerColumnQuestionKey(headerId) {
|
||||
const id = String(headerId ?? '');
|
||||
|
||||
Reference in New Issue
Block a user