UX improvements in data views
This commit is contained in:
@ -58,12 +58,7 @@ $qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex, config
|
||||
$qStmt->execute([':id' => $qnID]);
|
||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$questionIDs = array_column($questions, 'questionID');
|
||||
$questionColumnLabels = [];
|
||||
foreach ($questions as &$q) {
|
||||
$cfg = json_decode($q['configJson'] ?? '{}', true) ?: [];
|
||||
$questionColumnLabels[$q['questionID']] = qdb_question_column_label($cfg, $q['defaultText'], $q['questionID'], $qnID);
|
||||
}
|
||||
unset($q);
|
||||
$resultColumns = qdb_results_export_columns($questions, $qnID);
|
||||
|
||||
// Build answer-option lookup for resolving display text
|
||||
$optionTextMap = [];
|
||||
@ -121,10 +116,10 @@ foreach ($clients as $c) {
|
||||
$answerMap = [];
|
||||
foreach ($answers as $a) $answerMap[$a['questionID']] = $a;
|
||||
|
||||
foreach ($questions as $q) {
|
||||
$qid = $q['questionID'];
|
||||
foreach ($resultColumns as $col) {
|
||||
$qid = $col['questionID'];
|
||||
$a = $answerMap[$qid] ?? null;
|
||||
$row[$questionColumnLabels[$qid]] = qdb_format_client_answer_display($q, $a, $optionTextMap);
|
||||
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap);
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
@ -149,8 +144,8 @@ if (!empty($rows)) {
|
||||
}
|
||||
} else {
|
||||
$header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt'];
|
||||
foreach ($questions as $q) {
|
||||
$header[] = $questionColumnLabels[$q['questionID']] ?? $q['defaultText'];
|
||||
foreach ($resultColumns as $col) {
|
||||
$header[] = $col['header'];
|
||||
}
|
||||
fputcsv($out, $header);
|
||||
}
|
||||
|
||||
67
common.php
67
common.php
@ -532,6 +532,73 @@ function qdb_merge_glass_symptom_json(?string $existingJson, array $symptomLabel
|
||||
return json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/** Single symptom value from glass-scale JSON stored on the parent question row. */
|
||||
function qdb_glass_symptom_answer_value(?string $json, string $symptomKey): string
|
||||
{
|
||||
$symptomKey = trim($symptomKey);
|
||||
if ($symptomKey === '' || $json === null || trim($json) === '') {
|
||||
return '';
|
||||
}
|
||||
$decoded = json_decode($json, true);
|
||||
if (!is_array($decoded)) {
|
||||
return '';
|
||||
}
|
||||
return trim((string)($decoded[$symptomKey] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat columns for results / CSV: one column per glass-scale symptom.
|
||||
*
|
||||
* @return list<array{header: string, questionID: string, symptomKey: ?string, kind: string, question: array}>
|
||||
*/
|
||||
function qdb_results_export_columns(array $questions, string $qnID): array
|
||||
{
|
||||
$columns = [];
|
||||
foreach ($questions as $q) {
|
||||
$cfg = json_decode($q['configJson'] ?? '{}', true) ?: [];
|
||||
$type = $q['type'] ?? '';
|
||||
if ($type === 'glass_scale_question') {
|
||||
$symptoms = $cfg['symptoms'] ?? [];
|
||||
if (is_array($symptoms) && $symptoms !== []) {
|
||||
foreach ($symptoms as $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk === '') {
|
||||
continue;
|
||||
}
|
||||
$columns[] = [
|
||||
'header' => $sk,
|
||||
'questionID' => $q['questionID'],
|
||||
'symptomKey' => $sk,
|
||||
'kind' => 'glass_symptom',
|
||||
'question' => $q,
|
||||
];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$columns[] = [
|
||||
'header' => qdb_question_column_label($cfg, $q['defaultText'], $q['questionID'], $qnID),
|
||||
'questionID' => $q['questionID'],
|
||||
'symptomKey' => null,
|
||||
'kind' => 'question',
|
||||
'question' => $q,
|
||||
];
|
||||
}
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/** Cell value for one export/results column (including glass symptom columns). */
|
||||
function qdb_results_column_cell_value(array $column, ?array $answerRow, array $optionTextMap): string
|
||||
{
|
||||
if (($column['kind'] ?? '') === 'glass_symptom') {
|
||||
return qdb_glass_symptom_answer_value(
|
||||
$answerRow['freeTextValue'] ?? null,
|
||||
(string)($column['symptomKey'] ?? '')
|
||||
);
|
||||
}
|
||||
return qdb_format_client_answer_display($column['question'], $answerRow, $optionTextMap);
|
||||
}
|
||||
|
||||
/** Format one client_answer row for results table / CSV export. */
|
||||
function qdb_format_client_answer_display(array $question, ?array $answerRow, array $optionTextMap): string
|
||||
{
|
||||
|
||||
@ -38,13 +38,7 @@ $qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex, config
|
||||
$qStmt->execute([':id' => $qnID]);
|
||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$questionIDs = array_column($questions, 'questionID');
|
||||
$questionColumnLabels = [];
|
||||
foreach ($questions as &$q) {
|
||||
$cfg = json_decode($q['configJson'] ?? '{}', true) ?: [];
|
||||
$label = qdb_question_column_label($cfg, $q['defaultText'], $q['questionID'], $qnID);
|
||||
$questionColumnLabels[$q['questionID']] = $label;
|
||||
}
|
||||
unset($q);
|
||||
$resultColumns = qdb_results_export_columns($questions, $qnID);
|
||||
|
||||
$optionTextMap = [];
|
||||
foreach ($questionIDs as $qid) {
|
||||
@ -99,10 +93,10 @@ foreach ($clients as $c) {
|
||||
$answerMap = [];
|
||||
foreach ($answers as $a) $answerMap[$a['questionID']] = $a;
|
||||
|
||||
foreach ($questions as $q) {
|
||||
$qid = $q['questionID'];
|
||||
foreach ($resultColumns as $col) {
|
||||
$qid = $col['questionID'];
|
||||
$a = $answerMap[$qid] ?? null;
|
||||
$row[$q['defaultText']] = qdb_format_client_answer_display($q, $a, $optionTextMap);
|
||||
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap);
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
@ -126,8 +120,8 @@ if (!empty($rows)) {
|
||||
}
|
||||
} else {
|
||||
$header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt'];
|
||||
foreach ($questions as $q) {
|
||||
$header[] = $questionColumnLabels[$q['questionID']] ?? $q['defaultText'];
|
||||
foreach ($resultColumns as $col) {
|
||||
$header[] = $col['header'];
|
||||
}
|
||||
fputcsv($out, $header);
|
||||
}
|
||||
|
||||
@ -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