initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-06-29 12:39:55 +02:00
commit f1caa9e681
148 changed files with 34905 additions and 0 deletions

214
website/js/api.js Normal file
View File

@ -0,0 +1,214 @@
/**
* Resolve API base for sibling (/website + /api) or subpath (/prefix/ + /prefix/api) layouts.
* Override per deploy: <script>window.QDB_API_BASE = '/barometer-server/api';</script>
*/
function resolveApiBase() {
if (typeof window !== 'undefined' && window.QDB_API_BASE) {
return String(window.QDB_API_BASE).replace(/\/$/, '');
}
const fromRelative = new URL('../api', window.location.href);
const relPath = fromRelative.pathname.replace(/\/$/, '');
const pageDir = window.location.pathname.replace(/\/[^/]*$/, '/') || '/';
const mount = pageDir.split('/').filter(Boolean)[0];
// Subpath deploy: /prefix/ is aliased to website/, so ../api wrongly becomes /api
if (mount && mount !== 'api' && mount !== 'website' && relPath === '/api') {
return `/${mount}/api`;
}
return relPath;
}
const API_BASE = resolveApiBase();
/** Build a URL under the API base (path may include query string). */
export function apiUrl(endpoint) {
const clean = String(endpoint).replace(/^\//, '').replace(/\.php(\?|$)/, '$1');
return `${API_BASE}/${clean}`;
}
function getToken() {
return localStorage.getItem('qdb_token');
}
const WEBSITE_ROLES = ['admin', 'supervisor'];
let sessionCheckPromise = null;
let sessionCheckToken = null;
/** Clear cached session validation (after logout or token change). */
export function invalidateSessionCheck() {
sessionCheckPromise = null;
sessionCheckToken = null;
}
/** Clear stored session and open the login page. */
export function redirectToLogin() {
invalidateSessionCheck();
localStorage.removeItem('qdb_token');
localStorage.removeItem('qdb_user');
localStorage.removeItem('qdb_role');
window.location.hash = '#/login';
}
/** @returns {boolean} true if the response means the session is no longer valid */
function isSessionInvalidResponse(res, json) {
if (res.status === 401) return true;
const errMsg = json.error?.message || (typeof json.error === 'string' ? json.error : '');
const errCode = json.error?.code || '';
return res.status === 401
|| res.status === 403 && (
errCode === 'UNAUTHORIZED'
|| errCode === 'PASSWORD_CHANGE_REQUIRED'
|| errCode === 'FORBIDDEN'
|| errMsg === 'Invalid or expired token'
|| errMsg === 'Missing Bearer token'
|| errMsg === 'Password change required before access'
);
}
/**
* Verify the stored token is still valid on the server. Redirects to login when not.
* @returns {Promise<boolean>}
*/
export async function ensureValidSession() {
const token = getToken();
if (!token) {
redirectToLogin();
return false;
}
if (sessionCheckPromise && sessionCheckToken === token) {
return sessionCheckPromise;
}
sessionCheckToken = token;
sessionCheckPromise = (async () => {
try {
const data = await apiGet('session');
if (!data.success || !data.valid) {
redirectToLogin();
return false;
}
if (data.mustChangePassword) {
redirectToLogin();
return false;
}
if (!WEBSITE_ROLES.includes(data.role)) {
redirectToLogin();
return false;
}
if (data.user) localStorage.setItem('qdb_user', data.user);
if (data.role) localStorage.setItem('qdb_role', data.role);
return true;
} catch (e) {
if (e.message !== 'Session expired') {
redirectToLogin();
}
return false;
}
})();
return sessionCheckPromise;
}
async function handleAuthResponse(res) {
if (res.status !== 401 && res.status !== 403) return;
const json = await res.json().catch(() => ({}));
if (isSessionInvalidResponse(res, json)) {
redirectToLogin();
throw new Error('Session expired');
}
const errMsg = json.error?.message || json.error || '';
throw new Error(errMsg || 'Permission denied');
}
async function apiFetch(endpoint, options = {}) {
const token = getToken();
const headers = options.headers || {};
headers['X-QDB-Client'] = 'web';
if (token) headers['Authorization'] = `Bearer ${token}`;
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json; charset=UTF-8';
}
// Strip .php suffix for clean URLs
const cleanEndpoint = endpoint.replace(/\.php(\?|$)/, '$1');
const res = await fetch(apiUrl(cleanEndpoint), { ...options, headers });
if (res.status === 401 || res.status === 403) {
await handleAuthResponse(res);
}
return res;
}
/**
* Unwrap the unified response envelope:
* {"ok": true, "data": ...} -> returns { success: true, ...data }
* {"ok": false, "error": ...} -> returns { success: false, error: message }
* Also supports legacy format for backward compat.
*/
function unwrap(json) {
if (typeof json.ok === 'boolean') {
if (json.ok) {
const d = json.data || {};
return { success: true, ...(Array.isArray(d) ? { items: d } : d) };
}
const err = json.error;
const msg = typeof err === 'string' ? err : (err?.message || 'Unknown error');
return { success: false, error: msg, errorCode: typeof err === 'object' ? err?.code : null };
}
return json;
}
export async function apiGet(endpoint) {
const res = await apiFetch(endpoint);
return unwrap(await res.json());
}
export async function apiPost(endpoint, body) {
const res = await apiFetch(endpoint, {
method: 'POST',
body: JSON.stringify(body),
});
return unwrap(await res.json());
}
export async function apiPut(endpoint, body) {
const res = await apiFetch(endpoint, {
method: 'PUT',
body: JSON.stringify(body),
});
return unwrap(await res.json());
}
export async function apiPatch(endpoint, body) {
const res = await apiFetch(endpoint, {
method: 'PATCH',
body: JSON.stringify(body),
});
return unwrap(await res.json());
}
export async function apiDelete(endpoint, body) {
const res = await apiFetch(endpoint, {
method: 'DELETE',
body: JSON.stringify(body),
});
return unwrap(await res.json());
}
/** Authenticated file/download fetch (marks request as website for activity logging). */
export async function apiDownloadFetch(url, options = {}) {
const token = getToken();
const headers = { ...(options.headers || {}), 'X-QDB-Client': 'web' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(url, { ...options, headers });
const ct = res.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
await handleAuthResponse(res);
} else if (res.status === 401 || res.status === 403) {
redirectToLogin();
throw new Error('Session expired');
}
return res;
}

192
website/js/app.js Normal file
View File

@ -0,0 +1,192 @@
import { addRoute, startRouter, navigate } from './router.js';
import { ensureValidSession, invalidateSessionCheck } from './api.js';
import { loginPage } from './pages/login.js';
import { homeDashboardPage } from './pages/home.js';
import { questionnairesPage } from './pages/questionnaires.js';
import { editorPage } from './pages/editor.js';
import { resultsPage } from './pages/results.js';
import { exportPage } from './pages/export.js';
import { usersPage } from './pages/users.js';
import { assignmentsPage } from './pages/assignments.js';
import { clientsPage } from './pages/clients.js';
import { translationsPage } from './pages/translations.js';
import { devPage } from './pages/dev.js';
import { insightsPage } from './pages/insights.js';
import { coachesPage } from './pages/coaches.js';
import { openChangeOwnPasswordModal } from './password-modal.js';
// Auth state
export function isLoggedIn() {
return !!localStorage.getItem('qdb_token');
}
export function getRole() {
return localStorage.getItem('qdb_role') || '';
}
export function getUser() {
return localStorage.getItem('qdb_user') || '';
}
const WEBSITE_ROLES = ['admin', 'supervisor'];
export function canAccessWebsite() {
return WEBSITE_ROLES.includes(getRole());
}
export function canEdit() {
return canAccessWebsite();
}
/** User-facing label for the coach role (internal key remains `coach`). */
export function formatRoleLabel(role) {
if (role === 'coach') return 'Counselor';
if (!role) return '';
return role.charAt(0).toUpperCase() + role.slice(1);
}
function clearSession() {
invalidateSessionCheck();
localStorage.removeItem('qdb_token');
localStorage.removeItem('qdb_role');
localStorage.removeItem('qdb_user');
}
function logout() {
clearSession();
navigate('#/login');
}
/** Link to home dashboard (#/). */
export const HOME_HREF = '#/';
const HOME_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 9.5L12 3l9 6.5V20a1 1 0 01-1 1h-5v-7H9v7H4a1 1 0 01-1-1V9.5z"/></svg>`;
/** House icon button — returns to dashboard. */
export function homeNavButton(title = 'Overview') {
return `<a href="${HOME_HREF}" class="btn btn-home" title="${title}" aria-label="${title}">${HOME_ICON_SVG}</a>`;
}
/**
* Standard page header with home button.
* @param {string} title HTML-safe title text
* @param {string} [actionsHTML] optional buttons inside .actions
* @param {{ subtitle?: string }} [opts]
*/
export function pageHeaderHTML(title, actionsHTML = '', opts = {}) {
const subtitle = opts.subtitle
? `<p class="page-header-sub">${opts.subtitle}</p>`
: '';
return `
<div class="page-header">
<div class="page-header-start">
${homeNavButton()}
<div class="page-header-titles">
<h1>${title}</h1>
${subtitle}
</div>
</div>
${actionsHTML ? `<div class="actions">${actionsHTML}</div>` : ''}
</div>`;
}
export function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
function authGuard(handler) {
return async (params) => {
if (!(await ensureValidSession())) return;
return handler(params);
};
}
function updateNav() {
const nav = document.getElementById('mainNav');
const userInfo = document.getElementById('userInfo');
document.body.classList.toggle('logged-in', isLoggedIn());
const hash = window.location.hash.slice(1) || '/';
document.body.classList.toggle('login-active', hash === '/login' || hash.startsWith('/login?'));
if (isLoggedIn()) {
nav.style.display = '';
const role = getRole();
const roleLabel = formatRoleLabel(role);
userInfo.textContent = `${getUser()} (${roleLabel})`;
// Show/hide role-gated nav items
document.querySelectorAll('[data-nav-roles]').forEach(li => {
const allowed = li.dataset.navRoles.split(' ');
li.style.display = allowed.includes(role) ? '' : 'none';
});
// Highlight active nav link
document.querySelectorAll('.sidebar-nav a').forEach(a => {
const href = a.getAttribute('href').slice(1);
let active = hash === href;
if (!active && href === '/questionnaires' && hash.startsWith('/questionnaire')) {
active = true;
}
if (!active && href !== '/' && hash.startsWith(href)) {
active = true;
}
a.classList.toggle('active', active);
});
} else {
nav.style.display = 'none';
userInfo.textContent = '';
}
}
function roleGuard(roles, handler) {
return async (params) => {
if (!(await ensureValidSession())) return;
if (!roles.includes(getRole())) { navigate('#/'); return; }
return handler(params);
};
}
// Routes
addRoute('/login', loginPage);
addRoute('/', roleGuard(['admin', 'supervisor'], homeDashboardPage));
addRoute('/questionnaires', authGuard(questionnairesPage));
addRoute('/questionnaire/new', authGuard(editorPage));
addRoute('/questionnaire/:id/results', authGuard(resultsPage));
addRoute('/questionnaire/:id', authGuard(editorPage));
addRoute('/export', authGuard(exportPage));
addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage));
addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage));
addRoute('/insights', roleGuard(['admin', 'supervisor'], insightsPage));
addRoute('/coaches', roleGuard(['admin', 'supervisor'], coachesPage));
addRoute('/dev', roleGuard(['admin'], devPage));
// Nav link handling & logout
document.addEventListener('DOMContentLoaded', () => {
const bindLogout = (el) => {
if (!el) return;
el.addEventListener('click', (e) => {
e.preventDefault();
logout();
});
};
bindLogout(document.getElementById('logoutBtn'));
bindLogout(document.getElementById('mobileLogoutBtn'));
const openOwnPassword = async () => {
if (!(await ensureValidSession())) return;
openChangeOwnPasswordModal();
};
document.getElementById('changePasswordBtn')?.addEventListener('click', openOwnPassword);
document.getElementById('mobileChangePasswordBtn')?.addEventListener('click', openOwnPassword);
window.addEventListener('hashchange', updateNav);
updateNav();
startRouter();
});

View File

@ -0,0 +1,48 @@
import { apiPatch } from './api.js';
import { showToast } from './app.js';
import { confirmAction } from './confirm-modal.js';
export function isClientArchived(c) {
return Number(c.archived) === 1;
}
export async function confirmAndPatchClientArchive(clientCode, archived) {
if (!(await confirmAction({
title: archived ? 'Archive client' : 'Restore client',
message: archived
? `Archive client "${clientCode}"? They will be hidden from assignments, follow-up, and the mobile app. You can restore them later from the Clients page.`
: `Restore client "${clientCode}" to active lists?`,
confirmLabel: archived ? 'Archive' : 'Restore',
variant: archived ? 'danger' : 'default',
}))) return false;
try {
await apiPatch('clients.php', { clientCode, archived });
showToast(
archived ? `Client "${clientCode}" archived` : `Client "${clientCode}" restored`,
'success'
);
return true;
} catch (e) {
showToast(e.message, 'error');
return false;
}
}
/**
* @param {object} opts
* @param {string} opts.clientCode Escaped client code for data-code attributes
* @param {boolean} opts.archived
* @param {boolean} [opts.showDelete]
* @param {string} [opts.rawCode] Unescaped code for button labels (optional)
*/
export function clientTableActionsHTML({ clientCode, archived, showDelete = false }) {
const archiveBtn = archived
? `<button type="button" class="btn btn-sm restore-client-btn" data-code="${clientCode}" title="Restore to active lists">Restore</button>`
: `<button type="button" class="btn btn-sm archive-client-btn" data-code="${clientCode}" title="Mark as finished and hide from lists">Archive</button>`;
const deleteBtn = showDelete
? `<button type="button" class="btn btn-sm btn-danger delete-client-btn" data-code="${clientCode}" title="Permanently delete client and all data">Delete</button>`
: '';
return `<div class="table-row-actions" role="group" aria-label="Client actions">${archiveBtn}${deleteBtn}</div>`;
}

View File

@ -0,0 +1,662 @@
/**
* Questionnaire availability condition UI (same JSON schema as the Android app).
*/
const CONDITION_MODES = [
{ value: 'always', label: 'Always available' },
{ value: 'requires', label: 'Requires other questionnaires completed' },
{ value: 'requires_answer', label: 'Requires completions + answer check' },
{ value: 'any_of', label: 'Any of these rules (OR)' },
{ value: 'custom', label: 'Custom JSON (advanced)' },
];
const CHOICE_LAYOUT_TYPES = new Set([
'radio_question',
'multi_check_box_question',
'glass_scale_question',
]);
export function defaultConditionMessageKey(questionnaireId) {
const id = (questionnaireId || 'new').replace(/^questionnaire_/, '');
const slug = id.replace(/[^a-zA-Z0-9_]+/g, '_').replace(/^_+|_+$/g, '') || 'new';
const normalized = /^[a-zA-Z]/.test(slug) ? slug : `k_${slug}`;
return `questionnaire_unlock_${normalized}`;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}
function parseQuestionConfig(q) {
try {
return JSON.parse(q?.configJson || '{}');
} catch {
return {};
}
}
/** Stable id stored in conditionJson.questionId (matches app endsWith check). */
export function conditionQuestionId(q) {
const cfg = parseQuestionConfig(q);
const key = (q?.questionKey || cfg.questionKey || '').trim();
if (key) return key;
if (q?.localId) return q.localId;
const parts = (q?.questionID || '').split('__');
return parts[parts.length - 1] || '';
}
function conditionQuestionLabel(q) {
const id = conditionQuestionId(q);
const text = (q?.defaultText || '').trim();
if (!text) return id;
const short = text.length > 48 ? `${text.slice(0, 45)}` : text;
return `${id}${short}`;
}
function questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId) {
if (!questionnaireId) return [];
return questionsByQuestionnaire?.[questionnaireId] || [];
}
function findQuestion(questionsByQuestionnaire, questionnaireId, questionId) {
if (!questionnaireId || !questionId) return null;
return questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId)
.find(q => {
const cid = conditionQuestionId(q);
const local = q.localId || (q.questionID || '').split('__').pop();
return questionId === cid || questionId === local || questionId === q.questionID;
}) || null;
}
function questionMatchesSelection(q, selectedQuestionId) {
if (!selectedQuestionId) return false;
const cid = conditionQuestionId(q);
const local = q.localId || (q.questionID || '').split('__').pop();
return selectedQuestionId === cid
|| selectedQuestionId === local
|| selectedQuestionId === q.questionID;
}
function asRequiresList(obj) {
if (!obj) return [];
if (Array.isArray(obj.requiresCompleted)) {
return obj.requiresCompleted.filter(Boolean).map(String);
}
if (typeof obj.requiresCompleted === 'string' && obj.requiresCompleted) {
return [obj.requiresCompleted];
}
return [];
}
function hasQuestionCheck(obj) {
return Boolean(
obj?.questionnaire?.trim() &&
obj?.questionId?.trim() &&
obj?.operator?.trim() &&
obj?.value != null && String(obj.value).trim() !== ''
);
}
function filterRequires(requires, selfId) {
if (!requires?.length) return [];
if (!selfId) return [...requires];
return requires.filter(id => id !== selfId);
}
/** Answer checks cannot reference the questionnaire being edited. */
function sanitizeAnswerRule(rule, selfId) {
if (!selfId || rule.questionnaire !== selfId) {
return { ...rule, requires: filterRequires(rule.requires, selfId) };
}
return {
...rule,
requires: filterRequires(rule.requires, selfId),
questionnaire: '',
questionId: '',
value: '',
};
}
function parseRule(obj, selfId = '') {
if (!obj || typeof obj !== 'object') {
return { type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '' };
}
if (obj.alwaysAvailable) {
return { type: 'always' };
}
const rule = {
type: hasQuestionCheck(obj) ? 'requires_answer' : 'requires',
requires: asRequiresList(obj),
questionnaire: obj.questionnaire || '',
questionId: obj.questionId || '',
operator: obj.operator === '!=' ? '!=' : '==',
value: obj.value != null ? String(obj.value) : '',
};
return sanitizeAnswerRule(rule, selfId);
}
export function parseConditionForm(conditionJson, questionnaireId) {
const raw = (conditionJson || '{}').trim() || '{}';
let obj;
try {
obj = JSON.parse(raw);
} catch {
return {
mode: 'custom',
customJson: raw,
messageKey: defaultConditionMessageKey(questionnaireId),
germanLabel: '',
};
}
const messageKey = (obj.messageKey || '').trim() || defaultConditionMessageKey(questionnaireId);
if (obj.alwaysAvailable === true) {
return { mode: 'always', messageKey: '', germanLabel: '', customJson: '' };
}
if (Array.isArray(obj.anyOf) && obj.anyOf.length) {
return {
mode: 'any_of',
rules: obj.anyOf.map(r => parseRule(r, questionnaireId)).filter(r => r.type !== 'always'),
messageKey,
germanLabel: '',
customJson: '',
};
}
const rule = parseRule(obj, questionnaireId);
return {
mode: rule.type === 'always' ? 'always' : rule.type,
requires: rule.requires,
questionnaire: rule.questionnaire,
questionId: rule.questionId,
operator: rule.operator,
value: rule.value,
messageKey,
germanLabel: '',
customJson: '',
};
}
function buildRule(rule) {
if (rule.type === 'always') {
return { alwaysAvailable: true };
}
const out = {};
if (rule.requires?.length) {
out.requiresCompleted = [...rule.requires];
}
if (rule.type === 'requires_answer') {
out.questionnaire = (rule.questionnaire || '').trim();
out.questionId = (rule.questionId || '').trim();
out.operator = rule.operator === '!=' ? '!=' : '==';
out.value = String(rule.value ?? '').trim();
}
return out;
}
function assertAnswerNotSelf(questionnaireId, selfId) {
if (selfId && questionnaireId === selfId) {
throw new Error('Answer check must use a different questionnaire (not this one)');
}
}
export function buildConditionJson(form, selfQuestionnaireId = '') {
if (form.mode === 'custom') {
const raw = (form.customJson || '{}').trim() || '{}';
JSON.parse(raw);
return raw;
}
let obj;
if (form.mode === 'always') {
obj = { alwaysAvailable: true };
} else if (form.mode === 'any_of') {
const rules = (form.rules || []).map(buildRule).filter(r => Object.keys(r).length);
if (!rules.length) {
throw new Error('Add at least one rule for "Any of these rules"');
}
obj = { anyOf: rules };
} else {
obj = buildRule({
type: form.mode,
requires: form.requires || [],
questionnaire: form.questionnaire,
questionId: form.questionId,
operator: form.operator,
value: form.value,
});
if (form.mode === 'requires' && !obj.requiresCompleted?.length) {
throw new Error('Select at least one required questionnaire');
}
if (form.mode === 'requires_answer') {
if (!obj.questionnaire) {
throw new Error('Select the questionnaire for the answer check');
}
if (!obj.questionId) {
throw new Error('Select the question for the answer check');
}
if (!obj.value) {
throw new Error('Enter or select the expected answer value');
}
}
}
const messageKey = (form.messageKey || '').trim();
if (form.mode !== 'always' && messageKey) {
obj.messageKey = messageKey;
}
return JSON.stringify(obj);
}
function requiresCheckboxes(id, selected, allQuestionnaires, selfId, editable) {
const list = (allQuestionnaires || []).filter(q => q.questionnaireID !== selfId);
if (!list.length) {
return '<p class="data-toolbar-hint">No other questionnaires to require.</p>';
}
return `<div class="condition-requires-list" id="${id}">
${list.map(q => {
const checked = selected.includes(q.questionnaireID) ? ' checked' : '';
return `<label class="checkbox-label condition-requires-item">
<input type="checkbox" value="${esc(q.questionnaireID)}"${checked}${editable ? '' : ' disabled'}>
${esc(q.name || q.questionnaireID)}
</label>`;
}).join('')}
</div>`;
}
function questionnaireSelectHTML(prefix, selectedId, allQuestionnaires, selfId, editable) {
const dis = editable ? '' : ' disabled';
const list = (allQuestionnaires || []).filter(
q => q.questionnaireID && q.questionnaireID !== selfId
);
const options = ['<option value="">— Select questionnaire —</option>']
.concat(list.map(q => {
const id = q.questionnaireID;
const sel = selectedId === id ? ' selected' : '';
const label = q.name ? `${q.name}` : id;
const suffix = q.name && q.name !== id ? ` (${id})` : '';
return `<option value="${esc(id)}"${sel}>${esc(label)}${esc(suffix)}</option>`;
}));
return `<select id="${prefix}Questionnaire" class="condition-qn-select"${dis}>${options.join('')}</select>`;
}
function questionSelectHTML(prefix, questionnaireId, selectedQuestionId, questionsByQuestionnaire, editable) {
const dis = editable ? '' : ' disabled';
const questions = questionsForQuestionnaire(questionsByQuestionnaire, questionnaireId);
if (!questionnaireId) {
return `<select id="${prefix}QuestionId" class="condition-question-select" disabled${dis}>
<option value="">Select a questionnaire first</option>
</select>`;
}
const options = ['<option value="">— Select question —</option>'];
for (const q of questions) {
const val = conditionQuestionId(q);
const sel = questionMatchesSelection(q, selectedQuestionId) ? ' selected' : '';
options.push(`<option value="${esc(val)}"${sel}>${esc(conditionQuestionLabel(q))}</option>`);
}
const known = questions.some(q => questionMatchesSelection(q, selectedQuestionId));
if (selectedQuestionId && !known) {
options.push(
`<option value="${esc(selectedQuestionId)}" selected>${esc(selectedQuestionId)} (saved)</option>`
);
}
if (!questions.length) {
options.push('<option value="" disabled>No questions in this questionnaire</option>');
}
return `<select id="${prefix}QuestionId" class="condition-question-select"${dis}>${options.join('')}</select>`;
}
function answerValueFieldHTML(prefix, rule, questionsByQuestionnaire, editable) {
const dis = editable ? '' : ' disabled';
const q = findQuestion(questionsByQuestionnaire, rule.questionnaire, rule.questionId);
const options = q?.answerOptions || [];
const hasChoices = q && CHOICE_LAYOUT_TYPES.has(q.type) && options.length > 0;
if (hasChoices) {
const opts = ['<option value="">— Select answer —</option>'];
for (const opt of options) {
const val = (opt.optionKey || opt.defaultText || '').trim();
if (!val) continue;
const label = opt.labelGerman || opt.defaultText || val;
const sel = rule.value === val ? ' selected' : '';
opts.push(`<option value="${esc(val)}"${sel}>${esc(val)}${esc(label)}</option>`);
}
if (rule.value && !options.some(o => (o.optionKey || o.defaultText || '').trim() === rule.value)) {
opts.push(`<option value="${esc(rule.value)}" selected>${esc(rule.value)} (saved)</option>`);
}
return `<select id="${prefix}Value" class="condition-value-select"${dis}>${opts.join('')}</select>`;
}
return `<input type="text" id="${prefix}Value" class="condition-value-input" value="${esc(rule.value)}" placeholder="e.g. consent_signed"${dis}>`;
}
function answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable) {
const dis = editable ? '' : ' disabled';
return `
<div class="condition-answer-grid meta-form">
<div class="form-group">
<label>Questionnaire (answer check)</label>
${questionnaireSelectHTML(prefix, rule.questionnaire, allQuestionnaires, selfId, editable)}
<p class="field-hint">Must be a different questionnaire than this one.</p>
</div>
<div class="form-group">
<label>Question</label>
${questionSelectHTML(prefix, rule.questionnaire, rule.questionId, questionsByQuestionnaire, editable)}
</div>
<div class="form-group">
<label>Operator</label>
<select id="${prefix}Operator"${dis}>
<option value="==" ${rule.operator !== '!=' ? 'selected' : ''}>equals (==)</option>
<option value="!=" ${rule.operator === '!=' ? 'selected' : ''}>not equals (!=)</option>
</select>
</div>
<div class="form-group">
<label>Expected answer</label>
${answerValueFieldHTML(prefix, rule, questionsByQuestionnaire, editable)}
</div>
</div>`;
}
function singleRuleHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable) {
const showAnswer = rule.type === 'requires_answer';
return `
<div class="condition-rule-block">
<label class="form-label">Must be completed</label>
${requiresCheckboxes(`${prefix}Requires`, rule.requires || [], allQuestionnaires, selfId, editable)}
${showAnswer ? `
<label class="form-label" style="margin-top:12px">And answer must match</label>
${answerFieldsHTML(prefix, rule, allQuestionnaires, questionsByQuestionnaire, selfId, editable)}
` : ''}
</div>`;
}
export function renderConditionEditorHTML(form, {
questionnaireId,
allQuestionnaires,
questionsByQuestionnaire,
germanLabel,
editable,
}) {
const dis = editable ? '' : ' disabled';
const modeOptions = CONDITION_MODES.map(m =>
`<option value="${m.value}" ${form.mode === m.value ? 'selected' : ''}>${m.label}</option>`
).join('');
const rules = form.rules?.length ? form.rules : [{
type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '',
}];
return `
<div class="condition-editor" id="conditionEditor">
<h4 style="margin:0 0 12px;font-size:.95rem">Availability</h4>
<div class="form-group">
<label>When can counselors open this questionnaire?</label>
<select id="conditionMode"${dis}>${modeOptions}</select>
</div>
<div id="conditionPanelRequires" class="condition-mode-panel" style="display:${form.mode === 'requires' ? '' : 'none'}">
${singleRuleHTML('cond', { type: 'requires', requires: form.requires || [] }, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
</div>
<div id="conditionPanelRequiresAnswer" class="condition-mode-panel" style="display:${form.mode === 'requires_answer' ? '' : 'none'}">
${singleRuleHTML('cond', {
type: 'requires_answer',
requires: form.requires || [],
questionnaire: form.questionnaire || '',
questionId: form.questionId || '',
operator: form.operator || '==',
value: form.value || '',
}, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
</div>
<div id="conditionPanelAnyOf" class="condition-mode-panel" style="display:${form.mode === 'any_of' ? '' : 'none'}">
<p class="data-toolbar-hint" style="margin:0 0 10px">Unlock if any one rule matches.</p>
<div id="conditionAnyOfRules">
${rules.map((rule, i) => `
<div class="condition-anyof-rule card" data-rule-index="${i}">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>Rule ${i + 1}</strong>
${editable && rules.length > 1 ? `<button type="button" class="btn btn-sm remove-anyof-rule">Remove</button>` : ''}
</div>
<div class="form-group">
<label>Rule type</label>
<select class="anyof-rule-type" data-rule-index="${i}"${dis}>
<option value="requires" ${rule.type === 'requires' ? 'selected' : ''}>Requires completions</option>
<option value="requires_answer" ${rule.type === 'requires_answer' ? 'selected' : ''}>Requires completions + answer</option>
</select>
</div>
${singleRuleHTML(`anyof${i}`, rule, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
</div>
`).join('')}
</div>
${editable ? '<button type="button" class="btn btn-sm" id="addAnyOfRule" style="margin-top:8px">+ Add rule</button>' : ''}
</div>
<div id="conditionPanelCustom" class="condition-mode-panel" style="display:${form.mode === 'custom' ? '' : 'none'}">
<textarea id="conditionCustomJson" rows="8" class="condition-json-fallback"${dis}>${esc(form.customJson || '{}')}</textarea>
</div>
<div id="conditionLockedMessage" class="condition-locked-message" style="display:${form.mode === 'always' ? 'none' : ''};margin-top:16px;padding-top:16px;border-top:1px solid var(--border)">
<p class="data-toolbar-hint" style="margin:0 0 10px">Shown on the app when locked. Also listed under Translations → App strings.</p>
<div class="meta-form">
<div class="form-group">
<label>Message key</label>
<input type="text" id="conditionMessageKey" value="${esc(form.messageKey || '')}" placeholder="${esc(defaultConditionMessageKey(questionnaireId))}"${dis}>
</div>
<div class="form-group">
<label>German label</label>
<input type="text" id="conditionGermanLabel" value="${esc(germanLabel || '')}" placeholder="e.g. Complete Demografie and RHS first"${dis}>
</div>
</div>
</div>
</div>`;
}
function readRequires(containerId) {
const root = document.getElementById(containerId);
if (!root) return [];
return [...root.querySelectorAll('input[type=checkbox]:checked')].map(cb => cb.value);
}
function readAnswerPrefix(prefix) {
return {
questionnaire: document.getElementById(`${prefix}Questionnaire`)?.value?.trim() || '',
questionId: document.getElementById(`${prefix}QuestionId`)?.value?.trim() || '',
operator: document.getElementById(`${prefix}Operator`)?.value || '==',
value: document.getElementById(`${prefix}Value`)?.value?.trim() ?? '',
};
}
export function readConditionFormFromDOM() {
const mode = document.getElementById('conditionMode')?.value || 'always';
const form = { mode, messageKey: '', germanLabel: '', customJson: '' };
if (mode === 'custom') {
form.customJson = document.getElementById('conditionCustomJson')?.value || '{}';
try {
const obj = JSON.parse(form.customJson);
form.messageKey = (obj.messageKey || '').trim();
} catch { /* validated on save */ }
form.germanLabel = document.getElementById('conditionGermanLabel')?.value?.trim() || '';
return form;
}
if (mode === 'always') {
return form;
}
form.messageKey = document.getElementById('conditionMessageKey')?.value?.trim() || '';
form.germanLabel = document.getElementById('conditionGermanLabel')?.value?.trim() || '';
if (mode === 'requires') {
form.requires = readRequires('condRequires');
return form;
}
if (mode === 'requires_answer') {
form.requires = readRequires('condRequires');
Object.assign(form, readAnswerPrefix('cond'));
return form;
}
if (mode === 'any_of') {
form.rules = [];
document.querySelectorAll('#conditionAnyOfRules .condition-anyof-rule').forEach((el, i) => {
const type = el.querySelector('.anyof-rule-type')?.value || 'requires';
const requires = readRequires(`anyof${i}Requires`);
const rule = { type, requires, ...readAnswerPrefix(`anyof${i}`) };
form.rules.push(rule);
});
}
return form;
}
function wireConditionSelects(container, { questionnaireId, onChange }) {
container.querySelectorAll('.condition-qn-select').forEach(sel => {
sel.addEventListener('change', () => {
const prefix = sel.id.replace(/Questionnaire$/, '');
const form = readConditionFormFromDOM();
if (prefix === 'cond' && form.mode === 'requires_answer') {
const answer = readAnswerPrefix('cond');
answer.questionnaire = sel.value;
if (answer.questionnaire !== form.questionnaire) {
answer.questionId = '';
answer.value = '';
}
form.questionnaire = answer.questionnaire;
form.questionId = answer.questionId;
form.value = answer.value;
} else if (form.rules) {
const idx = parseInt(prefix.replace(/^anyof/, ''), 10);
if (!Number.isNaN(idx) && form.rules[idx]) {
const prev = form.rules[idx].questionnaire;
form.rules[idx].questionnaire = sel.value;
if (sel.value !== prev) {
form.rules[idx].questionId = '';
form.rules[idx].value = '';
}
}
}
onChange(form);
});
});
container.querySelectorAll('.condition-question-select').forEach(sel => {
sel.addEventListener('change', () => {
const prefix = sel.id.replace(/QuestionId$/, '');
const form = readConditionFormFromDOM();
const qId = sel.value;
if (prefix === 'cond' && form.mode === 'requires_answer') {
form.questionId = qId;
form.value = '';
} else if (form.rules) {
const idx = parseInt(prefix.replace(/^anyof/, ''), 10);
if (!Number.isNaN(idx) && form.rules[idx]) {
form.rules[idx].questionId = qId;
form.rules[idx].value = '';
}
}
onChange(form);
});
});
}
/**
* Mount editor into container; returns { readForm, buildJson }.
*/
export function mountConditionEditor(container, {
questionnaireId,
allQuestionnaires,
questionsByQuestionnaire = {},
conditionJson,
germanLabel,
editable,
}) {
let form = parseConditionForm(conditionJson, questionnaireId);
let label = germanLabel || '';
const render = () => {
container.innerHTML = renderConditionEditorHTML(
{ ...form, germanLabel: label },
{
questionnaireId,
allQuestionnaires,
questionsByQuestionnaire,
germanLabel: label,
editable,
}
);
wire();
};
const wire = () => {
const modeEl = document.getElementById('conditionMode');
modeEl?.addEventListener('change', () => {
form = readConditionFormFromDOM();
form.mode = modeEl.value;
if (form.mode === 'always') {
form.messageKey = '';
} else if (!form.messageKey) {
form.messageKey = defaultConditionMessageKey(questionnaireId);
}
render();
});
document.getElementById('addAnyOfRule')?.addEventListener('click', () => {
form = readConditionFormFromDOM();
form.mode = 'any_of';
form.rules = form.rules || [];
form.rules.push({
type: 'requires', requires: [], questionnaire: '', questionId: '', operator: '==', value: '',
});
label = document.getElementById('conditionGermanLabel')?.value || label;
render();
});
container.querySelectorAll('.remove-anyof-rule').forEach(btn => {
btn.addEventListener('click', () => {
form = readConditionFormFromDOM();
label = document.getElementById('conditionGermanLabel')?.value || label;
const idx = parseInt(btn.closest('.condition-anyof-rule')?.dataset.ruleIndex || '-1', 10);
form.rules.splice(idx, 1);
render();
});
});
container.querySelectorAll('.anyof-rule-type').forEach(sel => {
sel.addEventListener('change', () => {
form = readConditionFormFromDOM();
label = document.getElementById('conditionGermanLabel')?.value || label;
const idx = parseInt(sel.dataset.ruleIndex || '0', 10);
if (form.rules?.[idx]) {
form.rules[idx].type = sel.value;
}
render();
});
});
wireConditionSelects(container, {
questionnaireId,
onChange: (updated) => {
form = updated;
label = document.getElementById('conditionGermanLabel')?.value || label;
render();
},
});
};
render();
return {
readForm: () => {
form = readConditionFormFromDOM();
label = document.getElementById('conditionGermanLabel')?.value?.trim() || label;
return { ...form, germanLabel: label };
},
buildJson: () => buildConditionJson(readConditionFormFromDOM(), questionnaireId),
};
}

148
website/js/confirm-modal.js Normal file
View File

@ -0,0 +1,148 @@
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}
function formatMessageHTML(message) {
return String(message)
.split(/\n\n+/)
.map((block) => `<p>${esc(block).replace(/\n/g, '<br>')}</p>`)
.join('');
}
function renderChoicesHTML(choices) {
const name = 'confirm_modal_choice';
return `
<fieldset class="password-mode-fieldset confirm-modal-choices">
<div class="password-mode-options">
${choices.map((choice, i) => `
<label class="password-mode-option">
<input type="radio" name="${name}" value="${esc(choice.value)}"${i === 0 ? ' checked' : ''}>
<span class="password-mode-option-title">${esc(choice.label)}</span>
${choice.description
? `<span class="password-mode-option-desc">${esc(choice.description)}</span>`
: ''}
</label>
`).join('')}
</div>
</fieldset>`;
}
/** @type {((value: boolean | string | null) => void) | null} */
let resolveConfirm = null;
function ensureConfirmModal() {
let modal = document.getElementById('confirmModal');
if (modal) return modal;
modal = document.createElement('div');
modal.id = 'confirmModal';
modal.className = 'modal-overlay';
modal.hidden = true;
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'confirmModalTitle');
modal.innerHTML = `
<div class="modal-dialog confirm-modal-dialog">
<h2 id="confirmModalTitle"></h2>
<div class="confirm-modal-message modal-subtitle" id="confirmModalMessage"></div>
<div id="confirmModalChoices"></div>
<div class="modal-actions">
<button type="button" class="btn btn-sm" data-confirm-cancel>Cancel</button>
<button type="button" class="btn btn-sm btn-primary" data-confirm-submit>Confirm</button>
</div>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
if (e.target === modal) finishConfirm(null);
});
modal.querySelector('[data-confirm-cancel]').addEventListener('click', () => finishConfirm(null));
modal.querySelector('[data-confirm-submit]').addEventListener('click', () => {
const choicesEl = modal.querySelector('#confirmModalChoices');
const hasChoices = choicesEl.childElementCount > 0;
if (hasChoices) {
const selected = modal.querySelector('input[name="confirm_modal_choice"]:checked');
finishConfirm(selected ? selected.value : null);
} else {
finishConfirm(true);
}
});
document.addEventListener('keydown', (e) => {
const el = document.getElementById('confirmModal');
if (!el || el.hidden) return;
if (e.key === 'Escape') finishConfirm(null);
});
return modal;
}
function finishConfirm(value) {
const modal = document.getElementById('confirmModal');
if (!modal || modal.hidden) return;
modal.hidden = true;
document.body.classList.remove('modal-open');
const resolve = resolveConfirm;
resolveConfirm = null;
if (resolve) resolve(value);
}
/**
* Show a confirmation dialog.
*
* Without `choices`: resolves to `true` (confirmed) or `false` (cancelled).
* With `choices`: resolves to the selected choice value (string) or `null` (cancelled).
*
* @param {Object} options
* @param {string} options.title
* @param {string} options.message
* @param {string} [options.confirmLabel='Confirm']
* @param {string} [options.cancelLabel='Cancel']
* @param {'default'|'danger'} [options.variant='default']
* @param {{ value: string, label: string, description?: string }[]} [options.choices]
* @returns {Promise<boolean|string|null>}
*/
export function confirmAction(options) {
return new Promise((resolve) => {
if (resolveConfirm) {
resolve(false);
return;
}
const modal = ensureConfirmModal();
const hasChoices = Array.isArray(options.choices) && options.choices.length > 0;
const variant = options.variant === 'danger' ? 'danger' : 'default';
modal.querySelector('#confirmModalTitle').textContent = options.title || 'Confirm';
modal.querySelector('#confirmModalMessage').innerHTML = formatMessageHTML(options.message || '');
const choicesEl = modal.querySelector('#confirmModalChoices');
choicesEl.innerHTML = hasChoices ? renderChoicesHTML(options.choices) : '';
const cancelBtn = modal.querySelector('[data-confirm-cancel]');
const submitBtn = modal.querySelector('[data-confirm-submit]');
cancelBtn.textContent = options.cancelLabel || 'Cancel';
submitBtn.textContent = options.confirmLabel || (hasChoices ? 'Continue' : 'Confirm');
submitBtn.className = `btn btn-sm ${variant === 'danger' ? 'btn-danger' : 'btn-primary'}`;
modal.classList.toggle('confirm-modal--danger', variant === 'danger');
resolveConfirm = (value) => {
if (hasChoices) {
resolve(value);
} else {
resolve(value === true);
}
};
modal.hidden = false;
document.body.classList.add('modal-open');
(hasChoices
? modal.querySelector('input[name="confirm_modal_choice"]')
: submitBtn
)?.focus();
});
}

View File

@ -0,0 +1,461 @@
import { apiGet, apiPost } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
const CLIENT_PAGE_SIZE = 50;
let coaches = [];
let clients = [];
let selectedCoachID = null;
let filterCoachSearch = '';
let clientsByCoach = {};
let clientPage = 0;
let selectedClientCodes = new Set();
export async function assignmentsPage() {
selectedCoachID = null;
filterCoachSearch = '';
clientPage = 0;
selectedClientCodes = new Set();
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Assign Clients')}
<div id="assignContent"><div class="spinner"></div></div>
`;
try {
const data = await apiGet('assignments.php');
coaches = data.coaches || [];
clients = data.clients || [];
rebuildClientsByCoach();
renderAssignments();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('assignContent').innerHTML =
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
}
}
function rebuildClientsByCoach() {
clientsByCoach = {};
clients.forEach(c => {
if (c.coachID) {
(clientsByCoach[c.coachID] = clientsByCoach[c.coachID] || []).push(c);
}
});
}
function renderAssignments() {
const container = document.getElementById('assignContent');
if (!coaches.length) {
container.innerHTML = `
<div class="card empty-state">
<h3>No counselors found</h3>
<p>Create counselors first before assigning clients.</p>
</div>`;
return;
}
const unassignedCount = clients.filter(c => !c.coachID).length;
container.innerHTML = `
<div class="assign-page">
<div class="assign-layout">
<aside class="card assign-coaches" aria-label="Counselors">
<div class="assign-panel-header">
<h3>Counselors <span class="data-toolbar-hint">(${coaches.length})</span></h3>
<div class="filter-bar">
<input type="search" id="coachSearch" class="filter-search"
placeholder="Search counselor or supervisor…" value="${esc(filterCoachSearch)}">
</div>
</div>
<div class="assign-panel-body">
<ul class="coach-list" id="coachList" role="listbox" aria-label="Filter by counselor"></ul>
</div>
</aside>
<section class="card assign-clients-card" aria-label="Clients">
<div class="assign-clients-toolbar">
<div class="assign-clients-toolbar-top">
<h3>Clients <span class="data-toolbar-hint" id="clientListSummary"></span></h3>
<div class="assign-clients-actions">
<button type="button" class="btn btn-sm" id="selectAllBtn">Select visible</button>
<button type="button" class="btn btn-sm" id="clearSelBtn">Clear</button>
</div>
</div>
<div class="assign-clients-filters filter-bar">
<select id="filterByCoach" aria-label="Filter by counselor">
<option value="">All counselors (${clients.length})</option>
<option value="__unassigned__">Unassigned (${unassignedCount})</option>
${coaches.map(c => {
const n = (clientsByCoach[c.coachID] || []).length;
return `<option value="${c.coachID}">${esc(c.username)} (${n})</option>`;
}).join('')}
</select>
<input type="search" id="clientSearch" class="filter-search"
placeholder="Search client or counselor…">
</div>
</div>
<div class="assign-table-panel">
<div class="table-wrapper assign-table-scroll">
<table class="data-table">
<thead>
<tr>
<th style="width:40px">
<input type="checkbox" id="selectAllCb" title="Toggle all visible rows" aria-label="Select all visible">
</th>
<th>Client Code</th>
<th>Current counselor</th>
</tr>
</thead>
<tbody id="clientTableBody"></tbody>
</table>
</div>
</div>
<div class="assign-clients-footer">
<div class="pagination-bar" id="clientPagination"></div>
<div id="selectionInfo" class="selection-info" style="display:none"></div>
</div>
</section>
</div>
<section class="card assign-action-card" id="assignActionCard" style="display:none" aria-label="Assign selection">
<h4>Assign selected clients to</h4>
<div class="assign-action-row">
<select id="targetCoachSelect" aria-label="Target counselor">
<option value="">— select counselor —</option>
${coaches.map(c =>
`<option value="${c.coachID}">${esc(c.username)}${c.supervisorUsername ? ` (${esc(c.supervisorUsername)})` : ''}</option>`
).join('')}
</select>
<button type="button" class="btn btn-primary" id="assignBtn">Assign</button>
</div>
</section>
</div>
`;
document.getElementById('coachSearch').addEventListener('input', (e) => {
filterCoachSearch = e.target.value;
renderCoachList();
});
document.getElementById('coachList').addEventListener('click', (e) => {
const item = e.target.closest('.coach-list-item');
if (!item || item.dataset.action === 'clear-filter') return;
document.querySelectorAll('.coach-list-item').forEach(x => x.classList.remove('active'));
item.classList.add('active');
selectedCoachID = item.dataset.id;
document.getElementById('filterByCoach').value = selectedCoachID;
clientPage = 0;
renderClientRows();
});
document.getElementById('filterByCoach').addEventListener('change', (e) => {
const value = e.target.value;
clientPage = 0;
if (value && value !== '__unassigned__') {
selectedCoachID = value;
highlightCoachInList(value);
} else {
selectedCoachID = null;
document.querySelectorAll('.coach-list-item').forEach(x => x.classList.remove('active'));
}
renderClientRows();
});
document.getElementById('clientSearch').addEventListener('input', () => {
clientPage = 0;
renderClientRows();
});
document.getElementById('selectAllBtn').addEventListener('click', () => {
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => {
cb.checked = true;
selectedClientCodes.add(cb.value);
});
updateSelectionInfo();
});
document.getElementById('clearSelBtn').addEventListener('click', () => {
selectedClientCodes.clear();
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => { cb.checked = false; });
syncSelectAllCb();
updateSelectionInfo();
});
document.getElementById('selectAllCb').addEventListener('change', (e) => {
document.querySelectorAll('#clientTableBody .client-cb').forEach(cb => {
cb.checked = e.target.checked;
if (e.target.checked) selectedClientCodes.add(cb.value);
else selectedClientCodes.delete(cb.value);
});
updateSelectionInfo();
});
document.getElementById('assignBtn').addEventListener('click', doAssign);
renderCoachList();
renderClientRows();
}
function getFilteredCoaches() {
const q = filterCoachSearch.trim().toLowerCase();
if (!q) return coaches;
return coaches.filter(c => {
const hay = `${c.username} ${c.supervisorUsername || ''}`.toLowerCase();
return hay.includes(q);
});
}
function renderCoachList() {
const list = document.getElementById('coachList');
if (!list) return;
const filtered = getFilteredCoaches();
const q = filterCoachSearch.trim();
if (!filtered.length) {
list.innerHTML = `<li class="coach-list-empty">${q ? 'No counselors match' : 'No counselors'}</li>`;
return;
}
list.innerHTML = filtered.map(c => coachListItemHTML(c)).join('');
if (selectedCoachID) {
highlightCoachInList(selectedCoachID);
}
}
function coachListItemHTML(c) {
const count = (clientsByCoach[c.coachID] || []).length;
const active = selectedCoachID === c.coachID ? ' active' : '';
return `
<li class="coach-list-item${active}" data-id="${c.coachID}" role="option" tabindex="0">
<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>`;
}
function highlightCoachInList(coachID) {
document.querySelectorAll('.coach-list-item').forEach(li => {
li.classList.toggle('active', li.dataset.id === coachID);
});
const active = document.querySelector(`.coach-list-item[data-id="${CSS.escape(coachID)}"]`);
active?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
function getVisibleClients() {
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 => {
const hay = `${c.clientCode} ${c.coachUsername || ''}`.toLowerCase();
return hay.includes(search);
});
}
return visible;
}
function updateClientSummary(filteredCount, pageSlice) {
const el = document.getElementById('clientListSummary');
if (!el) return;
const search = document.getElementById('clientSearch')?.value.trim() ?? '';
const coachFilter = document.getElementById('filterByCoach')?.value ?? '';
const hasFilter = search || coachFilter;
const parts = [];
if (hasFilter && filteredCount !== clients.length) {
parts.push(`${filteredCount} of ${clients.length} match`);
} else {
parts.push(String(clients.length));
}
if (pageSlice && filteredCount > CLIENT_PAGE_SIZE) {
const from = clientPage * CLIENT_PAGE_SIZE + 1;
const to = Math.min((clientPage + 1) * CLIENT_PAGE_SIZE, filteredCount);
parts.push(`rows ${from}${to}`);
}
el.textContent = `(${parts.join(', ')})`;
}
function renderPaginationBar(barId, totalRows, currentPage, pageSize, onPageChange) {
const pag = document.getElementById(barId);
if (!pag) return;
if (!totalRows) {
pag.innerHTML = '';
return;
}
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
if (totalRows <= pageSize) {
pag.innerHTML = `<span>${totalRows} row${totalRows === 1 ? '' : 's'}</span>`;
return;
}
const from = currentPage * pageSize + 1;
const to = Math.min((currentPage + 1) * pageSize, totalRows);
const prevId = `${barId}Prev`;
const nextId = `${barId}Next`;
pag.innerHTML = `
<button type="button" class="btn btn-sm" id="${prevId}" ${currentPage <= 0 ? 'disabled' : ''}>Prev</button>
<span>Rows ${from}${to} of ${totalRows} (page ${currentPage + 1}/${totalPages})</span>
<button type="button" class="btn btn-sm" id="${nextId}" ${currentPage >= totalPages - 1 ? 'disabled' : ''}>Next</button>
`;
document.getElementById(prevId)?.addEventListener('click', () => onPageChange(currentPage - 1));
document.getElementById(nextId)?.addEventListener('click', () => onPageChange(currentPage + 1));
}
function syncSelectAllCb(codesOnPage = []) {
const allCb = document.getElementById('selectAllCb');
if (!allCb) return;
if (!codesOnPage.length) {
allCb.checked = false;
allCb.indeterminate = false;
return;
}
const allSelected = codesOnPage.every(code => selectedClientCodes.has(code));
const someSelected = codesOnPage.some(code => selectedClientCodes.has(code));
allCb.checked = allSelected;
allCb.indeterminate = !allSelected && someSelected;
}
function renderClientRows() {
const body = document.getElementById('clientTableBody');
if (!body) return;
const visible = getVisibleClients();
const totalPages = Math.max(1, Math.ceil(visible.length / CLIENT_PAGE_SIZE));
if (clientPage >= totalPages) clientPage = totalPages - 1;
if (!visible.length) {
updateClientSummary(0, false);
body.innerHTML = `
<tr>
<td colspan="3" style="text-align:center;color:var(--text-secondary);padding:28px 16px">
No clients match the current filters
</td>
</tr>`;
renderPaginationBar('clientPagination', 0, clientPage, CLIENT_PAGE_SIZE, (p) => {
clientPage = p;
renderClientRows();
});
syncSelectAllCb();
updateSelectionInfo();
return;
}
const slice = visible.slice(clientPage * CLIENT_PAGE_SIZE, (clientPage + 1) * CLIENT_PAGE_SIZE);
updateClientSummary(visible.length, visible.length > CLIENT_PAGE_SIZE);
body.innerHTML = slice.map(c => {
const checked = selectedClientCodes.has(c.clientCode) ? ' checked' : '';
return `
<tr>
<td>
<input type="checkbox" class="client-cb" data-code="${esc(c.clientCode)}" value="${esc(c.clientCode)}"${checked} aria-label="Select ${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', () => {
if (cb.checked) selectedClientCodes.add(cb.value);
else selectedClientCodes.delete(cb.value);
syncSelectAllCb(slice.map(c => c.clientCode));
updateSelectionInfo();
});
});
renderPaginationBar('clientPagination', visible.length, clientPage, CLIENT_PAGE_SIZE, (p) => {
clientPage = p;
renderClientRows();
});
syncSelectAllCb(slice.map(c => c.clientCode));
updateSelectionInfo();
}
function getSelectedCodes() {
return [...selectedClientCodes];
}
function updateSelectionInfo() {
const selected = getSelectedCodes();
const info = document.getElementById('selectionInfo');
const card = document.getElementById('assignActionCard');
const onPage = document.querySelectorAll('#clientTableBody .client-cb:checked').length;
if (!selected.length) {
info.style.display = 'none';
card.style.display = 'none';
return;
}
info.style.display = '';
const extra = selected.length > onPage
? ` <span class="data-toolbar-hint">(${onPage} on this page)</span>`
: '';
info.innerHTML = `<strong>${selected.length}</strong> client${selected.length === 1 ? '' : 's'} selected${extra}`;
card.style.display = '';
if (selectedCoachID) {
const sel = document.getElementById('targetCoachSelect');
if (sel) sel.value = selectedCoachID;
}
}
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 counselor', 'error'); return; }
const coachName = coaches.find(c => c.coachID === coachID)?.username || coachID;
if (!(await confirmAction({
title: 'Assign clients',
message: `Assign ${selected.length} client(s) to ${coachName}?`,
confirmLabel: 'Assign',
}))) 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) {
selected.forEach(code => {
const c = clients.find(x => x.clientCode === code);
if (c) {
c.coachID = coachID;
c.coachUsername = coachName;
}
});
selectedClientCodes.clear();
rebuildClientsByCoach();
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;
}

938
website/js/pages/clients.js Normal file
View File

@ -0,0 +1,938 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import {
isClientArchived,
confirmAndPatchClientArchive,
clientTableActionsHTML,
} from '../client-archive.js';
const PAGE_SIZE = 50;
const ARCHIVE_FILTER_KEY = 'clientsArchiveFilter';
let clientsList = [];
let coachesList = [];
let scoringProfileCatalog = [];
let filterSearch = '';
let archiveFilter = localStorage.getItem(ARCHIVE_FILTER_KEY) || 'active';
let page = 0;
let expandedClientCode = null;
let expandedQnKey = null;
let expandedVersionKey = null;
let detailByClient = {};
export async function clientsPage() {
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Clients', '<button class="btn btn-primary" id="showCreateFormBtn">+ Create Client</button>')}
<div id="createClientWrapper" style="display:none"></div>
<div id="clientsContent"><div class="spinner"></div></div>
`;
document.getElementById('showCreateFormBtn').addEventListener('click', () => {
const wrapper = document.getElementById('createClientWrapper');
if (wrapper.style.display === 'none') {
showCreateForm();
} else {
hideCreateForm();
}
});
await loadClients();
}
async function loadClients() {
try {
const archiveQuery = archiveFilter !== 'active'
? `?archiveFilter=${encodeURIComponent(archiveFilter)}`
: '';
const [clientsData, assignData] = await Promise.all([
apiGet(`clients.php${archiveQuery}`),
apiGet('assignments.php'),
]);
clientsList = clientsData.clients || [];
scoringProfileCatalog = clientsData.scoringProfiles || [];
coachesList = assignData.coaches || [];
renderClients();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('clientsContent').innerHTML =
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
}
}
function renderClients() {
const container = document.getElementById('clientsContent');
if (!clientsList.length) {
const emptyMsg = archiveFilter === 'archived'
? '<h3>No archived clients</h3><p>Archived clients are hidden from assignments and follow-up.</p>'
: archiveFilter === 'all'
? '<h3>No clients found</h3><p>Create the first client above.</p>'
: '<h3>No active clients</h3><p>Create a client above or switch the filter to view archived clients.</p>';
container.innerHTML = `
<div class="card">
<div class="filter-bar clients-list-filters">
${archiveFilterSelectHTML()}
<input type="search" id="clientListSearch" class="filter-search" placeholder="Search client or counselor…" value="${esc(filterSearch)}">
</div>
<div class="empty-state">${emptyMsg}</div>
</div>`;
bindArchiveFilterControl();
document.getElementById('clientListSearch')?.addEventListener('input', (e) => {
filterSearch = e.target.value.trim();
});
return;
}
const showProfileColumn = scoringProfileCatalog.length > 0;
const profileLegend = showProfileColumn ? scoringLegendHTML() : '';
container.innerHTML = `
<div class="card">
<div class="filter-bar clients-list-filters">
${archiveFilterSelectHTML()}
<input type="search" id="clientListSearch" class="filter-search" placeholder="Search client or counselor…" value="${esc(filterSearch)}">
<span class="data-toolbar-hint" id="clientListCount"></span>
</div>
${profileLegend}
<p class="data-toolbar-hint" style="margin:0 0 12px">
Expand a row to view questionnaire responses and upload version history.
</p>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Client Code</th>
<th>Current counselor</th>
${showProfileColumn ? '<th>Profile scores</th>' : ''}
<th>Actions</th>
</tr>
</thead>
<tbody id="clientsTableBody"></tbody>
</table>
</div>
<div class="pagination-bar" id="clientsPagination"></div>
</div>`;
bindArchiveFilterControl();
document.getElementById('clientListSearch').addEventListener('input', (e) => {
filterSearch = e.target.value.trim();
page = 0;
clearExpandIfHidden();
renderClientTableBody();
});
ensureCoachBandGlobalClick();
renderClientTableBody();
}
function archiveFilterSelectHTML() {
return `
<select id="clientArchiveFilter" class="clients-archive-filter" aria-label="Client status filter">
<option value="active" ${archiveFilter === 'active' ? 'selected' : ''}>Active</option>
<option value="archived" ${archiveFilter === 'archived' ? 'selected' : ''}>Archived</option>
<option value="all" ${archiveFilter === 'all' ? 'selected' : ''}>All</option>
</select>`;
}
function bindArchiveFilterControl() {
const sel = document.getElementById('clientArchiveFilter');
if (!sel) return;
sel.addEventListener('change', async (e) => {
archiveFilter = e.target.value;
localStorage.setItem(ARCHIVE_FILTER_KEY, archiveFilter);
page = 0;
expandedClientCode = null;
expandedQnKey = null;
expandedVersionKey = null;
detailByClient = {};
document.getElementById('clientsContent').innerHTML = '<div class="spinner"></div>';
await loadClients();
});
}
function getFilteredClientsList() {
let list = clientsList;
if (filterSearch) {
const q = filterSearch.toLowerCase();
list = list.filter(c => {
const hay = `${c.clientCode || ''} ${c.coachUsername || ''}`.toLowerCase();
return hay.includes(q);
});
}
return list;
}
function renderClientTableBody() {
const body = document.getElementById('clientsTableBody');
const countEl = document.getElementById('clientListCount');
const filtered = getFilteredClientsList();
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
if (page >= totalPages) page = totalPages - 1;
const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
if (countEl) {
countEl.textContent = `${filtered.length} client${filtered.length === 1 ? '' : 's'}`;
}
if (!slice.length) {
body.innerHTML = `<tr><td colspan="${colCount}" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match</td></tr>`;
} else {
body.innerHTML = slice.map(c => clientRowHTML(c)).join('');
body.querySelectorAll('.client-expand-btn').forEach(btn => {
btn.addEventListener('click', () => toggleClient(btn.dataset.code));
});
body.querySelectorAll('.client-qn-expand-btn').forEach(btn => {
btn.addEventListener('click', () => toggleQn(btn.dataset.client, btn.dataset.qn));
});
body.querySelectorAll('.client-version-expand-btn').forEach(btn => {
btn.addEventListener('click', () => toggleVersion(
btn.dataset.client,
btn.dataset.qn,
btn.dataset.submission
));
});
body.querySelectorAll('.delete-client-btn').forEach(btn => {
btn.addEventListener('click', () => deleteClient(btn.dataset.code));
});
body.querySelectorAll('.archive-client-btn').forEach(btn => {
btn.addEventListener('click', () => setClientArchived(btn.dataset.code, true));
});
body.querySelectorAll('.restore-client-btn').forEach(btn => {
btn.addEventListener('click', () => setClientArchived(btn.dataset.code, false));
});
bindCoachBandPickerEvents(body);
}
const pag = document.getElementById('clientsPagination');
if (!pag) return;
if (filtered.length <= PAGE_SIZE) {
pag.innerHTML = '';
return;
}
const from = page * PAGE_SIZE + 1;
const to = Math.min((page + 1) * PAGE_SIZE, filtered.length);
pag.innerHTML = `
<button type="button" class="btn btn-sm" id="clientsPagePrev" ${page <= 0 ? 'disabled' : ''}>Prev</button>
<span>Rows ${from}${to} of ${filtered.length}</span>
<button type="button" class="btn btn-sm" id="clientsPageNext" ${page >= totalPages - 1 ? 'disabled' : ''}>Next</button>
`;
document.getElementById('clientsPagePrev')?.addEventListener('click', () => {
page--;
clearExpandIfHidden();
renderClientTableBody();
});
document.getElementById('clientsPageNext')?.addEventListener('click', () => {
page++;
clearExpandIfHidden();
renderClientTableBody();
});
}
function qnKey(clientCode, questionnaireID) {
return `${clientCode}|${questionnaireID}`;
}
function versionKey(clientCode, questionnaireID, submissionID) {
return `${clientCode}|${questionnaireID}|${submissionID}`;
}
function clearExpandIfHidden() {
const filtered = getFilteredClientsList();
const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
if (expandedClientCode && !slice.some(c => c.clientCode === expandedClientCode)) {
expandedClientCode = null;
expandedQnKey = null;
expandedVersionKey = null;
}
}
function answerTableHTML(rows, { highlightLiveDiff = false } = {}) {
if (!rows?.length) {
return '<p class="data-toolbar-hint">No answers recorded</p>';
}
return `
<table class="data-table data-table-compact">
<thead><tr><th>Question</th><th>Answer</th></tr></thead>
<tbody>
${rows.map(r => `
<tr class="${highlightLiveDiff && r.changedFromLive ? 'answer-changed' : ''}">
<td>${esc(r.label)}</td>
<td class="answer-value-cell">${esc(r.value || '—')}</td>
</tr>
`).join('')}
</tbody>
</table>`;
}
function clientDetailPanelHTML(clientCode) {
const detail = detailByClient[clientCode];
if (!detail) {
return '<p class="data-toolbar-hint">Loading…</p>';
}
const cl = detail.client || {};
const questionnaires = detail.questionnaires || [];
if (!questionnaires.length) {
return `
<p class="client-detail-meta">
Counselor: ${esc(cl.coachUsername || '—')}
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
</p>
<p class="data-toolbar-hint">No questionnaire data for this client yet.</p>`;
}
return `
<p class="client-detail-meta">
Counselor: ${esc(cl.coachUsername || '—')}
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
</p>
${renderClientScoringProfiles(detail.scoringProfiles || [], clientCode)}
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`;
}
function scoringLegendHTML() {
return `
<div class="client-scoring-legend">
<p class="client-scoring-legend-title"><strong>How profile scores work</strong></p>
<p class="data-toolbar-hint client-scoring-legend-body">
Each profile adds up questionnaire points (× weight) into a <strong>weighted total</strong>,
then maps that total to <span class="band-badge band-green">green</span>
<span class="band-badge band-yellow">yellow</span>
<span class="band-badge band-red">red</span> using the thresholds on the Session measures page.
</p>
<dl class="client-scoring-legend-dl">
<div>
<dt>Calculated</dt>
<dd>Automatic category from answers and profile rules (shown before Counselor review).</dd>
</div>
<div>
<dt>Counselor</dt>
<dd>Category confirmed or changed by the counselor in the app (“Review scores”).</dd>
</div>
<div>
<dt>Incomplete</dt>
<dd>Not all questionnaires in that profile are completed yet.</dd>
</div>
</dl>
</div>`;
}
function bandBadgeHTML(band, { pending = false, incomplete = false, label = '' } = {}) {
const text = label || band || '';
if (incomplete) {
return '<span class="band-badge band-none">incomplete</span>';
}
if (pending) {
return '<span class="band-badge band-none">awaiting review</span>';
}
if (!band) {
return '<span class="band-badge band-none">—</span>';
}
return `<span class="band-badge band-${esc(band)}">${esc(text || band)}</span>`;
}
const COACH_BANDS = ['green', 'yellow', 'red'];
function bandLabel(band) {
if (!band) return '—';
return band.charAt(0).toUpperCase() + band.slice(1);
}
function coachBandPickerHTML(p, clientCode) {
const calcBand = p.calculatedBand || p.band;
if (!calcBand || !clientCode || !p.profileID) {
return '';
}
const current = p.coachBand || '';
const coachPending = p.pendingReview !== false && !current;
const triggerBadge = coachPending
? bandBadgeHTML(null, { pending: true })
: bandBadgeHTML(current);
const showAgree = coachPending || (current && current !== calcBand);
const options = COACH_BANDS.map(b => `
<button type="button" class="coach-band-option${current === b ? ' is-selected' : ''}"
data-band="${esc(b)}" role="option" aria-selected="${current === b}">
${bandBadgeHTML(b, { label: bandLabel(b) })}
</button>`).join('');
const agreeBtn = showAgree ? `
<button type="button" class="coach-band-option coach-band-option-agree${current === calcBand ? ' is-selected' : ''}"
data-band="${esc(calcBand)}" role="option">
<span class="coach-band-option-agree-label">Match calculated</span>
${bandBadgeHTML(calcBand, { label: bandLabel(calcBand) })}
</button>` : '';
return `
<div class="coach-band-picker"
data-client="${esc(clientCode)}"
data-profile="${esc(p.profileID)}"
data-profile-name="${esc(p.name || p.profileID)}"
data-calculated="${esc(calcBand)}"
data-weighted="${esc(String(p.weightedTotal ?? ''))}"
data-coach="${esc(current)}">
<button type="button" class="coach-band-trigger" aria-haspopup="listbox" aria-expanded="false"
title="Change Counselor category">
<span class="coach-band-trigger-badge">${triggerBadge}</span>
<span class="coach-band-trigger-caret" aria-hidden="true">▾</span>
</button>
<div class="coach-band-menu" role="listbox" hidden>
<p class="coach-band-menu-label">Counselor category</p>
<div class="coach-band-menu-options">${options}</div>
${agreeBtn}
</div>
</div>`;
}
function profileScoreBlockHTML(p, clientCode = '') {
const calcBand = p.calculatedBand || p.band;
if (!calcBand) {
return `
<div class="client-profile-score-block is-incomplete">
<div class="client-profile-score-block-title">${esc(p.name)}</div>
<div class="client-profile-score-row">
<span class="client-profile-score-label">Calculated</span>
${bandBadgeHTML(null, { incomplete: true })}
</div>
<p class="client-profile-score-hint">Not all questionnaires in this profile are completed yet.</p>
</div>`;
}
const coachPending = p.pendingReview !== false && !p.coachBand;
const coachDiffers = p.coachBand && p.coachBand !== calcBand;
const coachControl = clientCode
? coachBandPickerHTML(p, clientCode)
: (coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand));
return `
<div class="client-profile-score-block${coachDiffers ? ' has-coach-override' : ''}" data-profile-id="${esc(p.profileID || '')}">
<div class="client-profile-score-block-title">${esc(p.name)}</div>
<div class="client-profile-score-row">
<span class="client-profile-score-label">Calculated</span>
${bandBadgeHTML(calcBand)}
<span class="client-profile-score-total" title="Weighted total">${esc(String(p.weightedTotal))}</span>
</div>
<div class="client-profile-score-row">
<span class="client-profile-score-label">Counselor</span>
${coachControl}
${coachDiffers ? '<span class="client-profile-override-hint">override</span>' : ''}
</div>
</div>`;
}
function renderClientScoringProfiles(profiles, clientCode = '') {
if (!profiles.length) return '';
return `
<div class="client-scoring-profiles-detail">
<strong>Scoring profiles</strong>
<p class="data-toolbar-hint" style="margin:4px 0 10px">
Weighted totals and calculated vs Counselor categories for this client.
</p>
<div class="client-profile-scores">${profiles.map(p => {
const calc = p.calculatedBand || p.band;
if (!calc) return profileScoreBlockHTML(p, clientCode);
const coachPending = p.pendingReview !== false && !p.coachBand;
const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : '';
const reviewedAt = p.coachReviewedAt ? `Counselor reviewed ${esc(p.coachReviewedAt)}` : '';
const meta = [computedAt, reviewedAt].filter(Boolean).join(' · ');
return `
<div class="scoring-profile-detail-card">
${profileScoreBlockHTML(p, clientCode)}
${meta ? `<p class="client-profile-score-meta">${meta}</p>` : ''}
${coachPending ? '<p class="client-profile-score-hint">Counselor has not reviewed this profile in the app yet.</p>' : ''}
</div>`;
}).join('')}</div>
</div>`;
}
function clientQnBlockHTML(clientCode, q) {
const key = qnKey(clientCode, q.questionnaireID);
const qnExpanded = expandedQnKey === key;
const meta = [
q.status ? `Status: ${q.status}` : '',
q.sumPoints != null ? `Points: ${q.sumPoints}` : '',
q.completedAt ? `Completed: ${q.completedAt}` : '',
q.submissionCount ? `${q.submissionCount} upload(s)` : '',
].filter(Boolean).join(' · ');
const versionsHTML = (q.submissions || []).map(s => {
const vKey = versionKey(clientCode, q.questionnaireID, s.submissionID);
const vExpanded = expandedVersionKey === vKey;
const vMeta = [
s.submittedAt ? `Uploaded: ${s.submittedAt}` : '',
s.status ? `Status: ${s.status}` : '',
s.sumPoints != null ? `Points: ${s.sumPoints}` : '',
].filter(Boolean).join(' · ');
return `
<div class="client-qn-block">
<div>
<button type="button" class="btn btn-sm btn-link client-version-expand-btn"
data-client="${esc(clientCode)}"
data-qn="${esc(q.questionnaireID)}"
data-submission="${esc(s.submissionID)}">${vExpanded ? '▼' : '▶'}</button>
<strong>Version ${s.version}</strong>
${s.structureRevision ? `<span class="badge badge-q">struct rev ${s.structureRevision}</span>` : ''}
${s.changedCount > 0
? `<span class="badge badge-warn">${s.changedCount} diff vs live</span>`
: '<span class="client-qn-summary">Same as live</span>'}
${vMeta ? `<span class="client-qn-summary">${esc(vMeta)}</span>` : ''}
</div>
${vExpanded ? `
<div class="client-version-panel">
${s.changedCount > 0
? '<p class="data-toolbar-hint">Highlighted rows differ from current live data.</p>'
: ''}
${answerTableHTML(s.answers, { highlightLiveDiff: true })}
</div>
` : ''}
</div>`;
}).join('');
return `
<div class="client-qn-block" style="margin-bottom:10px">
<div>
<button type="button" class="btn btn-sm btn-link client-qn-expand-btn"
data-client="${esc(clientCode)}"
data-qn="${esc(q.questionnaireID)}">${qnExpanded ? '▼' : '▶'}</button>
<strong>${esc(q.name)}</strong>
${q.questionnaireVersion ? `<span class="badge badge-q">v${esc(q.questionnaireVersion)}</span>` : ''}
${meta ? `<span class="client-qn-summary">${esc(meta)}</span>` : ''}
</div>
${qnExpanded ? `
<div class="client-qn-panel">
<h4 style="margin:0 0 8px;font-size:0.9rem">Current data (live)</h4>
${answerTableHTML(q.currentAnswers)}
${(q.submissions || []).length ? `
<h4 style="margin:16px 0 8px;font-size:0.9rem">Upload versions</h4>
${versionsHTML}
` : '<p class="data-toolbar-hint" style="margin-top:12px">No archived upload versions.</p>'}
</div>
` : ''}
</div>`;
}
function profileDotsHTML(profiles, clientCode) {
if (!profiles?.length) {
return '<span class="client-profile-dots-empty">No scoring profiles</span>';
}
return `<div class="client-profile-scores">${profiles.map(p => profileScoreBlockHTML(p, clientCode)).join('')}</div>`;
}
function patchClientScoringBand(clientCode, profileID, coachBand) {
const client = clientsList.find(c => c.clientCode === clientCode);
if (client?.scoringProfiles) {
const profile = client.scoringProfiles.find(p => p.profileID === profileID);
if (profile) {
profile.coachBand = coachBand;
profile.pendingReview = false;
}
}
const detail = detailByClient[clientCode];
if (detail?.scoringProfiles) {
const profile = detail.scoringProfiles.find(p => p.profileID === profileID);
if (profile) {
profile.coachBand = coachBand;
profile.pendingReview = false;
}
}
}
function resetCoachBandMenuPosition(menu) {
if (!menu) return;
menu.style.position = '';
menu.style.left = '';
menu.style.top = '';
menu.style.minWidth = '';
menu.style.zIndex = '';
menu.style.visibility = '';
}
function positionCoachBandMenu(picker) {
const trigger = picker.querySelector('.coach-band-trigger');
const menu = picker.querySelector('.coach-band-menu');
if (!trigger || !menu) return;
menu.hidden = false;
menu.style.visibility = 'hidden';
const rect = trigger.getBoundingClientRect();
const menuWidth = Math.max(rect.width, 184);
const left = Math.min(rect.left, window.innerWidth - menuWidth - 8);
menu.style.position = 'fixed';
menu.style.left = `${Math.max(8, left)}px`;
menu.style.minWidth = `${menuWidth}px`;
menu.style.zIndex = '10001';
const menuHeight = menu.offsetHeight;
let top = rect.bottom + 6;
if (top + menuHeight > window.innerHeight - 8) {
top = rect.top - menuHeight - 6;
}
menu.style.top = `${Math.max(8, top)}px`;
menu.style.visibility = '';
}
function closeAllCoachBandMenus() {
document.querySelectorAll('.coach-band-picker.is-open').forEach(picker => {
picker.classList.remove('is-open');
const menu = picker.querySelector('.coach-band-menu');
const trigger = picker.querySelector('.coach-band-trigger');
resetCoachBandMenuPosition(menu);
if (menu) menu.hidden = true;
if (trigger) trigger.setAttribute('aria-expanded', 'false');
});
}
function toggleCoachBandMenu(picker) {
const isOpen = picker.classList.contains('is-open');
closeAllCoachBandMenus();
if (isOpen) return;
picker.classList.add('is-open');
const trigger = picker.querySelector('.coach-band-trigger');
positionCoachBandMenu(picker);
if (trigger) trigger.setAttribute('aria-expanded', 'true');
}
function openCoachBandConfirmModal({ clientCode, profileName, calculatedBand, coachBand }) {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay coach-band-confirm-modal';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML = `
<div class="modal-dialog">
<h2>Set Counselor category</h2>
<p class="modal-subtitle">
<strong>${esc(profileName)}</strong> · ${esc(clientCode)}
</p>
<div class="coach-band-confirm-compare">
<div class="coach-band-confirm-col">
<span class="coach-band-confirm-label">Calculated</span>
${bandBadgeHTML(calculatedBand, { label: bandLabel(calculatedBand) })}
</div>
<div class="coach-band-confirm-arrow" aria-hidden="true">→</div>
<div class="coach-band-confirm-col">
<span class="coach-band-confirm-label">Counselor</span>
${bandBadgeHTML(coachBand, { label: bandLabel(coachBand) })}
</div>
</div>
<p class="modal-hint">This overrides the Counselor category for this client and profile.</p>
<div class="modal-actions">
<button type="button" class="btn" data-coach-cancel>Cancel</button>
<button type="button" class="btn btn-primary" data-coach-confirm>Save category</button>
</div>
</div>`;
document.body.appendChild(overlay);
document.body.classList.add('modal-open');
const close = (confirmed) => {
overlay.remove();
document.body.classList.remove('modal-open');
resolve(confirmed);
};
overlay.querySelector('[data-coach-cancel]').addEventListener('click', () => close(false));
overlay.querySelector('[data-coach-confirm]').addEventListener('click', () => close(true));
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close(false);
});
overlay.querySelector('.modal-dialog').addEventListener('click', (e) => e.stopPropagation());
const onKey = (e) => {
if (e.key === 'Escape') close(false);
};
document.addEventListener('keydown', onKey, { once: true });
overlay.querySelector('[data-coach-confirm]').focus();
});
}
async function promptCoachBandSave(picker, coachBand) {
const clientCode = picker.dataset.client;
const profileID = picker.dataset.profile;
const profileName = picker.dataset.profileName || profileID;
const calculatedBand = picker.dataset.calculated;
const current = picker.dataset.coach || '';
if (!coachBand || coachBand === current) return;
const confirmed = await openCoachBandConfirmModal({
clientCode,
profileName,
calculatedBand,
coachBand,
});
if (!confirmed) return;
const weightedRaw = picker.dataset.weighted;
const weightedTotal = weightedRaw !== '' ? Number(weightedRaw) : null;
picker.classList.add('is-saving');
try {
await apiPut('clients.php', {
clientCode,
profileID,
coachBand,
calculatedBand,
weightedTotal,
});
patchClientScoringBand(clientCode, profileID, coachBand);
showToast(`Counselor category saved for ${profileName}`, 'success');
renderClientTableBody();
} catch (e) {
showToast(e.message, 'error');
} finally {
picker.classList.remove('is-saving');
}
}
function bindCoachBandPickerEvents(root = document) {
root.querySelectorAll('.coach-band-trigger').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
toggleCoachBandMenu(btn.closest('.coach-band-picker'));
});
});
root.querySelectorAll('.coach-band-option').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const picker = btn.closest('.coach-band-picker');
closeAllCoachBandMenus();
promptCoachBandSave(picker, btn.dataset.band);
});
});
}
let coachBandGlobalClickBound = false;
function ensureCoachBandGlobalClick() {
if (coachBandGlobalClickBound) return;
coachBandGlobalClickBound = true;
document.addEventListener('click', () => closeAllCoachBandMenus());
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllCoachBandMenus();
});
}
function clientHasResponseData(c) {
return Number(c.hasResponseData) === 1;
}
function clientArchivedLabel(c) {
if (!isClientArchived(c) || !c.archivedAt) return '';
const d = new Date(Number(c.archivedAt) * 1000);
const when = Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
return when ? ` <span class="client-archived-badge" title="Archived ${esc(when)}">Archived</span>` : '';
}
function clientRowHTML(c) {
const coach = c.coachUsername
? `<strong>${esc(c.coachUsername)}</strong>`
: `<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>`;
const archived = isClientArchived(c);
const canExpand = clientHasResponseData(c);
const expanded = canExpand && expandedClientCode === c.clientCode;
const expandControl = canExpand
? `<button type="button" class="btn btn-sm btn-link client-expand-btn" data-code="${esc(c.clientCode)}">${expanded ? '▼' : '▶'}</button> `
: '';
const profileCol = scoringProfileCatalog.length > 0
? `<td class="client-profile-dots-cell">${profileDotsHTML(c.scoringProfiles || [], c.clientCode)}</td>`
: '';
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
const actions = clientTableActionsHTML({
clientCode: esc(c.clientCode),
archived,
showDelete: true,
});
return `
<tr class="${archived ? 'client-row-archived' : ''}">
<td>
${expandControl}<strong>${esc(c.clientCode)}</strong>${clientArchivedLabel(c)}
</td>
<td>${coach}</td>
${profileCol}
<td class="client-actions-cell">${actions}</td>
</tr>
${expanded ? `
<tr class="client-detail-row">
<td colspan="${colCount}">
<div class="client-detail-panel">${clientDetailPanelHTML(c.clientCode)}</div>
</td>
</tr>` : ''}`;
}
async function toggleClient(clientCode) {
const row = clientsList.find(c => c.clientCode === clientCode);
if (!row || !clientHasResponseData(row)) {
return;
}
if (expandedClientCode === clientCode) {
expandedClientCode = null;
expandedQnKey = null;
expandedVersionKey = null;
renderClientTableBody();
return;
}
expandedClientCode = clientCode;
expandedQnKey = null;
expandedVersionKey = null;
renderClientTableBody();
if (!detailByClient[clientCode]) {
try {
const data = await apiGet(
`clients.php?clientCode=${encodeURIComponent(clientCode)}&detail=1`
);
detailByClient[clientCode] = data;
} catch (e) {
showToast(e.message, 'error');
detailByClient[clientCode] = { client: {}, questionnaires: [] };
}
renderClientTableBody();
}
}
function toggleQn(clientCode, questionnaireID) {
const key = qnKey(clientCode, questionnaireID);
expandedQnKey = expandedQnKey === key ? null : key;
expandedVersionKey = null;
renderClientTableBody();
}
function toggleVersion(clientCode, questionnaireID, submissionID) {
const key = versionKey(clientCode, questionnaireID, submissionID);
expandedVersionKey = expandedVersionKey === key ? null : key;
renderClientTableBody();
}
async function setClientArchived(clientCode, archived) {
const ok = await confirmAndPatchClientArchive(clientCode, archived);
if (!ok) return;
if (archived && archiveFilter === 'active') {
clientsList = clientsList.filter(c => c.clientCode !== clientCode);
} else if (!archived && archiveFilter === 'archived') {
clientsList = clientsList.filter(c => c.clientCode !== clientCode);
} else {
const row = clientsList.find(c => c.clientCode === clientCode);
if (row) {
row.archived = archived ? 1 : 0;
row.archivedAt = archived ? Math.floor(Date.now() / 1000) : 0;
}
}
if (expandedClientCode === clientCode && archived) {
expandedClientCode = null;
expandedQnKey = null;
expandedVersionKey = null;
}
if (!clientsList.length) renderClients();
else renderClientTableBody();
}
async function deleteClient(clientCode) {
if (!(await confirmAction({
title: 'Delete client',
message: `Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`,
confirmLabel: 'Delete',
variant: 'danger',
}))) return;
try {
await apiDelete('clients.php', { clientCode });
clientsList = clientsList.filter(c => c.clientCode !== clientCode);
delete detailByClient[clientCode];
if (expandedClientCode === clientCode) {
expandedClientCode = null;
expandedQnKey = null;
expandedVersionKey = null;
}
showToast(`Client "${clientCode}" deleted`, 'success');
if (!clientsList.length) renderClients();
else renderClientTableBody();
} catch (e) {
showToast(e.message, 'error');
}
}
function showCreateForm() {
if (!coachesList.length) {
showToast('No counselors available. Create a counselor first.', 'error');
return;
}
const wrapper = document.getElementById('createClientWrapper');
wrapper.style.display = '';
wrapper.innerHTML = `
<div class="inline-form-card" style="margin-bottom:20px">
<h4>Create New Client</h4>
<div class="form-row">
<div class="form-group" style="flex:2">
<label>Client Code</label>
<input type="text" id="cc_code" autocomplete="off" placeholder="Enter unique client code">
</div>
<div class="form-group" style="flex:2">
<label>Assign to counselor</label>
<select id="cc_coach">
<option value="">— select counselor —</option>
${coachesList.map(c =>
`<option value="${c.coachID}">${esc(c.username)}${c.supervisorUsername ? ` (${esc(c.supervisorUsername)})` : ''}</option>`
).join('')}
</select>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" id="cc_submit">Create Client</button>
<button class="btn btn-sm" id="cc_cancel">Cancel</button>
</div>
<p id="cc_error" class="error-text" style="display:none;margin-top:8px"></p>
</div>
`;
const input = document.getElementById('cc_code');
input.focus();
wrapper.querySelectorAll('input').forEach(inp => {
inp.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitCreateClient();
if (e.key === 'Escape') hideCreateForm();
});
});
document.getElementById('cc_submit').addEventListener('click', submitCreateClient);
document.getElementById('cc_cancel').addEventListener('click', hideCreateForm);
}
function hideCreateForm() {
const wrapper = document.getElementById('createClientWrapper');
if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; }
}
async function submitCreateClient() {
const errEl = document.getElementById('cc_error');
errEl.style.display = 'none';
const clientCode = document.getElementById('cc_code').value.trim();
const coachID = document.getElementById('cc_coach').value;
if (!clientCode) { showError(errEl, 'Client code is required'); return; }
if (!coachID) { showError(errEl, 'Please select a counselor'); return; }
const btn = document.getElementById('cc_submit');
btn.disabled = true;
btn.textContent = 'Creating...';
try {
const data = await apiPost('clients.php', { clientCode, coachID });
const coach = coachesList.find(c => c.coachID === coachID);
clientsList.push({
clientCode: data.clientCode,
coachID: coachID,
coachUsername: coach?.username ?? null,
});
showToast(`Client "${data.clientCode}" created`, 'success');
hideCreateForm();
renderClients();
} catch (e) {
showError(errEl, e.message);
btn.disabled = false;
btn.textContent = 'Create Client';
}
}
function showError(el, msg) {
el.textContent = msg;
el.style.display = '';
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

423
website/js/pages/coaches.js Normal file
View File

@ -0,0 +1,423 @@
import { apiGet } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js';
const FETCH_LIMIT = 100;
const CLIENT_GROUP_PAGE = 10;
const UPLOADS_VISIBLE = 5;
let coachesList = [];
let expandedCoachId = null;
/** @type {Record<string, { submissions: object[], total: number }>} */
let recentByCoach = {};
/** @type {Record<string, { clientLimit: number, expandedClients: Set<string>, showAllUploads: Set<string>, filter: string }>} */
let coachDetailUi = {};
let filterSearch = '';
export async function coachesPage() {
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Counselor activity')}
<div id="coachesContent"><div class="spinner"></div></div>
`;
try {
const data = await apiGet('coaches.php');
coachesList = data.coaches || [];
renderCoaches();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('coachesContent').innerHTML =
`<p class="error-text">${esc(e.message)}</p>`;
}
}
function coachSearchText(c) {
return [c.username, c.coachID, c.supervisorUsername].filter(Boolean).join(' ');
}
function coachHasUploads(c) {
return (c.totalSubmissions ?? 0) > 0;
}
function filteredCoaches() {
const q = filterSearch.trim().toLowerCase();
if (!q) return coachesList;
return coachesList.filter(c => coachSearchText(c).toLowerCase().includes(q));
}
function getCoachUi(coachID) {
if (!coachDetailUi[coachID]) {
coachDetailUi[coachID] = {
clientLimit: CLIENT_GROUP_PAGE,
expandedClients: new Set(),
showAllUploads: new Set(),
filter: '',
};
}
return coachDetailUi[coachID];
}
function groupSubmissionsByClient(submissions) {
const map = new Map();
for (const s of submissions) {
const code = s.clientCode || '';
if (!map.has(code)) {
map.set(code, []);
}
map.get(code).push(s);
}
const groups = [];
for (const [clientCode, uploads] of map) {
uploads.sort((a, b) => String(b.submittedAt).localeCompare(String(a.submittedAt)));
groups.push({
clientCode,
uploads,
count: uploads.length,
lastAt: uploads[0]?.submittedAt || '',
});
}
groups.sort((a, b) => String(b.lastAt).localeCompare(String(a.lastAt)));
return groups;
}
function filterClientGroups(groups, q) {
if (!q) return groups;
const needle = q.toLowerCase();
return groups.filter(g => {
if (g.clientCode.toLowerCase().includes(needle)) return true;
return g.uploads.some(u =>
(u.questionnaireName || '').toLowerCase().includes(needle)
|| (u.questionnaireID || '').toLowerCase().includes(needle)
);
});
}
function uploadsTableHTML(uploads) {
if (!uploads.length) {
return '<p class="data-toolbar-hint">No uploads</p>';
}
return `
<table class="data-table data-table-compact">
<thead>
<tr><th>When</th><th>Questionnaire</th><th>Ver.</th></tr>
</thead>
<tbody>
${uploads.map(s => `
<tr>
<td>${esc(s.submittedAt)}</td>
<td>${esc(s.questionnaireName)}</td>
<td>${s.version}</td>
</tr>
`).join('')}
</tbody>
</table>`;
}
function coachRecentPanelHTML(coachID) {
const data = recentByCoach[coachID];
if (!data) {
return '<p class="data-toolbar-hint">Loading…</p>';
}
const ui = getCoachUi(coachID);
const allGroups = groupSubmissionsByClient(data.submissions || []);
const groups = filterClientGroups(allGroups, ui.filter.trim());
const visibleGroups = groups.slice(0, ui.clientLimit);
const hasMoreClients = groups.length > ui.clientLimit;
const hasMoreUploads = (data.submissions?.length ?? 0) < (data.total ?? 0);
const summary = data.total
? `${data.total} upload${data.total === 1 ? '' : 's'} · ${allGroups.length} client${allGroups.length === 1 ? '' : 's'}`
: 'No uploads';
let groupsHTML = '';
if (!groups.length) {
groupsHTML = `<p class="data-toolbar-hint">${ui.filter.trim() ? 'No matches' : 'No uploads'}</p>`;
} else {
groupsHTML = visibleGroups.map(g => {
const expanded = ui.expandedClients.has(g.clientCode);
const showAll = ui.showAllUploads.has(g.clientCode);
const visibleUploads = showAll ? g.uploads : g.uploads.slice(0, UPLOADS_VISIBLE);
const hiddenCount = g.uploads.length - UPLOADS_VISIBLE;
return `
<div class="coach-client-group" data-client="${esc(g.clientCode)}">
<button type="button" class="coach-client-toggle btn btn-sm btn-link"
data-coach="${esc(coachID)}" data-client="${esc(g.clientCode)}">
${expanded ? '▼' : '▶'}
<code>${esc(g.clientCode)}</code>
<span class="coach-client-meta">${g.count} upload${g.count === 1 ? '' : 's'} · last ${esc(g.lastAt || '—')}</span>
</button>
${expanded ? `
<div class="coach-client-uploads">
${uploadsTableHTML(visibleUploads)}
${!showAll && hiddenCount > 0 ? `
<button type="button" class="btn btn-sm coach-show-all-uploads"
data-coach="${esc(coachID)}" data-client="${esc(g.clientCode)}">
Show all ${g.count} uploads
</button>
` : ''}
</div>
` : ''}
</div>`;
}).join('');
}
return `
<div class="coach-recent-panel coach-recent-scroll" data-coach-panel="${esc(coachID)}">
<div class="coach-recent-toolbar">
<input type="search" class="filter-search coach-detail-search"
placeholder="Filter client or questionnaire…"
value="${esc(ui.filter)}"
data-coach="${esc(coachID)}">
<span class="data-toolbar-hint">${esc(summary)}${ui.filter.trim() && groups.length !== allGroups.length
? ` · ${groups.length} shown`
: ''}</span>
</div>
<div class="coach-client-groups">${groupsHTML}</div>
<div class="coach-recent-actions">
${hasMoreClients ? `
<button type="button" class="btn btn-sm coach-more-clients" data-coach="${esc(coachID)}">
Show more clients (${groups.length - ui.clientLimit} remaining)
</button>
` : ''}
${hasMoreUploads ? `
<button type="button" class="btn btn-sm coach-more-uploads" data-coach="${esc(coachID)}">
Load more uploads (${(data.total ?? 0) - (data.submissions?.length ?? 0)} remaining)
</button>
` : ''}
</div>
</div>`;
}
function renderCoaches() {
const el = document.getElementById('coachesContent');
if (!coachesList.length) {
el.innerHTML = `<div class="card empty-state"><h3>No counselors</h3></div>`;
return;
}
el.innerHTML = `
<div class="card">
<p class="data-toolbar-hint" style="margin:0 0 12px">
Track uploads and client load per counselor. Expand a row to browse uploads by client.
</p>
<div class="filter-bar">
<input type="search" id="coachListSearch" class="filter-search"
placeholder="Search counselor or supervisor…" value="${esc(filterSearch)}">
<span class="data-toolbar-hint" id="coachListCount"></span>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Counselor</th><th>Supervisor</th><th>Clients</th>
<th>Total uploads</th><th>7d</th><th>30d</th><th>Last upload</th>
</tr>
</thead>
<tbody id="coachTableBody"></tbody>
</table>
</div>
</div>
`;
document.getElementById('coachListSearch').addEventListener('input', (e) => {
filterSearch = e.target.value;
const filtered = filteredCoaches();
if (
expandedCoachId &&
!filtered.some(c => c.coachID === expandedCoachId && coachHasUploads(c))
) {
expandedCoachId = null;
}
renderCoachTableBody();
});
renderCoachTableBody();
}
function coachRowHTML(c) {
const canExpand = coachHasUploads(c);
const expanded = canExpand && expandedCoachId === c.coachID;
const expandControl = canExpand
? `<button type="button" class="btn btn-sm btn-link coach-expand-btn" data-coach="${esc(c.coachID)}">${expanded ? '▼' : '▶'}</button> `
: '';
return `
<tr class="coach-row" data-coach="${esc(c.coachID)}">
<td>${expandControl}<strong>${esc(c.username)}</strong></td>
<td>${esc(c.supervisorUsername || '—')}</td>
<td>${c.clientCount ?? 0}</td>
<td>${c.totalSubmissions ?? 0}</td>
<td>${c.submissionsLast7d ?? 0}</td>
<td>${c.submissionsLast30d ?? 0}</td>
<td>${esc(c.lastSubmissionAt || '—')}</td>
</tr>
${expanded ? `
<tr class="coach-detail-row">
<td colspan="7">${coachRecentPanelHTML(c.coachID)}</td>
</tr>` : ''}`;
}
function wireCoachDetailPanel(coachID) {
const panel = document.querySelector(`[data-coach-panel="${CSS.escape(coachID)}"]`);
if (!panel) return;
panel.querySelector('.coach-detail-search')?.addEventListener('input', (e) => {
getCoachUi(coachID).filter = e.target.value;
getCoachUi(coachID).clientLimit = CLIENT_GROUP_PAGE;
renderCoachTableBody();
});
panel.querySelectorAll('.coach-client-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const ui = getCoachUi(coachID);
const client = btn.dataset.client;
if (ui.expandedClients.has(client)) {
ui.expandedClients.delete(client);
} else {
ui.expandedClients.add(client);
}
renderCoachTableBody();
});
});
panel.querySelectorAll('.coach-show-all-uploads').forEach(btn => {
btn.addEventListener('click', () => {
getCoachUi(coachID).showAllUploads.add(btn.dataset.client);
renderCoachTableBody();
});
});
panel.querySelector('.coach-more-clients')?.addEventListener('click', () => {
getCoachUi(coachID).clientLimit += CLIENT_GROUP_PAGE;
renderCoachTableBody();
});
panel.querySelector('.coach-more-uploads')?.addEventListener('click', () => {
loadMoreCoachUploads(coachID);
});
}
function renderCoachTableBody() {
const body = document.getElementById('coachTableBody');
const countEl = document.getElementById('coachListCount');
if (!body) return;
const filtered = filteredCoaches();
if (!filtered.length) {
body.innerHTML = `
<tr>
<td colspan="7" style="text-align:center;color:var(--text-secondary);padding:24px">
No counselors match
</td>
</tr>`;
if (countEl) {
countEl.textContent = filterSearch.trim()
? `0 of ${coachesList.length} counselors`
: '';
}
return;
}
body.innerHTML = filtered.map(c => coachRowHTML(c)).join('');
if (countEl) {
const q = filterSearch.trim();
countEl.textContent = q
? `${filtered.length} of ${coachesList.length} counselors`
: `${coachesList.length} counselors${coachesList.length === 1 ? '' : ''}`;
}
body.querySelectorAll('.coach-expand-btn').forEach(btn => {
btn.addEventListener('click', () => toggleCoach(btn.dataset.coach));
});
if (expandedCoachId) {
wireCoachDetailPanel(expandedCoachId);
}
}
async function fetchCoachRecent(coachID, offset = 0, append = false) {
const data = await apiGet(
`coaches.php?coachID=${encodeURIComponent(coachID)}&recent=1`
+ `&limit=${FETCH_LIMIT}&offset=${offset}`
);
const submissions = data.submissions || [];
if (append && recentByCoach[coachID]) {
const existing = recentByCoach[coachID].submissions || [];
const seen = new Set(existing.map(s => s.submissionID));
for (const s of submissions) {
if (!seen.has(s.submissionID)) {
existing.push(s);
}
}
recentByCoach[coachID] = {
submissions: existing,
total: data.total ?? existing.length,
};
} else {
recentByCoach[coachID] = {
submissions,
total: data.total ?? submissions.length,
};
}
}
async function loadMoreCoachUploads(coachID) {
const data = recentByCoach[coachID];
if (!data) return;
const btn = document.querySelector(
`[data-coach-panel="${CSS.escape(coachID)}"] .coach-more-uploads`
);
if (btn) {
btn.disabled = true;
btn.textContent = 'Loading…';
}
try {
await fetchCoachRecent(coachID, data.submissions.length, true);
renderCoachTableBody();
} catch (e) {
showToast(e.message, 'error');
if (btn) {
btn.disabled = false;
btn.textContent = 'Load more uploads';
}
}
}
async function toggleCoach(coachID) {
const coach = coachesList.find(c => c.coachID === coachID);
if (!coach || !coachHasUploads(coach)) {
return;
}
if (expandedCoachId === coachID) {
expandedCoachId = null;
renderCoachTableBody();
return;
}
expandedCoachId = coachID;
if (!coachDetailUi[coachID]) {
coachDetailUi[coachID] = {
clientLimit: CLIENT_GROUP_PAGE,
expandedClients: new Set(),
showAllUploads: new Set(),
filter: '',
};
}
renderCoachTableBody();
if (!recentByCoach[coachID]) {
try {
await fetchCoachRecent(coachID, 0, false);
} catch (e) {
showToast(e.message, 'error');
recentByCoach[coachID] = { submissions: [], total: 0 };
}
renderCoachTableBody();
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

643
website/js/pages/dev.js Normal file
View File

@ -0,0 +1,643 @@
import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin, apiUrl } from '../api.js';
import { getRole, pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import { navigate } from '../router.js';
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
const ACTIVITY_LABELS = {
app_sync: 'App sync',
app_change: 'App change',
web_change: 'Website change',
web_export: 'Website export',
api_error: 'API error',
};
export function devPage() {
if (getRole() !== 'admin') {
navigate('#/');
return;
}
const app = document.getElementById('app');
const today = new Date().toISOString().slice(0, 10);
app.innerHTML = `
${pageHeaderHTML('Admin Settings')}
<div class="card security-settings-card" style="max-width:720px;margin-bottom:16px">
<h2 style="margin:0 0 8px;font-size:1.1rem">Security &amp; sessions</h2>
<p class="field-hint" style="margin:0 0 14px">
Login rate limits apply per username and IP. Session length applies to new logins
(website and mobile). Password-change tokens use the temporary session length.
</p>
<div id="securitySettingsForm" class="security-settings-form">
<div class="spinner" style="margin:12px 0"></div>
</div>
<p id="securitySettingsMeta" class="field-hint" style="margin:12px 0 0"></p>
<div class="security-revoke-all" style="margin-top:20px;padding-top:16px;border-top:1px solid var(--border)">
<h3 style="margin:0 0 8px;font-size:1rem">Revoke all sessions</h3>
<p class="field-hint callout-danger" style="margin:0 0 12px">
<strong>Signs out everyone</strong> — all admins, supervisors, and counselors on every device.
You will be logged out too and must sign in again. Use after a suspected compromise or policy change.
</p>
<label for="revokeAllConfirm" style="font-size:.85rem;font-weight:500;display:block;margin-bottom:6px">
Type <code>${REVOKE_ALL_CONFIRM}</code> to confirm
</label>
<input type="text" id="revokeAllConfirm" class="revoke-confirm-input" autocomplete="off" spellcheck="false"
placeholder="${REVOKE_ALL_CONFIRM}">
<button type="button" class="btn btn-danger" id="revokeAllSessionsBtn" style="margin-top:10px" disabled>
Revoke all sessions
</button>
</div>
</div>
<div class="card" style="max-width:720px;margin-bottom:16px">
<h2 style="margin:0 0 8px;font-size:1.1rem">Export all response data (ZIP)</h2>
<p class="field-hint" style="margin:0 0 14px">
Download one CSV per questionnaire in a single ZIP (full server data).
<strong>Current data</strong> uses live answers;
<strong>All versions</strong> includes every upload (one row per submission).
</p>
<div style="display:flex;flex-wrap:wrap;gap:10px">
<button type="button" class="btn btn-primary" id="exportAllCurrentZipBtn">Export all — current data (ZIP)</button>
<button type="button" class="btn" id="exportAllVersionsZipBtn">Export all — all versions (ZIP)</button>
</div>
</div>
<div class="card" style="max-width:720px">
<h2 style="margin:0 0 8px;font-size:1.1rem">Dev import</h2>
<p class="field-hint callout-warn">
<strong>Local / test only.</strong> Imports prefixed dev users (<code>dev_*</code>),
clients (<code>DEV-CL-*</code>), and completed questionnaire runs.
Existing real users are not modified. Conflicts are skipped (usernames, client codes, completions).
Default password: <code>socialvrlab</code>. Questionnaires must already exist in the database.
</p>
<div class="form-group">
<label for="devFixtureFile">Fixture JSON</label>
<input type="file" id="devFixtureFile" accept=".json,application/json">
<span class="field-hint">Use <code>dev-test-fixture.json</code> from the repo root (regenerate with <code>barometer-server/scripts/generate_dev_fixture.py</code> after importing the latest <code>questionnaires_bundle_*.json</code>; uneven counselor load, multi-version uploads, natural timestamps).</span>
</div>
<div id="devFixtureSummary" class="field-hint" style="margin:12px 0;display:none"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
<button type="button" class="btn btn-primary" id="devImportBtn" disabled>Import fixture</button>
<button type="button" class="btn btn-danger" id="devRemoveBtn">Remove test data</button>
</div>
<pre id="devImportResult" style="margin-top:16px;font-size:.8rem;max-height:320px;overflow:auto;display:none"></pre>
</div>
<div class="card" style="max-width:720px;margin-top:16px">
<h2 style="margin:0 0 8px;font-size:1.1rem">Reset database</h2>
<p class="field-hint callout-danger">
<strong>Destructive.</strong> Deletes all clients, completions, counselors, and supervisors
(including non-dev accounts). Admin logins and questionnaire definitions are kept.
</p>
<button type="button" class="btn btn-danger" id="devWipeBtn">Remove all data (keep admins)</button>
</div>
<div class="card activity-log-card" style="margin-top:16px">
<h2 style="margin:0 0 8px;font-size:1.1rem">API activity log</h2>
<p class="field-hint" style="margin:0 0 14px">
App syncs, website edits (translations, users, questionnaires, …),
and exports/downloads. Routine page loads (lists, dashboards) are not logged.
</p>
<div class="filter-bar" style="margin-bottom:12px">
<label style="font-size:.85rem;font-weight:500">Date</label>
<input type="date" id="activityLogDate" value="${today}">
<label style="font-size:.85rem;font-weight:500">Activity</label>
<select id="activityLogKind">
<option value="">All kinds</option>
<option value="app_sync">App sync</option>
<option value="app_change">App change</option>
<option value="web_change">Website change</option>
<option value="web_export">Website export</option>
<option value="errors">Errors only (4xx/5xx)</option>
</select>
<button type="button" class="btn btn-sm" id="activityLogRefresh">Refresh</button>
</div>
<div id="activityLogStatus" class="field-hint" style="margin:0 0 10px;display:none"></div>
<p id="activityLogMeta" class="field-hint" style="margin:0 0 10px"></p>
<div class="table-wrapper">
<table class="data-table activity-log-table">
<thead>
<tr>
<th>Time (UTC)</th>
<th>Kind</th>
<th>Subject</th>
<th>Method</th>
<th>Route</th>
<th>Status</th>
<th>Duration</th>
<th>Values</th>
<th>Performed by</th>
</tr>
</thead>
<tbody id="activityLogBody">
<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:20px">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
`;
let parsedFixture = null;
document.getElementById('devFixtureFile').addEventListener('change', async (e) => {
const file = e.target.files?.[0];
const summary = document.getElementById('devFixtureSummary');
const btn = document.getElementById('devImportBtn');
parsedFixture = null;
btn.disabled = true;
summary.style.display = 'none';
if (!file) return;
try {
const text = await file.text();
parsedFixture = JSON.parse(text);
const st = parsedFixture.stats || {};
summary.innerHTML = `
<strong>Preview:</strong>
${parsedFixture.admins?.length ?? st.admins ?? 0} admins,
${parsedFixture.supervisors?.length ?? st.supervisors ?? 0} supervisors,
${parsedFixture.coaches?.length ?? st.coaches ?? 0} counselors,
${parsedFixture.clients?.length ?? st.clients ?? 0} clients,
${parsedFixture.completions?.length ?? st.completionRecords ?? st.completions ?? 0} completions,
${st.totalUploads ?? '—'} uploads (${st.multiVersionPairs ?? '—'} re-uploaded)
`;
summary.style.display = '';
btn.disabled = false;
} catch (err) {
showToast('Invalid JSON: ' + err.message, 'error');
}
});
document.getElementById('devRemoveBtn').addEventListener('click', async () => {
if (!(await confirmAction({
title: 'Remove dev test data',
message: 'Remove all dev test data?\n\nDeletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\nReal accounts are not affected.',
confirmLabel: 'Remove',
variant: 'danger',
}))) {
return;
}
const btn = document.getElementById('devRemoveBtn');
const pre = document.getElementById('devImportResult');
btn.disabled = true;
btn.textContent = 'Removing…';
pre.style.display = 'none';
try {
const res = await apiPost('dev', {
action: 'remove',
usernamePrefix: 'dev_',
clientCodePrefix: 'DEV-CL-',
});
if (!res.success) {
showToast(res.error || res.message || 'Remove failed', 'error');
return;
}
pre.textContent = JSON.stringify(res, null, 2);
pre.style.display = '';
const d = res.deleted || {};
showToast(
`Removed ${d.clients ?? 0} clients, ${d.users ?? 0} users, `
+ `${d.completed_questionnaires ?? 0} completions`,
'success'
);
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Remove test data';
}
});
document.getElementById('devWipeBtn').addEventListener('click', async () => {
if (!(await confirmAction({
title: 'Wipe operational data',
message: 'Remove ALL clients, completions, counselors, and supervisors?\n\nAdmin accounts and questionnaires will NOT be deleted.\nThis cannot be undone.',
confirmLabel: 'Remove all',
variant: 'danger',
}))) {
return;
}
const btn = document.getElementById('devWipeBtn');
const pre = document.getElementById('devImportResult');
btn.disabled = true;
btn.textContent = 'Removing…';
pre.style.display = 'none';
try {
const res = await apiPost('dev', { action: 'wipeExceptAdmins' });
if (!res.success) {
showToast(res.error || res.message || 'Wipe failed', 'error');
return;
}
pre.textContent = JSON.stringify(res, null, 2);
pre.style.display = '';
const d = res.deleted || {};
const kept = res.kept?.admins ?? '?';
showToast(
`Wiped ${d.clients ?? 0} clients, ${d.users ?? 0} users, `
+ `${d.completed_questionnaires ?? 0} completions (${kept} admins kept)`,
'success'
);
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Remove all data (keep admins)';
}
});
wireAdminZipExport();
wireActivityLog();
wireSecuritySettings();
document.getElementById('devImportBtn').addEventListener('click', async () => {
if (!parsedFixture) return;
const btn = document.getElementById('devImportBtn');
const pre = document.getElementById('devImportResult');
btn.disabled = true;
btn.textContent = 'Importing…';
pre.style.display = 'none';
try {
const res = await apiPost('dev', { fixture: parsedFixture });
if (!res.success) {
showToast(res.error || 'Import failed', 'error');
return;
}
pre.textContent = JSON.stringify(res, null, 2);
pre.style.display = '';
const imp = res.imported || {};
const sk = res.skipped || {};
showToast(
`Imported: ${imp.completions ?? 0} completions, ${imp.submissions ?? 0} uploads, `
+ `${imp.clients ?? 0} clients, ${imp.coaches ?? 0} counselors `
+ `(skipped ${sk.completions ?? 0} existing)`,
'success'
);
if (res.errors?.length) {
showToast(`${res.errors.length} warning(s) — see log below`, 'error');
}
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Import fixture';
}
});
}
function renderActivityLogStatus(status) {
const el = document.getElementById('activityLogStatus');
if (!el || !status) {
if (el) el.style.display = 'none';
return;
}
if (!status.enabled) {
el.className = 'field-hint callout-warn';
el.innerHTML = 'Activity logging is disabled (<code>QDB_API_LOG=0</code> in .env).';
el.style.display = '';
return;
}
if (status.writable) {
el.className = 'field-hint';
el.innerHTML = `Logging to <code>${escHtml(status.dir)}</code>`
+ (status.php_user ? ` (PHP user: <code>${escHtml(status.php_user)}</code>)` : '');
el.style.display = '';
return;
}
el.className = 'field-hint callout-danger';
el.innerHTML = `<strong>Cannot write activity log.</strong> ${escHtml(status.error || 'Permission problem.')}
<br>Path: <code>${escHtml(status.dir)}</code>`
+ (status.php_user ? `<br>PHP runs as: <code>${escHtml(status.php_user)}</code>` : '')
+ (status.fix_hint ? `<br>${escHtml(status.fix_hint)}` : '');
el.style.display = '';
}
function wireActivityLog() {
const dateEl = document.getElementById('activityLogDate');
const kindEl = document.getElementById('activityLogKind');
document.getElementById('activityLogRefresh')?.addEventListener('click', loadActivityLog);
dateEl?.addEventListener('change', loadActivityLog);
kindEl?.addEventListener('change', loadActivityLog);
loadActivityLog();
}
async function loadActivityLog() {
const tbody = document.getElementById('activityLogBody');
const meta = document.getElementById('activityLogMeta');
if (!tbody) return;
const date = document.getElementById('activityLogDate')?.value || new Date().toISOString().slice(0, 10);
const activity = document.getElementById('activityLogKind')?.value || '';
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:20px">Loading…</td></tr>';
try {
const q = new URLSearchParams({ date, limit: '300' });
if (activity) q.set('activity', activity);
const data = await apiGet(`activity-log?${q}`);
if (!data.success) throw new Error(data.error || 'Failed to load activity log');
renderActivityLogStatus(data.logStatus);
const entries = data.entries || [];
const files = (data.files || []).join(', ') || 'none';
const trunc = data.truncated ? ' (newest 300 shown)' : '';
if (meta) {
meta.textContent = entries.length
? `${entries.length} entries from ${files}${trunc}`
: `No entries for this date/filter (files: ${files})`;
}
if (!entries.length) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:24px">No activity recorded</td></tr>';
return;
}
tbody.innerHTML = entries.map((e) => {
const kind = e.activity || '';
const label = ACTIVITY_LABELS[kind] || kind || '—';
const badgeClass = kind ? `badge-activity-${kind}` : '';
const time = formatActivityTime(e.ts);
const statusNum = e.status != null ? Number(e.status) : null;
const status = statusNum != null ? String(statusNum) : '—';
const statusClass = statusNum != null && statusNum >= 400 ? 'error-text' : '';
const dur = e.duration_ms != null ? `${e.duration_ms} ms` : '—';
const subject = formatActivitySubject(e);
const performedBy = formatActivityPerformer(e);
const errPart = e.error
? `<div class="activity-log-error">${escHtml(e.error)}</div>`
: '';
return `<tr>
<td class="activity-log-col-time">${escHtml(time)}</td>
<td class="activity-log-col-kind"><span class="badge ${badgeClass}">${escHtml(label)}</span></td>
<td class="activity-log-subject">${subject}</td>
<td class="activity-log-col-meta"><code>${escHtml(e.method || '')}</code></td>
<td class="activity-log-col-route"><code>${escHtml(e.route || '')}</code></td>
<td class="activity-log-col-meta ${statusClass}">${escHtml(status)}</td>
<td class="activity-log-col-meta">${escHtml(dur)}</td>
<td class="activity-log-changes-cell">${renderActivityChanges(e.changes)}</td>
<td class="activity-log-actor">${performedBy}${errPart}</td>
</tr>`;
}).join('');
} catch (err) {
if (meta) meta.textContent = '';
tbody.innerHTML = `<tr><td colspan="9" class="error-text" style="padding:16px">${escHtml(err.message)}</td></tr>`;
}
}
function formatActivitySubject(entry) {
if (entry.target_username) {
return `<strong>${escHtml(entry.target_username)}</strong>`;
}
const changes = entry.changes || [];
const userChange = changes.find((c) => (c.field || '').toLowerCase() === 'username');
if (userChange) {
const name = userChange.display || formatActivityChangeValue(userChange.value);
return `<strong>${escHtml(name)}</strong>`;
}
const client = changes.find((c) => (c.field || '').toLowerCase().includes('clientcode'));
if (client) {
const code = client.display || formatActivityChangeValue(client.value);
return `client <strong>${escHtml(code)}</strong>`;
}
const qClient = changes.find((c) => (c.field || '') === 'query.clientCode');
if (qClient) {
return `client <strong>${escHtml(formatActivityChangeValue(qClient.value))}</strong>`;
}
return '<span style="color:var(--text-secondary)">—</span>';
}
function formatActivityPerformer(entry) {
if (entry.actor_username) {
const role = entry.role ? ` <span class="activity-log-role">(${escHtml(entry.role)})</span>` : '';
return `<strong>${escHtml(entry.actor_username)}</strong>${role}`;
}
if (entry.role) {
return escHtml(entry.role);
}
return '—';
}
function renderActivityChanges(changes) {
const list = Array.isArray(changes) ? changes : [];
if (!list.length) {
return '<span style="color:var(--text-secondary)">—</span>';
}
const rows = list.map((c) => {
const field = c.field ?? '';
const raw = formatActivityChangeValue(c.value);
const display = (c.display != null && String(c.display) !== '')
? String(c.display)
: raw;
const showRaw = display !== raw
&& !raw.startsWith('[REDACTED]')
&& !raw.startsWith('[ENCRYPTED');
return `<tr>
<th scope="row">${escHtml(field)}</th>
<td>
<strong>${escHtml(display)}</strong>
${showRaw ? `<div class="activity-change-raw">${escHtml(raw)}</div>` : ''}
</td>
</tr>`;
}).join('');
return `<table class="activity-changes-table"><tbody>${rows}</tbody></table>`;
}
function formatActivityChangeValue(value) {
if (value === null || value === undefined) return 'null';
if (typeof value === 'boolean') return value ? 'true' : 'false';
return String(value);
}
function formatActivityTime(iso) {
if (!iso) return '—';
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toISOString().replace('T', ' ').slice(0, 19);
} catch {
return iso;
}
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}
function filenameFromContentDisposition(res, fallback) {
const disp = res.headers.get('Content-Disposition');
if (!disp) return fallback;
const m = /filename="([^"]+)"/i.exec(disp);
return m ? m[1] : fallback;
}
async function downloadExportZip(url, defaultFilename) {
const res = await apiDownloadFetch(url);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Download failed' }));
throw new Error(err.error?.message || err.error || 'Download failed');
}
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filenameFromContentDisposition(res, defaultFilename);
a.click();
URL.revokeObjectURL(blobUrl);
}
function wireAdminZipExport() {
document.getElementById('exportAllCurrentZipBtn')?.addEventListener('click', async () => {
const btn = document.getElementById('exportAllCurrentZipBtn');
btn.disabled = true;
const prev = btn.textContent;
btn.textContent = 'Preparing ZIP…';
try {
await downloadExportZip(apiUrl('export?exportAll=1'), 'responses_export_current.zip');
showToast('ZIP download started (current data)', 'success');
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = prev;
}
});
document.getElementById('exportAllVersionsZipBtn')?.addEventListener('click', async () => {
const btn = document.getElementById('exportAllVersionsZipBtn');
btn.disabled = true;
const prev = btn.textContent;
btn.textContent = 'Preparing ZIP…';
try {
await downloadExportZip(apiUrl('export?exportAll=1&allVersions=1'), 'responses_export_all_versions.zip');
showToast('ZIP download started (all versions)', 'success');
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = prev;
}
});
}
function wireSecuritySettings() {
const formEl = document.getElementById('securitySettingsForm');
const metaEl = document.getElementById('securitySettingsMeta');
const revokeInput = document.getElementById('revokeAllConfirm');
const revokeBtn = document.getElementById('revokeAllSessionsBtn');
if (!formEl) return;
revokeInput?.addEventListener('input', () => {
if (revokeBtn) {
revokeBtn.disabled = revokeInput.value.trim() !== REVOKE_ALL_CONFIRM;
}
});
revokeBtn?.addEventListener('click', async () => {
if (revokeInput.value.trim() !== REVOKE_ALL_CONFIRM) return;
if (!(await confirmAction({
title: 'Revoke all sessions',
message: 'Revoke every active session?\n\nAll users (including you) will be signed out on website and mobile.',
confirmLabel: 'Revoke all',
variant: 'danger',
}))) {
return;
}
revokeBtn.disabled = true;
try {
const data = await apiPost('settings', {
action: 'revokeAllSessions',
confirmPhrase: REVOKE_ALL_CONFIRM,
});
if (!data.success) throw new Error(data.error || 'Failed');
showToast(`Revoked ${data.revokedSessions ?? 0} session(s). Signing you out…`, 'success');
revokeInput.value = '';
setTimeout(() => redirectToLogin(), 800);
} catch (err) {
showToast(err.message, 'error');
revokeBtn.disabled = revokeInput.value.trim() !== REVOKE_ALL_CONFIRM;
}
});
loadSecuritySettings(formEl, metaEl);
}
async function loadSecuritySettings(formEl, metaEl) {
try {
const data = await apiGet('settings');
if (!data.success) throw new Error(data.error || 'Failed to load settings');
const s = data.settings || {};
const active = data.activeSessions ?? 0;
if (metaEl) {
metaEl.textContent = `${active} active session(s) in the database.`;
}
formEl.innerHTML = `
<div class="security-settings-grid">
<div class="form-group">
<label for="setLoginMax">Max failed logins</label>
<input type="number" id="setLoginMax" min="1" max="100" step="1"
value="${escAttr(s.login_max_attempts)}">
<span class="field-hint">Before lockout (per username + IP)</span>
</div>
<div class="form-group">
<label for="setLoginWindow">Login attempt window (minutes)</label>
<input type="number" id="setLoginWindow" min="1" max="1440" step="1"
value="${escAttr(Math.round((s.login_window_seconds || 900) / 60))}">
</div>
<div class="form-group">
<label for="setLoginLockout">Lockout duration (minutes)</label>
<input type="number" id="setLoginLockout" min="1" max="1440" step="1"
value="${escAttr(Math.round((s.login_lockout_seconds || 900) / 60))}">
</div>
<div class="form-group">
<label for="setSessionDays">Session length (days)</label>
<input type="number" id="setSessionDays" min="1" max="365" step="1"
value="${escAttr(Math.max(1, Math.round((s.session_ttl_seconds || 2592000) / 86400)))}">
<span class="field-hint">Website &amp; app after normal login</span>
</div>
<div class="form-group">
<label for="setTempSession">Temporary session (minutes)</label>
<input type="number" id="setTempSession" min="5" max="1440" step="1"
value="${escAttr(Math.round((s.temp_session_ttl_seconds || 600) / 60))}">
<span class="field-hint">Forced password change flow</span>
</div>
</div>
<button type="button" class="btn btn-primary" id="saveSecuritySettingsBtn">Save security settings</button>
`;
document.getElementById('saveSecuritySettingsBtn')?.addEventListener('click', () => {
saveSecuritySettings(formEl, metaEl);
});
} catch (err) {
formEl.innerHTML = `<p class="error-text">${escHtml(err.message)}</p>`;
}
}
async function saveSecuritySettings(formEl, metaEl) {
const btn = document.getElementById('saveSecuritySettingsBtn');
const payload = {
login_max_attempts: parseInt(document.getElementById('setLoginMax')?.value || '10', 10),
login_window_seconds: parseInt(document.getElementById('setLoginWindow')?.value || '15', 10) * 60,
login_lockout_seconds: parseInt(document.getElementById('setLoginLockout')?.value || '15', 10) * 60,
session_ttl_seconds: parseInt(document.getElementById('setSessionDays')?.value || '30', 10) * 86400,
temp_session_ttl_seconds: parseInt(document.getElementById('setTempSession')?.value || '10', 10) * 60,
};
if (btn) btn.disabled = true;
try {
const data = await apiPut('settings', payload);
if (!data.success) throw new Error(data.error || 'Save failed');
showToast('Security settings saved', 'success');
await loadSecuritySettings(formEl, metaEl);
} catch (err) {
showToast(err.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
function escAttr(v) {
return String(v ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}

2107
website/js/pages/editor.js Normal file

File diff suppressed because it is too large Load Diff

233
website/js/pages/export.js Normal file
View File

@ -0,0 +1,233 @@
import { apiGet, apiDownloadFetch, apiUrl } from '../api.js';
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
let questionnairesList = [];
let filterSearch = '';
export async function exportPage() {
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Export Data')}
<div class="card" id="exportContent"><div class="spinner"></div></div>
`;
try {
const data = await apiGet('questionnaires.php');
questionnairesList = data.questionnaires || [];
renderExport();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('exportContent').innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
}
}
function renderExport() {
const container = document.getElementById('exportContent');
if (!questionnairesList.length) {
container.innerHTML = `
<div class="empty-state">
<h3>No questionnaires</h3>
<p>Create questionnaires first to export data.</p>
</div>
`;
return;
}
const bundleBlock = canEdit() ? `
<div class="card" style="margin-bottom:20px;padding:16px 20px">
<h3 style="margin:0 0 8px;font-size:1.05rem">Export questionnaires (JSON)</h3>
<p style="margin:0 0 14px;color:var(--text-secondary);font-size:.9rem">
Download all questionnaires with questions, answer options, and every translation.
Use <strong>Import questionnaires</strong> on the dashboard to restore this file on another server.
</p>
<button type="button" class="btn btn-primary" id="exportBundleBtn">Export all questionnaires (JSON)</button>
</div>
` : '';
container.innerHTML = `
${bundleBlock}
<div class="card">
<p style="margin:0 0 12px;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>
<div class="filter-bar">
<input type="search" id="exportListSearch" class="filter-search" placeholder="Search questionnaire name…" value="${esc(filterSearch)}">
<span class="data-toolbar-hint" id="exportListCount"></span>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Questionnaire</th>
<th>Version</th>
<th>State</th>
<th>Questions</th>
<th>Completed</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="exportTableBody"></tbody>
</table>
</div>
</div>
`;
document.getElementById('exportListSearch').addEventListener('input', (e) => {
filterSearch = e.target.value;
renderExportTableBody();
});
renderExportTableBody();
wireExportActions();
}
function exportRowSearchText(q) {
return [q.name, q.questionnaireID, q.version, q.state].filter(Boolean).join(' ');
}
function renderExportTableBody() {
const body = document.getElementById('exportTableBody');
const countEl = document.getElementById('exportListCount');
if (!body) return;
const q = filterSearch.trim().toLowerCase();
const filtered = q
? questionnairesList.filter(item => exportRowSearchText(item).toLowerCase().includes(q))
: questionnairesList;
if (!filtered.length) {
body.innerHTML = `
<tr data-filter-placeholder="1">
<td colspan="6" style="text-align:center;color:var(--text-secondary);padding:24px">
No questionnaires match
</td>
</tr>`;
if (countEl) {
countEl.textContent = q
? `0 of ${questionnairesList.length} questionnaires`
: '';
}
return;
}
body.innerHTML = filtered.map(item => exportRowHTML(item)).join('');
if (countEl) {
countEl.textContent = q
? `${filtered.length} of ${questionnairesList.length} questionnaires`
: `${questionnairesList.length} questionnaire${questionnairesList.length === 1 ? '' : 's'}`;
}
body.querySelectorAll('.export-btn').forEach(btn => {
btn.addEventListener('click', onExportCsvClick);
});
body.querySelectorAll('.export-btn-all').forEach(btn => {
btn.addEventListener('click', onExportAllVersionsClick);
});
}
function exportRowHTML(q) {
return `
<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>${q.completedCount ?? 0}</td>
<td class="export-actions-cell">
<button class="btn btn-sm btn-primary export-btn" data-id="${esc(q.questionnaireID)}" data-name="${esc(q.name)}" data-version="${esc(q.version)}" data-all="0">
Export current
</button>
<button class="btn btn-sm export-btn-all" data-id="${esc(q.questionnaireID)}" data-name="${esc(q.name)}">
All versions
</button>
<a href="#/questionnaire/${esc(q.questionnaireID)}/results" class="btn btn-sm">Results</a>
</td>
</tr>`;
}
function wireExportActions() {
document.getElementById('exportBundleBtn')?.addEventListener('click', async () => {
const btn = document.getElementById('exportBundleBtn');
btn.disabled = true;
const prev = btn.textContent;
btn.textContent = 'Preparing…';
try {
const res = await apiDownloadFetch(apiUrl('export?bundle=1'));
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message || err.error || 'Export failed');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const stamp = new Date().toISOString().slice(0, 10);
a.download = `questionnaires_bundle_${stamp}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('Questionnaire bundle downloaded', 'success');
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = prev;
}
});
}
async function downloadExportCsv(btn, url, filename) {
const res = await apiDownloadFetch(url);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Download failed' }));
throw new Error(err.error?.message || err.error || 'Download failed');
}
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(blobUrl);
}
async function onExportCsvClick(ev) {
const btn = ev.currentTarget;
btn.disabled = true;
const prev = btn.textContent;
btn.textContent = 'Downloading…';
try {
const url = apiUrl(`export?questionnaireID=${encodeURIComponent(btn.dataset.id)}`);
await downloadExportCsv(btn, url, `${btn.dataset.name}_v${btn.dataset.version}.csv`);
showToast('Download started', 'success');
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = prev;
}
}
async function onExportAllVersionsClick(ev) {
const btn = ev.currentTarget;
btn.disabled = true;
const prev = btn.textContent;
btn.textContent = 'Downloading…';
try {
const url = apiUrl(`export?questionnaireID=${encodeURIComponent(btn.dataset.id)}&allVersions=1`);
const stamp = new Date().toISOString().slice(0, 10);
await downloadExportCsv(btn, url, `${btn.dataset.name}_all_versions_${stamp}.csv`);
showToast('All versions download started', 'success');
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = prev;
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

108
website/js/pages/home.js Normal file
View File

@ -0,0 +1,108 @@
import { apiGet } from '../api.js';
import { getRole, pageHeaderHTML, showToast } from '../app.js';
export async function homeDashboardPage() {
const app = document.getElementById('app');
const role = getRole();
const isAdmin = role === 'admin';
app.innerHTML = `
${pageHeaderHTML('Overview', '', {
subtitle: `Ressourcenbarometer administration for your ${role === 'admin' ? 'organization' : 'supervised counselors'}`,
})}
<div id="homeDashboardContent"><div class="spinner"></div></div>
`;
try {
const ov = await apiGet('analytics.php?overview=1');
renderHomeDashboard(ov, isAdmin);
} catch (e) {
showToast(e.message, 'error');
document.getElementById('homeDashboardContent').innerHTML =
`<p class="error-text">${esc(e.message)}</p>`;
}
}
function renderHomeDashboard(ov, isAdmin) {
const el = document.getElementById('homeDashboardContent');
const qPreview = (ov.questionnaires || []).slice(0, 6).map(q => `
<tr>
<td>${esc(q.name)}</td>
<td>${q.completedCount ?? 0} / ${q.eligibleCount ?? 0}</td>
<td>${q.completionRatio ?? 0}%</td>
</tr>
`).join('');
const links = [
{ href: '#/questionnaires', label: 'Session measures', desc: 'Edit instruments, order, and categories' },
{ href: '#/clients', label: 'Clients', desc: 'Client codes, counselors, response history' },
{ href: '#/coaches', label: 'Counselor activity', desc: 'Uploads and workload per counselor' },
{ href: '#/insights', label: 'Insights', desc: 'Charts, completion rates, follow-up' },
{ href: '#/export', label: 'Export data', desc: 'Download CSV and bundles' },
{ href: '#/assignments', label: 'Assign clients', desc: 'Move clients between counselors' },
{ href: '#/users', label: 'Users', desc: 'Admins, supervisors, and counselors' },
{ href: '#/translations', label: 'Translations', desc: 'German source and other languages' },
];
if (isAdmin) {
links.push({ href: '#/dev', label: 'Admin settings', desc: 'Dev import, exports, database tools' });
}
el.innerHTML = `
<div class="insights-kpi-grid">
<a href="#/clients" class="insights-kpi card home-kpi-link">
<div class="insights-kpi-value">${ov.clientCount ?? 0}</div>
<div class="insights-kpi-label">Clients</div>
</a>
<a href="#/insights" class="insights-kpi card home-kpi-link">
<div class="insights-kpi-value">${ov.clientsWithCompletions ?? 0}</div>
<div class="insights-kpi-label">With completions</div>
</a>
<a href="#/coaches" class="insights-kpi card home-kpi-link">
<div class="insights-kpi-value">${ov.submissionsLast7d ?? 0}</div>
<div class="insights-kpi-label">Uploads (7 days)</div>
</a>
<a href="#/coaches" class="insights-kpi card home-kpi-link">
<div class="insights-kpi-value">${ov.submissionsLast30d ?? 0}</div>
<div class="insights-kpi-label">Uploads (30 days)</div>
</a>
</div>
<div class="card" style="margin-top:20px">
<h3 style="margin:0 0 12px">Quick links</h3>
<div class="home-link-grid">
${links.map(l => `
<a href="${l.href}" class="home-link-card">
<span class="home-link-title">${esc(l.label)}</span>
<span class="home-link-desc">${esc(l.desc)}</span>
</a>
`).join('')}
</div>
</div>
<div class="card" style="margin-top:20px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0">Measure completion</h3>
<a href="#/insights" class="btn btn-sm">Full report</a>
</div>
${(ov.questionnaires || []).length ? `
<div class="table-wrapper">
<table class="data-table data-table-compact">
<thead>
<tr><th>Session measure</th><th>Completed</th><th>Rate</th></tr>
</thead>
<tbody>${qPreview}${(ov.questionnaires.length > 6)
? `<tr><td colspan="3" class="data-toolbar-hint" style="text-align:center">+ ${ov.questionnaires.length - 6} more — see Insights</td></tr>`
: ''}</tbody>
</table>
</div>
` : '<p class="data-toolbar-hint">No active session measures.</p>'}
</div>
`;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

View File

@ -0,0 +1,376 @@
import { apiGet, apiPut } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js';
import { clientTableActionsHTML, confirmAndPatchClientArchive } from '../client-archive.js';
let overview = null;
let staleClients = [];
let followupSearch = '';
export async function insightsPage() {
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Insights')}
<div id="insightsContent"><div class="spinner"></div></div>
`;
try {
const [ov, stale] = await Promise.all([
apiGet('analytics.php?overview=1'),
apiGet('analytics.php?staleClients=1'),
]);
overview = ov;
staleClients = stale.clients || [];
renderInsights();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('insightsContent').innerHTML =
`<p class="error-text">${esc(e.message)}</p>`;
}
}
function renderInsights() {
const el = document.getElementById('insightsContent');
const ov = overview || {};
const kpiCards = `
<div class="insights-kpi-grid">
<div class="insights-kpi card">
<div class="insights-kpi-value">${ov.clientCount ?? 0}</div>
<div class="insights-kpi-label">Clients</div>
</div>
<div class="insights-kpi card">
<div class="insights-kpi-value">${ov.clientsWithCompletions ?? 0}</div>
<div class="insights-kpi-label">With completions</div>
</div>
<div class="insights-kpi card">
<div class="insights-kpi-value">${ov.submissionsLast7d ?? 0}</div>
<div class="insights-kpi-label">Uploads (7 days)</div>
</div>
<div class="insights-kpi card">
<div class="insights-kpi-value">${ov.submissionsLast30d ?? 0}</div>
<div class="insights-kpi-label">Uploads (30 days)</div>
</div>
</div>
`;
const uploadChart = verticalBarChart(
(ov.submissionsByDay || []).map(d => ({
label: formatChartDay(d.date),
value: d.count,
title: `${d.date}: ${d.count} upload(s)`,
})),
{ emptyLabel: 'No uploads in this period' }
);
const withoutCompletions = Math.max(0, (ov.clientCount ?? 0) - (ov.clientsWithCompletions ?? 0));
const engagementChart = horizontalBarChart([
{ label: 'With completions', value: ov.clientsWithCompletions ?? 0 },
{ label: 'No completions yet', value: withoutCompletions },
], { suffix: '' });
const idleChart = horizontalBarChart(idleBucketItems(staleClients), {
suffix: '',
variant: 'warn',
});
const completionChart = horizontalBarChart(
(ov.questionnaires || []).map(q => ({
label: q.name,
value: q.completionRatio ?? 0,
title: `${q.name}: ${q.completionRatio ?? 0}%`,
})),
{ suffix: '%', max: 100 }
);
const scoringProfiles = ov.scoringProfiles || [];
const scoringCharts = scoringProfiles.map(sp => {
const computed = sp.computedBands || sp.bands || {};
const coach = sp.coachBands || {};
const computedChart = horizontalBarChart([
{ label: 'Green', value: computed.green ?? 0 },
{ label: 'Yellow', value: computed.yellow ?? 0 },
{ label: 'Red', value: computed.red ?? 0 },
], { suffix: '' });
const coachChart = horizontalBarChart([
{ label: 'Green', value: coach.green ?? 0 },
{ label: 'Yellow', value: coach.yellow ?? 0 },
{ label: 'Red', value: coach.red ?? 0 },
], { suffix: '' });
return `
<div class="card insights-chart-card">
<h3>${esc(sp.name)}</h3>
<p class="insights-chart-hint">
${sp.clientCount ?? 0} client(s) with complete profile
${sp.averageTotal != null ? ` · avg ${sp.averageTotal}` : ''}
${sp.pendingReview ? ` · ${sp.pendingReview} pending Counselor review` : ''}
</p>
<p class="insights-chart-hint" style="margin:0 0 6px"><strong>Calculated bands</strong></p>
${computedChart}
<p class="insights-chart-hint" style="margin:12px 0 6px"><strong>Counselor decisions</strong></p>
${coachChart}
</div>`;
}).join('');
const qRows = (ov.questionnaires || []).map(q => `
<tr>
<td><strong>${esc(q.name)}</strong></td>
<td>${q.completedCount ?? 0} / ${q.eligibleCount ?? 0}</td>
<td>${q.completionRatio ?? 0}%</td>
</tr>
`).join('');
el.innerHTML = `
${kpiCards}
<div class="insights-charts-grid">
<div class="card insights-chart-card">
<h3>Uploads per day</h3>
<p class="insights-chart-hint">Last 14 days</p>
${uploadChart}
</div>
<div class="card insights-chart-card">
<h3>Client engagement</h3>
<p class="insights-chart-hint">Among clients in your scope</p>
${engagementChart}
</div>
<div class="card insights-chart-card">
<h3>Days since last activity</h3>
<p class="insights-chart-hint">Clients with at least one completion</p>
${idleChart}
</div>
</div>
${scoringProfiles.length ? `
<div class="card" style="margin-top:20px">
<h3 style="margin:0 0 8px">Scoring profiles</h3>
<p class="insights-chart-hint" style="margin:0 0 12px">Calculated vs Counselor band distribution among clients with a complete weighted score</p>
<div class="insights-charts-grid">${scoringCharts}</div>
</div>` : ''}
<div class="card" style="margin-top:20px">
<h3 style="margin:0 0 8px">Completion by questionnaire</h3>
<p class="insights-chart-hint" style="margin:0 0 12px">Share of clients who completed each active questionnaire</p>
${completionChart}
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr><th>Questionnaire</th><th>Completed</th><th>Rate</th></tr>
</thead>
<tbody>${qRows || '<tr><td colspan="3">No active questionnaires</td></tr>'}</tbody>
</table>
</div>
</div>
<div class="card" style="margin-top:20px">
<h3 style="margin:0 0 8px">Needs follow-up</h3>
<p class="data-toolbar-hint" style="margin:0 0 12px">
Clients who completed at least one questionnaire, sorted by longest time since last activity.
</p>
<div class="filter-bar">
<input type="search" id="followupListSearch" class="filter-search"
placeholder="Search client, counselor, questionnaire, note…" value="${esc(followupSearch)}">
<span class="data-toolbar-hint" id="followupListCount"></span>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Client</th><th>Counselor</th><th>Last questionnaire</th>
<th>Completed at</th><th>Days idle</th><th>Note</th><th></th>
</tr>
</thead>
<tbody id="followupTableBody"></tbody>
</table>
</div>
</div>
`;
document.getElementById('followupListSearch').addEventListener('input', (e) => {
followupSearch = e.target.value;
renderFollowupTableBody();
});
renderFollowupTableBody();
}
function followupSearchText(c) {
return [
c.clientCode,
c.coachUsername,
c.lastQuestionnaireName,
c.lastQuestionnaireID,
c.note,
c.lastCompletedAt,
c.daysSinceLastActivity != null ? String(c.daysSinceLastActivity) : '',
].filter(Boolean).join(' ');
}
function filteredFollowupClients() {
const q = followupSearch.trim().toLowerCase();
if (!q) return staleClients;
return staleClients.filter(c => followupSearchText(c).toLowerCase().includes(q));
}
function followupRowHTML(c) {
const code = esc(c.clientCode);
return `
<tr data-client="${code}">
<td><code>${code}</code></td>
<td>${esc(c.coachUsername)}</td>
<td>${esc(c.lastQuestionnaireName || '—')}</td>
<td>${esc(c.lastCompletedAt || '—')}</td>
<td>${c.daysSinceLastActivity != null ? c.daysSinceLastActivity : '—'}</td>
<td class="followup-note-cell">
<textarea class="followup-note-input" rows="2" data-client="${code}"
placeholder="Follow-up note…">${esc(c.note || '')}</textarea>
<div class="followup-note-actions">
<button type="button" class="btn btn-sm btn-primary save-note-btn" data-client="${code}">Save note</button>
</div>
</td>
<td class="followup-actions-cell">
${clientTableActionsHTML({ clientCode: code, archived: false, showDelete: false })}
</td>
</tr>`;
}
function renderFollowupTableBody() {
const body = document.getElementById('followupTableBody');
const countEl = document.getElementById('followupListCount');
if (!body) return;
const filtered = filteredFollowupClients();
if (!filtered.length) {
body.innerHTML = `
<tr>
<td colspan="7" style="text-align:center;color:var(--text-secondary);padding:24px">
${staleClients.length ? 'No clients match' : 'No clients'}
</td>
</tr>`;
if (countEl) {
countEl.textContent = followupSearch.trim() && staleClients.length
? `0 of ${staleClients.length} clients`
: '';
}
return;
}
body.innerHTML = filtered.map(c => followupRowHTML(c)).join('');
if (countEl) {
const q = followupSearch.trim();
countEl.textContent = q
? `${filtered.length} of ${staleClients.length} clients`
: `${staleClients.length} client${staleClients.length === 1 ? '' : 's'}`;
}
body.querySelectorAll('.save-note-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tr = btn.closest('tr');
const ta = tr?.querySelector('.followup-note-input');
saveNote(btn.dataset.client, ta);
});
});
body.querySelectorAll('.archive-client-btn').forEach(btn => {
btn.addEventListener('click', () => archiveFromFollowup(btn.dataset.code));
});
}
async function archiveFromFollowup(clientCode) {
const ok = await confirmAndPatchClientArchive(clientCode, true);
if (!ok) return;
staleClients = staleClients.filter(c => c.clientCode !== clientCode);
renderFollowupTableBody();
}
async function saveNote(clientCode, ta) {
if (!ta) return;
try {
await apiPut('analytics.php', { clientCode, note: ta.value.trim() });
const row = staleClients.find(c => c.clientCode === clientCode);
if (row) row.note = ta.value.trim();
showToast('Note saved', 'success');
} catch (e) {
showToast(e.message, 'error');
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}
function formatChartDay(isoDate) {
if (!isoDate) return '';
const d = new Date(isoDate + 'T12:00:00');
if (Number.isNaN(d.getTime())) return isoDate.slice(5);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function chartMax(values, fallbackMax = 1) {
const m = Math.max(0, ...values);
return m > 0 ? m : fallbackMax;
}
function verticalBarChart(items, { emptyLabel = 'No data' } = {}) {
if (!items.length) {
return `<p class="insights-chart-hint">${esc(emptyLabel)}</p>`;
}
const max = chartMax(items.map(i => i.value));
return `
<div class="insights-vchart" role="img" aria-label="Bar chart">
${items.map(item => {
const pct = max ? Math.round((100 * item.value) / max) : 0;
return `
<div class="insights-vchart-col" title="${esc(item.title || `${item.label}: ${item.value}`)}">
<div class="insights-vchart-bar-wrap">
<div class="insights-vchart-bar" style="height:${pct}%"></div>
</div>
<span class="insights-vchart-value">${item.value}</span>
<span class="insights-vchart-label">${esc(item.label)}</span>
</div>`;
}).join('')}
</div>`;
}
function horizontalBarChart(items, { suffix = '%', max: fixedMax, variant = '' } = {}) {
if (!items.length) {
return '<p class="insights-chart-hint">No data</p>';
}
const max = fixedMax ?? chartMax(items.map(i => i.value));
const fillClass = variant === 'warn' ? 'insights-hchart-fill--warn'
: variant === 'muted' ? 'insights-hchart-fill--muted'
: '';
return `
<div class="insights-hchart" role="img" aria-label="Bar chart">
${items.map(item => {
const pct = max ? Math.round((100 * item.value) / max) : 0;
const display = suffix === '%' ? `${item.value}${suffix}` : String(item.value);
return `
<div class="insights-hchart-row" title="${esc(item.title || `${item.label}: ${display}`)}">
<span class="insights-hchart-label">${esc(item.label)}</span>
<div class="insights-hchart-track">
<div class="insights-hchart-fill ${fillClass}" style="width:${pct}%"></div>
</div>
<span class="insights-hchart-num">${esc(display)}</span>
</div>`;
}).join('')}
</div>`;
}
function idleBucketItems(clients) {
const buckets = [
{ label: '07 days', value: 0 },
{ label: '830 days', value: 0 },
{ label: '3190 days', value: 0 },
{ label: '90+ days', value: 0 },
{ label: 'No uploads yet', value: 0 },
];
for (const c of clients) {
const d = c.daysSinceLastActivity;
if (d == null) buckets[4].value += 1;
else if (d <= 7) buckets[0].value += 1;
else if (d <= 30) buckets[1].value += 1;
else if (d <= 90) buckets[2].value += 1;
else buckets[3].value += 1;
}
return buckets.filter(b => b.value > 0);
}

268
website/js/pages/login.js Normal file
View File

@ -0,0 +1,268 @@
import { navigate } from '../router.js';
import { isLoggedIn, showToast } from '../app.js';
import { apiGet, invalidateSessionCheck, apiUrl } from '../api.js';
const WEBSITE_ROLES = ['admin', 'supervisor'];
function setFormBusy(formId, busy, busyLabel) {
const form = document.getElementById(formId);
if (!form) return;
const btn = form.querySelector('button[type="submit"]');
if (!btn) return;
if (!btn.dataset.defaultLabel) {
btn.dataset.defaultLabel = btn.textContent.trim();
}
btn.disabled = busy;
btn.textContent = busy ? busyLabel : btn.dataset.defaultLabel;
form.querySelectorAll('input').forEach((el) => { el.disabled = busy; });
}
function setButtonBusy(button, busy, busyLabel) {
if (!button) return;
if (!button.dataset.defaultLabel) {
button.dataset.defaultLabel = button.textContent.trim();
}
button.disabled = busy;
button.textContent = busy ? busyLabel : button.dataset.defaultLabel;
}
export async function loginPage() {
if (isLoggedIn()) {
try {
const data = await apiGet('session');
if (data.success && data.valid && !data.mustChangePassword && WEBSITE_ROLES.includes(data.role)) {
if (data.user) localStorage.setItem('qdb_user', data.user);
if (data.role) localStorage.setItem('qdb_role', data.role);
navigate('#/');
return;
}
} catch {
invalidateSessionCheck();
}
localStorage.removeItem('qdb_token');
localStorage.removeItem('qdb_user');
localStorage.removeItem('qdb_role');
invalidateSessionCheck();
}
const app = document.getElementById('app');
app.innerHTML = `
<div class="login-screen" role="main">
<div class="login-screen__backdrop" aria-hidden="true">
<div class="login-grid"></div>
</div>
<div class="login-screen__content">
<header class="login-brand">
<h1 class="login-brand__title">NAT-AS Ressourcenbarometer</h1>
<p class="login-brand__subtitle">Administration</p>
</header>
<div id="loginOptions" class="login-options">
<section class="login-card login-card--local card" aria-labelledby="login-panel-title">
<h2 id="login-panel-title" class="login-card__heading">Local account</h2>
<p id="loginPanelSubtitle" class="login-card__lead">Admin or supervisor account.</p>
<form id="loginForm" class="login-form" autocomplete="on">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Log in</button>
<p id="loginError" class="login-alert login-alert--error" role="alert" hidden></p>
</form>
<div id="changePwSection" class="login-change" hidden>
<hr>
<p>You must change your password before continuing.</p>
<form id="changePwForm" class="login-form">
<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 and continue</button>
<p id="changePwError" class="login-alert login-alert--error" role="alert" hidden></p>
</form>
</div>
</section>
<section id="keycloakLoginSection" class="login-card login-card--sso card" aria-labelledby="keycloak-panel-title">
<h2 id="keycloak-panel-title" class="login-card__heading">University login</h2>
<p id="keycloakPanelSubtitle" class="login-card__lead">Use your University Konstanz credentials.</p>
<div class="login-sso">
<div class="login-sso__logo" aria-hidden="true">
<img
src="media/uni-logo.png"
alt=""
onerror="this.hidden=true; this.parentElement.classList.add('login-sso__logo--fallback');"
>
<span>Universit&auml;t Konstanz</span>
</div>
<button id="keycloakLoginBtn" type="button" class="btn btn-block login-sso__button" disabled>
Log in with University Konstanz
</button>
<p id="keycloakLoginError" class="login-alert login-alert--error" role="alert" hidden></p>
</div>
</section>
</div>
<p class="login-card__footnote">Counselors use the mobile app.</p>
</div>
</div>
`;
let pendingUsername = '';
let pendingTempToken = '';
const keycloakSection = document.getElementById('keycloakLoginSection');
const keycloakBtn = document.getElementById('keycloakLoginBtn');
const keycloakErr = document.getElementById('keycloakLoginError');
let keycloakLoginUrl = '';
apiGet('auth/keycloak-config')
.then((data) => {
if (!data.success || !data.enabled) {
keycloakBtn.textContent = 'University login unavailable';
return;
}
keycloakLoginUrl = data.loginUrl ? apiUrl(data.loginUrl) : apiUrl('auth/keycloak-login');
keycloakBtn.textContent = `Log in with ${data.displayName || 'University Konstanz'}`;
keycloakBtn.disabled = false;
})
.catch(() => {
keycloakBtn.textContent = 'University login unavailable';
});
keycloakBtn.addEventListener('click', () => {
keycloakErr.hidden = true;
if (!keycloakLoginUrl) {
keycloakErr.textContent = 'University login is not available.';
keycloakErr.hidden = false;
return;
}
setButtonBusy(keycloakBtn, true, 'Redirecting...');
window.location.assign(keycloakLoginUrl);
});
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const errEl = document.getElementById('loginError');
errEl.hidden = true;
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
setFormBusy('loginForm', true, 'Signing in…');
try {
const res = await fetch(apiUrl('auth/login'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-QDB-Client': 'web',
},
body: JSON.stringify({ username, password }),
});
const json = await res.json();
if (!json.ok) {
const code = json.error?.code || '';
let msg = json.error?.message || 'Login failed';
if (res.status === 429 || code === 'RATE_LIMITED') {
const retry = res.headers.get('Retry-After');
if (retry) {
msg += ` Try again in about ${retry} seconds.`;
}
}
errEl.textContent = msg;
errEl.hidden = false;
return;
}
const d = json.data;
if (d.role === 'coach') {
errEl.textContent = 'Counselor accounts can only sign in through the mobile app.';
errEl.hidden = false;
return;
}
if (d.mustChangePassword) {
pendingUsername = username;
pendingTempToken = d.token;
document.getElementById('loginForm').hidden = true;
document.getElementById('changePwSection').hidden = false;
keycloakSection.hidden = true;
document.getElementById('login-panel-title').textContent = 'New password';
document.getElementById('loginPanelSubtitle').textContent = pendingUsername;
document.getElementById('newPw').focus();
return;
}
localStorage.setItem('qdb_token', d.token);
localStorage.setItem('qdb_user', d.user);
localStorage.setItem('qdb_role', d.role);
invalidateSessionCheck();
navigate('#/');
} catch (err) {
errEl.textContent = err.message;
errEl.hidden = false;
} finally {
setFormBusy('loginForm', false, 'Signing in…');
}
});
document.getElementById('changePwForm').addEventListener('submit', async (e) => {
e.preventDefault();
const errEl = document.getElementById('changePwError');
errEl.hidden = true;
const newPw = document.getElementById('newPw').value;
const confirm = document.getElementById('newPwConfirm').value;
if (newPw !== confirm) {
errEl.textContent = 'Passwords do not match';
errEl.hidden = false;
return;
}
const oldPw = document.getElementById('password').value;
setFormBusy('changePwForm', true, 'Saving…');
try {
const headers = {
'Content-Type': 'application/json',
'X-QDB-Client': 'web',
};
if (pendingTempToken) {
headers['Authorization'] = `Bearer ${pendingTempToken}`;
}
const res = await fetch(apiUrl('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.hidden = false;
return;
}
const d = json.data;
if (d.role === 'coach') {
errEl.textContent = 'Counselor accounts can only sign in through the mobile app.';
errEl.hidden = false;
return;
}
localStorage.setItem('qdb_token', d.token);
localStorage.setItem('qdb_user', d.user);
localStorage.setItem('qdb_role', d.role);
invalidateSessionCheck();
showToast('Password changed successfully', 'success');
navigate('#/');
} catch (err) {
errEl.textContent = err.message;
errEl.hidden = false;
} finally {
setFormBusy('changePwForm', false, 'Saving…');
}
});
}

View File

@ -0,0 +1,832 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { canEdit, pageHeaderHTML, showToast, getRole } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import { navigate } from '../router.js';
export async function questionnairesPage() {
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Session measures', '<span id="headerActions"></span>')}
<div id="qGrid" class="q-dashboard-root"><div class="spinner"></div></div>
<div id="scoringProfilesPanel"></div>
<div id="categoryKeysPanel"></div>
`;
if (canEdit()) {
document.getElementById('headerActions').innerHTML = `
<input type="file" id="importBundleFile" accept=".json,application/json" hidden>
<button type="button" class="btn" id="importBundleBtn">Import questionnaires</button>
<a href="#/questionnaire/new" class="btn btn-primary">+ New Questionnaire</a>
`;
bindImportBundle();
}
try {
const [data, profilesData] = await Promise.all([
apiGet('questionnaires.php'),
apiGet('scoring_profiles.php').catch(() => ({ profiles: [] })),
]);
const categoryKeys = data.categoryKeys || [];
renderGrid(data.questionnaires || [], categoryKeys);
renderScoringProfilesSection(profilesData.profiles || [], data.questionnaires || []);
renderCategoryKeysPanel(categoryKeys);
bindCategoryLabelEditors();
} catch (e) {
showToast(e.message, 'error');
document.getElementById('qGrid').innerHTML = `<p class="error-text">${e.message}</p>`;
}
}
function renderCategoryKeysPanel(categoryKeys) {
const panel = document.getElementById('categoryKeysPanel');
if (!panel) return;
if (!canEdit()) {
panel.innerHTML = '';
return;
}
if (!categoryKeys.length) {
panel.innerHTML = `
<div class="card category-keys-card" style="margin-top:16px">
<h3 style="margin:0 0 8px;font-size:1rem">Category keys</h3>
<p class="data-toolbar-hint" style="margin:0">
Set a category key on a questionnaire (editor → settings) to group it on the app opening screen.
German group titles can be edited below once a key is in use.
</p>
</div>`;
return;
}
panel.innerHTML = `
<div class="card category-keys-card" style="margin-top:16px">
<h3 style="margin:0 0 8px;font-size:1rem">Category keys</h3>
<p class="data-toolbar-hint" style="margin:0 0 12px">
German labels for the app opening screen. Other languages: <a href="#/translations">Translations</a>.
Delete removes the key from questionnaires, all translations, and catalog defaults.
</p>
<div class="table-wrapper" style="max-height:none">
<table class="data-table category-keys-table">
<thead>
<tr>
<th>Category key</th>
<th>App string key</th>
<th>German label</th>
<th>Session measures</th>
<th></th>
</tr>
</thead>
<tbody>
${categoryKeys.map(row => `
<tr class="${row.orphaned ? 'row-orphan-category' : ''}">
<td><code>${esc(row.categoryKey)}</code></td>
<td><code class="data-toolbar-hint">${esc(row.stringKey)}</code></td>
<td class="category-label-cell">
<input type="text" class="category-german-input"
data-category-key="${esc(row.categoryKey)}"
value="${esc(row.germanLabel || '')}"
placeholder="German label"
aria-label="German label for ${esc(row.categoryKey)}">
</td>
<td>${row.orphaned
? '<span class="data-toolbar-hint">unused</span>'
: esc(String(row.questionnaireCount))}</td>
<td style="text-align:right;white-space:nowrap">
<button type="button" class="btn btn-sm btn-danger delete-cat-key-btn"
data-key="${esc(row.categoryKey)}"
data-count="${row.questionnaireCount}">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>`;
panel.querySelectorAll('.delete-cat-key-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const key = btn.dataset.key;
const count = parseInt(btn.dataset.count || '0', 10);
let msg = `Delete category key "${key}"?\n\nThis removes all translations and catalog defaults for questionnaire_group_${key}.`;
if (count > 0) {
msg += `\n\n${count} questionnaire(s) will have their category key cleared.`;
}
if (!(await confirmAction({
title: 'Delete category key',
message: msg,
confirmLabel: 'Delete',
variant: 'danger',
}))) return;
btn.disabled = true;
try {
const result = await apiPost('questionnaires.php', { action: 'deleteCategoryKey', categoryKey: key });
const cleared = result.questionnairesCleared ?? count;
showToast(
cleared
? `Deleted "${key}" and cleared ${cleared} questionnaire(s)`
: `Deleted "${key}"`,
'success'
);
await questionnairesPage();
} catch (err) {
showToast(err.message, 'error');
btn.disabled = false;
}
});
});
}
function categoryGermanInputHTML(categoryKey, label) {
if (!canEdit() || !categoryKey) {
return esc(label);
}
return `<input type="text" class="category-german-input category-title-input"
data-category-key="${esc(categoryKey)}"
value="${esc(label)}"
aria-label="German category title">`;
}
function bindCategoryLabelEditors() {
if (!canEdit()) return;
document.querySelectorAll('.category-german-input').forEach(input => {
input.dataset.lastSaved = input.value.trim();
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
}
});
input.addEventListener('blur', () => saveCategoryGermanLabel(input));
});
}
async function saveCategoryGermanLabel(input) {
const categoryKey = input.dataset.categoryKey?.trim();
const label = input.value.trim();
if (!categoryKey) return;
const prev = input.dataset.lastSaved ?? '';
if (label === prev) return;
if (!label) {
showToast('German label cannot be empty', 'error');
input.value = prev;
return;
}
input.disabled = true;
try {
await apiPost('questionnaires.php', {
action: 'updateCategoryLabel',
categoryKey,
germanLabel: label,
});
input.dataset.lastSaved = label;
syncCategoryGermanInputs(categoryKey, label);
showToast('Category label saved', 'success');
} catch (err) {
showToast(err.message, 'error');
input.value = prev;
} finally {
input.disabled = false;
}
}
function syncCategoryGermanInputs(categoryKey, label) {
document.querySelectorAll('.category-german-input').forEach(el => {
if (el.dataset.categoryKey === categoryKey) {
el.value = label;
el.dataset.lastSaved = label;
}
});
}
const UNCATEGORIZED = '__uncategorized__';
function groupQuestionnairesByCategory(questionnaires, categoryKeys) {
const labelByKey = Object.fromEntries(
(categoryKeys || [])
.filter(r => !r.orphaned)
.map(r => [r.categoryKey, r.germanLabel || r.categoryKey])
);
const sorted = [...questionnaires].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
const buckets = new Map();
for (const q of sorted) {
const key = (q.categoryKey || '').trim() || UNCATEGORIZED;
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(q);
}
const sections = [...buckets.entries()].map(([key, items]) => ({
categoryKey: key === UNCATEGORIZED ? '' : key,
title: key === UNCATEGORIZED ? 'Uncategorized' : (labelByKey[key] || key),
items,
sortKey: Math.min(...items.map(q => q.orderIndex ?? 0)),
}));
sections.sort((a, b) => {
const aUncat = a.categoryKey === '';
const bUncat = b.categoryKey === '';
if (aUncat !== bUncat) {
return aUncat ? 1 : -1;
}
return a.sortKey - b.sortKey;
});
return sections;
}
function renderQuestionnaireCard(q) {
const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`;
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" aria-label="Drag to reorder">&#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>
</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>`;
}
function renderGrid(questionnaires, categoryKeys = []) {
const grid = document.getElementById('qGrid');
if (!questionnaires.length) {
grid.innerHTML = `
<div class="empty-state">
<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;
}
const sections = groupQuestionnairesByCategory(questionnaires, categoryKeys);
grid.innerHTML = `
<div class="q-dashboard-groups">
${sections.map(section => `
<section class="q-category-group" data-category="${esc(section.categoryKey)}">
<h2 class="q-category-title">${categoryGermanInputHTML(section.categoryKey, section.title)}</h2>
<div class="q-category-grid">
${section.items.map(q => renderQuestionnaireCard(q)).join('')}
</div>
</section>
`).join('')}
</div>`;
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 (!(await confirmAction({
title: 'Delete questionnaire',
message: 'Delete this questionnaire and all its data?',
confirmLabel: 'Delete',
variant: 'danger',
}))) 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 collectDashboardCardOrder() {
const ids = [];
document.querySelectorAll('.q-category-group').forEach(section => {
section.querySelectorAll('.q-category-grid .q-card').forEach(card => {
ids.push(card.dataset.id);
});
});
return ids;
}
function initGridDrag(questionnaires) {
const root = document.getElementById('qGrid');
if (!root) return;
let dragItem = null;
let dragRow = null;
root.addEventListener('dragstart', (e) => {
const card = e.target.closest('.q-card');
if (!card || !e.target.closest('.drag-handle')) { e.preventDefault(); return; }
dragItem = card;
dragRow = card.closest('.q-category-grid');
card.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
});
root.addEventListener('dragover', (e) => {
e.preventDefault();
if (!dragItem || !dragRow) return;
const row = e.target.closest('.q-category-grid');
if (!row || row !== dragRow) return;
const cards = [...row.querySelectorAll('.q-card')].filter(c => c !== dragItem);
let insertBefore = null;
for (const child of cards) {
const box = child.getBoundingClientRect();
if (e.clientX < box.left + box.width / 2) {
insertBefore = child;
break;
}
}
if (insertBefore) row.insertBefore(dragItem, insertBefore);
else row.appendChild(dragItem);
});
root.addEventListener('dragend', async () => {
if (!dragItem) return;
dragItem.style.opacity = '';
const newOrder = collectDashboardCardOrder();
dragItem = null;
dragRow = null;
for (let i = 0; i < newOrder.length; i++) {
const id = newOrder[i];
const q = questionnaires.find(x => x.questionnaireID === id);
if (q && parseInt(q.orderIndex, 10) !== i) {
try {
await apiPut('questionnaires.php', { questionnaireID: id, orderIndex: i });
q.orderIndex = i;
} catch (err) {
showToast(err.message, 'error');
return;
}
}
}
showToast('Order saved', 'success');
});
}
function normalizeProfileBands(profile) {
const greenMax = profile?.greenMax ?? 12;
const yellowMax = profile?.yellowMax ?? 36;
return {
greenMin: profile?.greenMin ?? 0,
greenMax,
yellowMin: profile?.yellowMin ?? (greenMax + 1),
yellowMax,
redMin: profile?.redMin ?? (yellowMax + 1),
};
}
function bandSummary(profile) {
const b = normalizeProfileBands(profile || {});
return `Green ${b.greenMin}${b.greenMax} · Yellow ${b.yellowMin}${b.yellowMax} · Red ${b.redMin}+`;
}
function readBandsFromModal(overlay) {
return {
greenMin: parseInt(overlay.querySelector('#spGreenMin')?.value || '0', 10),
greenMax: parseInt(overlay.querySelector('#spGreenMax')?.value || '0', 10),
yellowMin: parseInt(overlay.querySelector('#spYellowMin')?.value || '0', 10),
yellowMax: parseInt(overlay.querySelector('#spYellowMax')?.value || '0', 10),
redMin: parseInt(overlay.querySelector('#spRedMin')?.value || '0', 10),
};
}
function validateBands(b) {
if (b.greenMin > b.greenMax) return 'Green min must not exceed green max';
if (b.yellowMin > b.yellowMax) return 'Yellow min must not exceed yellow max';
if (b.greenMax >= b.yellowMin) return 'Green range must end before yellow range starts';
if (b.yellowMax >= b.redMin) return 'Yellow range must end before red range starts';
return null;
}
function bandPreviewHTML(b) {
return `
<div class="scoring-band-strips">
<span class="band-badge band-green">Green: ${b.greenMin} ${b.greenMax}</span>
<span class="band-badge band-yellow">Yellow: ${b.yellowMin} ${b.yellowMax}</span>
<span class="band-badge band-red">Red: ${b.redMin}+</span>
</div>`;
}
function bandRangesEditorHTML(bands) {
const b = normalizeProfileBands(bands);
return `
<h4 class="scoring-modal-section-title">Score bands</h4>
<p class="data-toolbar-hint" style="margin:0 0 10px">Set inclusive min/max for each band. Ranges must not overlap.</p>
<div class="scoring-band-ranges-table">
<div class="scoring-band-range-row scoring-band-range-header">
<span>Band</span><span>Min</span><span>Max</span>
</div>
<div class="scoring-band-range-row">
<span class="band-badge band-green">Green</span>
<input type="number" id="spGreenMin" min="0" value="${b.greenMin}">
<input type="number" id="spGreenMax" min="0" value="${b.greenMax}">
</div>
<div class="scoring-band-range-row">
<span class="band-badge band-yellow">Yellow</span>
<input type="number" id="spYellowMin" min="0" value="${b.yellowMin}">
<input type="number" id="spYellowMax" min="0" value="${b.yellowMax}">
</div>
<div class="scoring-band-range-row">
<span class="band-badge band-red">Red</span>
<input type="number" id="spRedMin" min="0" value="${b.redMin}">
<span class="scoring-band-max-placeholder">∞</span>
</div>
</div>
<div id="spBandPreview" class="scoring-band-labels"></div>`;
}
function renderScoringProfilesSection(profiles, questionnaires) {
const panel = document.getElementById('scoringProfilesPanel');
if (!panel) return;
panel.innerHTML = `
<div class="scoring-profiles-section">
<div class="card scoring-profiles-card">
<div class="scoring-profile-header">
<div>
<h3 class="scoring-section-title">Scoring profiles</h3>
<p class="scoring-section-desc">
Weighted multi-questionnaire scores with Green / Yellow / Red bands for insights.
</p>
</div>
${canEdit() ? '<button type="button" class="btn btn-primary btn-sm" id="newScoringProfileBtn">+ New profile</button>' : ''}
</div>
<div id="scoringProfilesList" class="scoring-profiles-list"></div>
</div>
</div>`;
const list = panel.querySelector('#scoringProfilesList');
if (!profiles.length) {
list.innerHTML = '<p class="scoring-empty-hint">No scoring profiles yet. Create one to combine questionnaire scores for insights.</p>';
} else {
list.innerHTML = profiles.map(p => scoringProfileCardHTML(p, questionnaires)).join('');
}
if (canEdit()) {
panel.querySelector('#newScoringProfileBtn')?.addEventListener('click', () => {
openScoringProfileModal(null, questionnaires, profiles);
});
list.querySelectorAll('.edit-profile-btn').forEach(btn => {
btn.addEventListener('click', () => {
const profile = profiles.find(x => x.profileID === btn.dataset.id);
openScoringProfileModal(profile, questionnaires, profiles);
});
});
list.querySelectorAll('.delete-profile-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!(await confirmAction({
title: 'Delete scoring profile',
message: `Delete scoring profile "${btn.dataset.name}"?`,
confirmLabel: 'Delete',
variant: 'danger',
}))) return;
try {
await apiDelete('scoring_profiles.php', { profileID: btn.dataset.id });
showToast('Profile deleted', 'success');
await questionnairesPage();
} catch (err) {
showToast(err.message, 'error');
}
});
});
}
}
function scoringProfileCardHTML(profile, questionnaires) {
const qnById = Object.fromEntries((questionnaires || []).map(q => [q.questionnaireID, q]));
const members = profile.questionnaires || [];
const activeBadge = profile.isActive
? '<span class="badge badge-active">Active</span>'
: '<span class="badge badge-draft">Inactive</span>';
const memberRows = members.map(m => {
const q = qnById[m.questionnaireID];
const name = q?.name || m.questionnaireID;
return `<div class="scoring-profile-q-row"><span class="scoring-profile-q-name">${esc(name)}</span><span class="scoring-profile-q-weight">× ${m.weight}</span></div>`;
}).join('');
return `
<div class="scoring-profile-card">
<div class="scoring-profile-card-top">
<div class="scoring-profile-card-main">
<div class="scoring-profile-name-row">
<strong>${esc(profile.name)}</strong>
${activeBadge}
</div>
${profile.description ? `<p class="scoring-profile-desc">${esc(profile.description)}</p>` : ''}
<p class="scoring-profile-bands">${bandSummary(profile)}</p>
</div>
${canEdit() ? `
<div class="scoring-profile-card-actions">
<button type="button" class="btn btn-sm edit-profile-btn" data-id="${esc(profile.profileID)}">Edit</button>
<button type="button" class="btn btn-sm btn-danger delete-profile-btn"
data-id="${esc(profile.profileID)}" data-name="${esc(profile.name)}">Delete</button>
</div>` : ''}
</div>
<p class="scoring-profile-member-count">${members.length} questionnaire(s)</p>
<div class="scoring-profile-members">${memberRows || '<p class="scoring-empty-hint">No questionnaires linked</p>'}</div>
</div>`;
}
function openScoringProfileModal(profile, questionnaires, allProfiles) {
const isEdit = !!profile;
const members = profile?.questionnaires?.length
? [...profile.questionnaires]
: [];
const selectedIds = new Set(members.map(m => m.questionnaireID));
const initialBands = normalizeProfileBands(profile || {});
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal-dialog modal-dialog-wide">
<h2>${isEdit ? 'Edit scoring profile' : 'New scoring profile'}</h2>
<p class="modal-subtitle">Combine questionnaire totals with weights, then map the sum to Green / Yellow / Red bands.</p>
<div class="form-group">
<label for="spName">Name</label>
<input type="text" id="spName" value="${esc(profile?.name || '')}" placeholder="e.g. RHS Index">
</div>
<div class="form-group">
<label for="spDesc">Description</label>
<textarea id="spDesc" rows="2" placeholder="Optional note for Counselors and admins">${esc(profile?.description || '')}</textarea>
</div>
<div class="form-group scoring-profile-active-row">
<label class="checkbox-inline">
<input type="checkbox" id="spActive" ${profile?.isActive !== 0 ? 'checked' : ''}>
Active (shown in insights)
</label>
</div>
${bandRangesEditorHTML(initialBands)}
<h4 class="scoring-modal-section-title">Session measures</h4>
<p class="data-toolbar-hint" style="margin:0 0 10px">Add questionnaires and set weight. List order is the display order.</p>
<div id="spQnPicker" class="scoring-profile-picker"></div>
<div class="modal-actions">
<button type="button" class="btn btn-sm" id="spCancel">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="spSave">${isEdit ? 'Save' : 'Create'}</button>
</div>
</div>`;
document.body.appendChild(overlay);
document.body.classList.add('modal-open');
const picker = overlay.querySelector('#spQnPicker');
const sortedQn = [...(questionnaires || [])].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
function renderPicker() {
const ordered = members.length
? members.map(m => ({ ...m, q: sortedQn.find(q => q.questionnaireID === m.questionnaireID) }))
: [];
const available = sortedQn.filter(q => !selectedIds.has(q.questionnaireID));
picker.innerHTML = `
${ordered.map((m, idx) => `
<div class="scoring-profile-q-row" data-qn="${esc(m.questionnaireID)}">
<span class="scoring-profile-q-name">${esc(m.q?.name || m.questionnaireID)}</span>
<span class="scoring-profile-q-actions">
<label class="sp-weight-wrap">Weight
<input type="number" class="sp-weight" step="0.1" min="0.1"
value="${m.weight ?? 1}">
</label>
<button type="button" class="btn btn-sm sp-move-up" ${idx === 0 ? 'disabled' : ''} title="Move up">↑</button>
<button type="button" class="btn btn-sm sp-move-down" ${idx === ordered.length - 1 ? 'disabled' : ''} title="Move down">↓</button>
<button type="button" class="btn btn-sm btn-danger sp-remove" title="Remove">Remove</button>
</span>
</div>
`).join('')}
${available.length ? `
<div class="scoring-profile-add-row">
<select id="spAddSelect">
<option value="">Add questionnaire…</option>
${available.map(q => `<option value="${esc(q.questionnaireID)}">${esc(q.name)}</option>`).join('')}
</select>
<button type="button" class="btn btn-sm" id="spAddBtn">Add</button>
</div>` : ''}`;
picker.querySelectorAll('.sp-weight').forEach((input, idx) => {
input.addEventListener('change', () => {
members[idx].weight = parseFloat(input.value) || 1;
});
});
picker.querySelectorAll('.sp-remove').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('.scoring-profile-q-row');
const id = row?.dataset.qn;
if (!id) return;
selectedIds.delete(id);
const i = members.findIndex(m => m.questionnaireID === id);
if (i >= 0) members.splice(i, 1);
renderPicker();
});
});
picker.querySelectorAll('.sp-move-up').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('.scoring-profile-q-row');
const id = row?.dataset.qn;
const i = members.findIndex(m => m.questionnaireID === id);
if (i > 0) {
[members[i - 1], members[i]] = [members[i], members[i - 1]];
renderPicker();
}
});
});
picker.querySelectorAll('.sp-move-down').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('.scoring-profile-q-row');
const id = row?.dataset.qn;
const i = members.findIndex(m => m.questionnaireID === id);
if (i >= 0 && i < members.length - 1) {
[members[i], members[i + 1]] = [members[i + 1], members[i]];
renderPicker();
}
});
});
overlay.querySelector('#spAddBtn')?.addEventListener('click', () => {
const sel = overlay.querySelector('#spAddSelect');
const id = sel?.value;
if (!id || selectedIds.has(id)) return;
selectedIds.add(id);
members.push({ questionnaireID: id, weight: 1, orderIndex: members.length });
renderPicker();
});
}
function updateBandPreview() {
const el = overlay.querySelector('#spBandPreview');
if (!el) return;
const b = readBandsFromModal(overlay);
const err = validateBands(b);
if (err) {
el.innerHTML = `<p class="error-text" style="margin:0">${esc(err)}</p>`;
return;
}
el.innerHTML = bandPreviewHTML(b);
}
renderPicker();
updateBandPreview();
['#spGreenMin', '#spGreenMax', '#spYellowMin', '#spYellowMax', '#spRedMin'].forEach(sel => {
overlay.querySelector(sel)?.addEventListener('input', updateBandPreview);
});
const close = () => {
overlay.remove();
document.body.classList.remove('modal-open');
};
overlay.querySelector('#spCancel')?.addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelector('#spSave')?.addEventListener('click', async () => {
const name = overlay.querySelector('#spName')?.value.trim();
const description = overlay.querySelector('#spDesc')?.value.trim() || '';
const isActive = overlay.querySelector('#spActive')?.checked;
const bands = readBandsFromModal(overlay);
const bandErr = validateBands(bands);
if (!name) {
showToast('Profile name is required', 'error');
return;
}
if (bandErr) {
showToast(bandErr, 'error');
return;
}
if (!members.length) {
showToast('Add at least one questionnaire', 'error');
return;
}
const payload = {
name,
description,
isActive,
...bands,
questionnaires: members.map((m, idx) => ({
questionnaireID: m.questionnaireID,
weight: parseFloat(picker.querySelector(`[data-qn="${m.questionnaireID}"] .sp-weight`)?.value) || m.weight || 1,
orderIndex: idx,
})),
};
if (isEdit) payload.profileID = profile.profileID;
const saveBtn = overlay.querySelector('#spSave');
saveBtn.disabled = true;
try {
if (isEdit) {
await apiPut('scoring_profiles.php', payload);
} else {
await apiPost('scoring_profiles.php', payload);
}
showToast(isEdit ? 'Profile saved' : 'Profile created', 'success');
close();
await questionnairesPage();
} catch (err) {
showToast(err.message, 'error');
saveBtn.disabled = false;
}
});
}
function bindImportBundle() {
const fileInput = document.getElementById('importBundleFile');
const btn = document.getElementById('importBundleBtn');
if (!fileInput || !btn) return;
btn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
fileInput.value = '';
if (!file) return;
let bundle;
try {
const text = await file.text();
bundle = JSON.parse(text);
} catch {
showToast('Invalid JSON file', 'error');
return;
}
if (!bundle?.questionnaires || !Array.isArray(bundle.questionnaires)) {
showToast('Not a questionnaire bundle (missing questionnaires array)', 'error');
return;
}
const count = bundle.questionnaires.length;
if (!(await confirmAction({
title: 'Import questionnaires',
message: `Import ${count} questionnaire(s) from this file?`,
confirmLabel: 'Import',
}))) return;
const mode = await confirmAction({
title: 'Existing questionnaire IDs',
message: 'If a questionnaire ID from the file already exists, how should it be handled?',
choices: [
{
value: 'replace',
label: 'Replace existing',
description: 'Overwrite the questionnaire with that ID.',
},
{
value: 'copy',
label: 'Import as new copy',
description: 'Keep the existing questionnaire and import with a new ID.',
},
],
confirmLabel: 'Continue',
cancelLabel: 'Cancel import',
});
if (!mode) return;
const replace = mode === 'replace';
btn.disabled = true;
const prev = btn.textContent;
btn.textContent = 'Importing…';
try {
const result = await apiPost('questionnaires.php', {
action: 'importBundle',
bundle,
replaceIfExists: replace,
});
const n = result.imported ?? count;
showToast(`Imported ${n} questionnaire(s)`, 'success');
await questionnairesPage();
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = prev;
}
});
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

468
website/js/pages/results.js Normal file
View File

@ -0,0 +1,468 @@
import { apiGet } from '../api.js';
import { homeNavButton, showToast } from '../app.js';
import { buildResultColumns, resultColumnCellValue } from '../test-data.js';
const PAGE_SIZE = 50;
let sortCol = null;
let sortDir = 'asc';
let allClients = [];
let questionsDef = [];
let resultColumns = [];
let questionnaireMeta = null;
let filterCoach = '';
let filterSupervisor = '';
let filterStatus = '';
let filterDateFrom = '';
let filterDateTo = '';
let filterSearch = '';
let filterQuestion = '';
let page = 0;
export async function resultsPage(params) {
const app = document.getElementById('app');
sortCol = null;
sortDir = 'asc';
filterCoach = '';
filterSupervisor = '';
filterStatus = '';
filterDateFrom = '';
filterDateTo = '';
filterSearch = '';
filterQuestion = '';
page = 0;
app.innerHTML = `
<div class="page-header">
<div class="page-header-start">
${homeNavButton()}
<div class="page-header-titles">
<h1 id="resultsTitle">Results</h1>
</div>
</div>
<div class="actions">
<a href="#/questionnaire/${params.id}" class="btn">Editor</a>
<a href="#/questionnaires" class="btn">Session measures</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');
resultColumns = buildResultColumns(questionsDef);
// Collect unique coaches and statuses for filters
const coachOptions = getCoachFilterOptions();
const supervisorOptions = getSupervisorFilterOptions();
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="filterSupervisor">
<option value="">All supervisors</option>
${supervisorOptions.map(name => `<option value="${esc(name)}" ${filterSupervisor === name ? 'selected' : ''}>${esc(name)}</option>`).join('')}
</select>
<select id="filterCoach">
<option value="">All counselors</option>
${coachOptions.map(([id, label]) => `<option value="${esc(id)}" ${filterCoach === id ? 'selected' : ''}>${esc(label)}</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>
<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>
<input type="search" id="filterSearch" class="filter-search" placeholder="Search client, counselors, status…" value="${esc(filterSearch)}">
<input type="search" id="filterQuestion" placeholder="Go to question key…" value="${esc(filterQuestion)}" style="min-width:160px" list="questionKeyList">
<datalist id="questionKeyList"></datalist>
<button type="button" class="btn btn-sm" id="gotoQuestionBtn">Go to column</button>
<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>
<a id="exportCsvLink" class="btn btn-sm" href="#">Export CSV</a>
</div>
<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.</p>
</div>
<div class="card">
<div class="table-wrapper" id="resultsTableWrap">
<table class="data-table" id="resultsTable">
<thead><tr id="resultsHead"></tr></thead>
<tbody id="resultsBody"></tbody>
</table>
</div>
<div class="pagination-bar" id="paginationBar"></div>
</div>
`;
const qKeyList = document.getElementById('questionKeyList');
qKeyList.innerHTML = resultColumns
.map(col => `<option value="${esc(col.label)}"></option>`)
.join('');
document.getElementById('filterSupervisor').addEventListener('change', (e) => {
filterSupervisor = e.target.value;
page = 0;
renderTableRows();
});
document.getElementById('filterCoach').addEventListener('change', (e) => {
filterCoach = e.target.value;
page = 0;
renderTableRows();
});
document.getElementById('filterStatus').addEventListener('change', (e) => {
filterStatus = e.target.value;
page = 0;
renderTableRows();
});
document.getElementById('filterDateFrom').addEventListener('change', (e) => {
filterDateFrom = e.target.value;
page = 0;
renderTableRows();
});
document.getElementById('filterDateTo').addEventListener('change', (e) => {
filterDateTo = e.target.value;
page = 0;
renderTableRows();
});
document.getElementById('filterSearch').addEventListener('input', (e) => {
filterSearch = e.target.value.trim();
page = 0;
renderTableRows();
});
document.getElementById('gotoQuestionBtn').addEventListener('click', () => {
filterQuestion = document.getElementById('filterQuestion').value.trim();
scrollToQuestionColumn(filterQuestion);
});
document.getElementById('filterQuestion').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
filterQuestion = e.target.value.trim();
scrollToQuestionColumn(filterQuestion);
}
});
document.getElementById('clearFiltersBtn').addEventListener('click', () => {
filterCoach = '';
filterSupervisor = '';
filterStatus = '';
filterDateFrom = '';
filterDateTo = '';
filterSearch = '';
filterQuestion = '';
page = 0;
renderResults();
});
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: 'coachUsername', label: 'Counselor' },
{ key: 'supervisorUsername', label: 'Supervisor' },
{ key: 'status', label: 'Status' },
{ key: 'sumPoints', label: 'Score' },
{ key: 'completedAt', label: 'Completed' },
];
const allCols = [...fixedCols, ...resultColumns.map(col => ({
key: col.key,
label: col.label,
title: col.title,
}))];
head.innerHTML = allCols.map((col, idx) => {
let cls = 'sortable';
if (sortCol === col.key) cls += sortDir === 'asc' ? ' sort-asc' : ' sort-desc';
if (idx === 0) cls += ' sticky-col';
const title = col.title ? ` title="${esc(col.title)}"` : '';
return `<th class="${cls}" data-key="${col.key}"${title}>${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'; }
page = 0;
renderTableHead();
renderTableRows();
});
});
}
function scrollToQuestionColumn(query) {
if (!query) return;
const q = query.toLowerCase();
const th = [...document.querySelectorAll('#resultsHead th')].find(el => {
const key = (el.dataset.key || '').toLowerCase();
const text = (el.textContent || '').trim().toLowerCase();
const title = (el.getAttribute('title') || '').toLowerCase();
return text === q || title === q || key.endsWith(q) || text.includes(q);
});
if (!th) {
showToast(`No column matching "${query}"`, 'error');
return;
}
th.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
th.style.outline = '2px solid var(--primary)';
setTimeout(() => { th.style.outline = ''; }, 2000);
}
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() {
let clients = allClients;
if (filterSupervisor) clients = clients.filter(c => c.supervisorUsername === filterSupervisor);
if (filterCoach) clients = clients.filter(c => c.coachID === filterCoach);
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;
});
}
if (filterSearch) {
const q = filterSearch.toLowerCase();
clients = clients.filter(c => {
const hay = [
c.clientCode,
c.coachUsername,
c.supervisorUsername,
c.status,
].filter(Boolean).join(' ').toLowerCase();
return hay.includes(q);
});
}
return clients;
}
function renderPagination(totalRows) {
const bar = document.getElementById('paginationBar');
if (!bar) return;
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
if (page >= totalPages) page = totalPages - 1;
if (page < 0) page = 0;
if (totalRows <= PAGE_SIZE) {
bar.innerHTML = totalRows
? `<span>${totalRows} row${totalRows === 1 ? '' : 's'}</span>`
: '';
return;
}
const from = page * PAGE_SIZE + 1;
const to = Math.min((page + 1) * PAGE_SIZE, totalRows);
bar.innerHTML = `
<button type="button" class="btn btn-sm" id="pagePrev" ${page <= 0 ? 'disabled' : ''}>Prev</button>
<span>Rows ${from}${to} of ${totalRows} (page ${page + 1}/${totalPages})</span>
<button type="button" class="btn btn-sm" id="pageNext" ${page >= totalPages - 1 ? 'disabled' : ''}>Next</button>
`;
document.getElementById('pagePrev')?.addEventListener('click', () => {
page--;
renderTableRows();
});
document.getElementById('pageNext')?.addEventListener('click', () => {
page++;
renderTableRows();
});
}
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() {
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'}`;
renderPagination(clients.length);
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;
}
const totalPages = Math.max(1, Math.ceil(clients.length / PAGE_SIZE));
if (page >= totalPages) page = totalPages - 1;
clients = clients.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
// 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 = 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>
<td class="sticky-col">${esc(c.clientCode)}</td>
<td>${esc(coachDisplayName(c))}</td>
<td>${esc(supervisorDisplayName(c))}</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 === 'coachUsername') return coachDisplayName(client);
if (key === 'supervisorUsername') return supervisorDisplayName(client);
if (key === 'status') return client.status;
if (key === 'sumPoints') return client.sumPoints;
if (key === 'completedAt') return client.completedAt;
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;
}
function exportCSV() {
const clients = getFilteredClients();
const optionMap = {};
questionsDef.forEach(q => {
(q.answerOptions || []).forEach(o => {
optionMap[o.answerOptionID] = o.defaultText;
});
});
const headers = ['clientCode', 'Counselor', 'supervisor', 'status', 'sumPoints', 'completedAt',
...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 = resultColumns.map(col => {
const ans = c.answers && c.answers[col.questionID];
const val = resultColumnCellValue(col, ans, optionMap);
return val != null ? val : '';
});
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;
}

View File

@ -0,0 +1,707 @@
import { apiGet, apiPost, apiPut, apiDelete, apiDownloadFetch, apiUrl } from '../api.js';
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import {
SOURCE_LANG,
normalizeEntry,
translationFor,
targetLanguages,
sourceLanguageLabel,
translationListHeaderHTML,
buildTranslationGroups,
renderCollapsibleGroupedTranslationsHTML,
flattenAllQuestionnaires,
countMissing,
sectionEntries,
escHtml as esc,
} from '../translations-helpers.js';
const STORAGE_LANG = 'qdb_trans_lang';
const STORAGE_SCOPE = 'qdb_trans_scope';
const STORAGE_SHOW = 'qdb_trans_show_mode';
let transData = null;
let allEntries = [];
let selectedLang = '';
let selectedScope = '';
/** @type {'missing' | 'all' | 'complete'} */
let showMode = 'missing';
let saveTimers = {};
let savesInFlight = 0;
export async function translationsPage() {
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Translations', '<span id="transHeaderActions"></span>')}
<div id="transPageRoot"><div class="spinner"></div></div>
`;
if (canEdit()) {
document.getElementById('transHeaderActions').innerHTML = `
<input type="file" id="importTransBundleFile" accept=".json,application/json" hidden>
<button type="button" class="btn" id="importTransBundleBtn">Import translations</button>
<button type="button" class="btn btn-primary" id="exportTransBundleBtn">Export translations</button>
`;
bindTranslationBundleActions();
}
try {
renderShell();
await loadTranslations();
} catch (e) {
document.getElementById('transPageRoot').innerHTML =
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
}
}
function renderShell() {
document.getElementById('transPageRoot').innerHTML = `
<div class="card trans-page">
<div class="trans-controls">
<label class="trans-control">
<span>Translate into</span>
<select id="transLangSelect" class="trans-select" disabled>
<option value="">—</option>
</select>
</label>
</div>
<details class="trans-lang-panel">
<summary>More languages</summary>
<div id="transLangPanel"><span class="text-muted">Loading…</span></div>
</details>
<div class="trans-toolbar">
<label class="trans-control trans-toolbar-field">
<span>Content</span>
<select id="transScopeSelect" class="trans-select" disabled>
<option value="">Loading…</option>
</select>
</label>
<label class="trans-control trans-toolbar-field">
<span>Show</span>
<select id="transShowMode" class="trans-select trans-type-select" disabled>
<option value="missing">Missing only</option>
<option value="all">All</option>
<option value="complete">Complete only</option>
</select>
</label>
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search in scope…" disabled>
<select id="transTypeFilter" class="trans-select trans-type-select" disabled>
<option value="">All types</option>
<option value="app_string">App strings</option>
<option value="string">Questionnaire UI strings</option>
<option value="question">Questions</option>
<option value="answer_option">Answer options</option>
</select>
</div>
<div id="transListArea"><div class="spinner"></div></div>
</div>
`;
}
function pickDefaultTargetLang(languages) {
const targets = targetLanguages(languages);
if (!targets.length) return '';
const stored = localStorage.getItem(STORAGE_LANG);
if (stored && targets.some(l => l.languageCode === stored)) {
return stored;
}
if (selectedLang && targets.some(l => l.languageCode === selectedLang)) {
return selectedLang;
}
return targets[0].languageCode;
}
function pickDefaultShowMode() {
const stored = localStorage.getItem(STORAGE_SHOW);
if (stored === 'missing' || stored === 'all' || stored === 'complete') {
return stored;
}
return 'missing';
}
function pickDefaultScope(sections, entries, lang) {
const stored = localStorage.getItem(STORAGE_SCOPE);
if (stored === '__all__' || sections.some(s => s.questionnaireID === stored)) {
return stored;
}
for (const sec of sections) {
const slice = sectionEntries(entries, sec);
if (countMissing(slice, lang) > 0) {
return sec.questionnaireID;
}
}
return sections[0]?.questionnaireID || '__app__';
}
function scopeLabel(sec, entries, lang) {
const slice = sectionEntries(entries, sec);
const missing = countMissing(slice, lang);
const suffix = missing ? ` (${missing} missing)` : ' (complete)';
return `${sec.name}${suffix}`;
}
function scopedSections() {
const sections = transData?.sections || [];
if (!selectedScope || selectedScope === '__all__') {
return sections;
}
return sections.filter(s => s.questionnaireID === selectedScope);
}
function entriesInScope() {
const sections = scopedSections();
const out = [];
for (const sec of sections) {
out.push(...sectionEntries(allEntries, sec));
}
return out;
}
function setSaveStatus(state) {
const el = document.getElementById('transSaveStatus');
if (!el) return;
el.dataset.state = state;
if (state === 'saving') {
el.textContent = 'Saving…';
} else if (state === 'error') {
el.textContent = 'Save failed';
} else {
el.textContent = 'All changes saved';
}
}
async function ensureGermanLanguage() {
try {
await apiPut('translations.php', { type: 'language', languageCode: SOURCE_LANG, name: 'German' });
} catch (_) { /* already exists */ }
}
async function loadTranslations() {
const listArea = document.getElementById('transListArea');
const langSelect = document.getElementById('transLangSelect');
const filter = document.getElementById('transFilter');
const typeFilter = document.getElementById('transTypeFilter');
if (listArea) listArea.innerHTML = '<div class="spinner"></div>';
saveTimers = {};
try {
await ensureGermanLanguage();
const data = await apiGet('translations.php?all=1');
transData = data;
const languages = transData.languages || [];
const questionnaires = transData.questionnaires || [];
const flat = flattenAllQuestionnaires(questionnaires, data.appStrings || []);
allEntries = flat.allEntries;
transData.sections = flat.sections;
selectedLang = pickDefaultTargetLang(languages);
showMode = pickDefaultShowMode();
selectedScope = pickDefaultScope(flat.sections, allEntries, selectedLang);
if (langSelect) {
const targets = targetLanguages(languages);
langSelect.disabled = !targets.length;
langSelect.innerHTML = targets.length
? targets.map(l => {
const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode;
return `<option value="${esc(l.languageCode)}" ${l.languageCode === selectedLang ? 'selected' : ''}>${esc(label)}</option>`;
}).join('')
: '<option value="">Add a language below</option>';
langSelect.onchange = () => {
selectedLang = langSelect.value;
localStorage.setItem(STORAGE_LANG, selectedLang);
populateScopeSelect();
renderEntryList();
};
}
const scopeSelect = document.getElementById('transScopeSelect');
const showSelect = document.getElementById('transShowMode');
if (scopeSelect) {
scopeSelect.disabled = false;
populateScopeSelect();
scopeSelect.onchange = () => {
selectedScope = scopeSelect.value;
localStorage.setItem(STORAGE_SCOPE, selectedScope);
renderEntryList();
};
}
if (showSelect) {
showSelect.disabled = false;
showSelect.value = showMode;
showSelect.onchange = () => {
showMode = showSelect.value;
localStorage.setItem(STORAGE_SHOW, showMode);
applyFilters();
};
}
if (filter) filter.disabled = false;
if (typeFilter) typeFilter.disabled = false;
renderLanguagePanel(languages);
renderEntryList();
bindFilters();
setSaveStatus('saved');
} catch (e) {
if (listArea) listArea.innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
showToast(e.message, 'error');
}
}
function renderLanguagePanel(languages) {
const panel = document.getElementById('transLangPanel');
if (!panel) return;
const others = targetLanguages(languages);
panel.innerHTML = `
<p class="text-muted" style="font-size:.85rem;margin:0 0 10px">
German source text is edited in the questionnaire editor. Add other languages here.
</p>
<div class="lang-chips">
<span class="lang-chip lang-chip-fixed">
<strong>DE</strong>
<span class="lang-chip-name">German (source)</span>
</span>
${others.map(l => `
<span class="lang-chip">
<strong>${esc(l.languageCode.toUpperCase())}</strong>
${l.name ? `<span class="lang-chip-name">${esc(l.name)}</span>` : ''}
<button type="button" class="lang-chip-remove" data-lc="${esc(l.languageCode)}" title="Remove language">&times;</button>
</span>
`).join('')}
</div>
<div class="lang-add-row">
<input type="text" id="newLangCode" placeholder="Code (en)" class="lang-add-code" maxlength="8">
<input type="text" id="newLangName" placeholder="Name (English)" class="lang-add-name">
<button type="button" class="btn btn-sm btn-primary" id="addLangBtn">Add</button>
</div>
`;
document.getElementById('addLangBtn')?.addEventListener('click', async () => {
const code = document.getElementById('newLangCode').value.trim().toLowerCase();
const name = document.getElementById('newLangName').value.trim();
if (!code) { showToast('Language code is required', 'error'); return; }
if (code === SOURCE_LANG) {
showToast('German (de) is always available as the source language', 'error');
return;
}
if (languages.some(l => l.languageCode === code)) {
showToast('Language already exists', 'error');
return;
}
try {
await apiPut('translations.php', { type: 'language', languageCode: code, name });
showToast(`Language "${code}" added`, 'success');
selectedLang = code;
await loadTranslations();
} catch (e) {
showToast(e.message, 'error');
}
});
panel.querySelectorAll('.lang-chip-remove').forEach(btn => {
btn.addEventListener('click', async () => {
const lc = btn.dataset.lc;
if (!(await confirmAction({
title: 'Remove language',
message: `Remove "${lc}" and all its translations?`,
confirmLabel: 'Remove',
variant: 'danger',
}))) return;
try {
await apiDelete('translations.php', { type: 'language', languageCode: lc });
showToast(`Language "${lc}" removed`, 'success');
if (selectedLang === lc) selectedLang = '';
await loadTranslations();
} catch (e) {
showToast(e.message, 'error');
}
});
});
}
function populateScopeSelect() {
const scopeSelect = document.getElementById('transScopeSelect');
if (!scopeSelect || !transData) return;
const sections = transData.sections || [];
const options = sections.map(sec =>
`<option value="${esc(sec.questionnaireID)}"${sec.questionnaireID === selectedScope ? ' selected' : ''}>${esc(scopeLabel(sec, allEntries, selectedLang))}</option>`
);
options.push(`<option value="__all__"${selectedScope === '__all__' ? ' selected' : ''}>All content</option>`);
scopeSelect.innerHTML = options.join('');
if (!selectedScope || (selectedScope !== '__all__' && !sections.some(s => s.questionnaireID === selectedScope))) {
selectedScope = pickDefaultScope(sections, allEntries, selectedLang);
scopeSelect.value = selectedScope;
localStorage.setItem(STORAGE_SCOPE, selectedScope);
}
}
function renderEntryList() {
const listArea = document.getElementById('transListArea');
if (!listArea || !transData) return;
const languages = transData.languages || [];
const targets = targetLanguages(languages);
const sections = scopedSections();
if (!targets.length) {
listArea.innerHTML = `
<p class="text-muted trans-hint">Add at least one language under <strong>More languages</strong> to translate into.</p>`;
return;
}
if (!selectedLang) {
listArea.innerHTML = `<p class="text-muted trans-hint">Choose a language above.</p>`;
return;
}
if (!transData.sections?.length) {
listArea.innerHTML = `
<p class="text-muted trans-hint">No translatable content yet. Add questionnaires and questions in the editor first.</p>`;
return;
}
const sourceLabel = sourceLanguageLabel(languages);
const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase();
const scopeEntries = entriesInScope();
const missingCount = countMissing(scopeEntries, selectedLang);
const pct = scopeEntries.length
? Math.round(((scopeEntries.length - missingCount) / scopeEntries.length) * 100)
: 0;
const headerOnce = translationListHeaderHTML(langLabel, sourceLabel);
const useQnDetails = selectedScope === '__all__';
const sectionsHtml = sections.map(sec => {
const entries = sectionEntries(allEntries, sec);
const groups = buildTranslationGroups(entries, selectedLang, sec.startIdx);
const secMissing = countMissing(entries, selectedLang);
const inner = renderCollapsibleGroupedTranslationsHTML(groups, selectedLang, sourceLabel, true, {
openGroupsWithMissing: selectedScope !== '__all__',
});
if (useQnDetails) {
const open = secMissing > 0 && sections.filter(s =>
countMissing(sectionEntries(allEntries, s), selectedLang) > 0
)[0]?.questionnaireID === sec.questionnaireID;
const summary = secMissing
? `${sec.name} · ${secMissing} missing`
: `${sec.name} · complete`;
return `
<details class="trans-qn-section"${open ? ' open' : ''} data-qn="${esc(sec.questionnaireID)}">
<summary class="trans-qn-summary">${esc(summary)}</summary>
${inner}
</details>`;
}
return `<div class="trans-scope-block" data-qn="${esc(sec.questionnaireID)}">${inner}</div>`;
}).join('');
listArea.innerHTML = `
<div class="trans-top-bar">
<div class="trans-top-stats">
<span id="transVisibleCount">${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} in scope</span>
<span class="trans-stats-progress">
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
<span class="trans-progress-label">${pct}% in ${esc(selectedLang.toUpperCase())}</span>
</span>
</div>
<div class="trans-top-actions">
<span id="transSaveStatus" class="trans-save-status" data-state="saved">All changes saved</span>
<button type="button" class="btn btn-sm" id="transJumpMissingBtn">Jump to next missing</button>
</div>
</div>
<div class="trans-sheet">
${headerOnce}
<div id="transGroupedRoot" class="trans-sheet-body${useQnDetails ? ' trans-sheet-body-stack' : ''}">${sectionsHtml}</div>
</div>
`;
document.getElementById('transJumpMissingBtn')?.addEventListener('click', jumpToNextMissing);
bindEntryEditing(allEntries);
applyFilters();
}
function openAncestors(el) {
let node = el;
while (node) {
if (node.tagName === 'DETAILS') node.open = true;
node = node.parentElement;
}
}
function jumpToNextMissing() {
const rows = [...document.querySelectorAll('#transGroupedRoot .trans-list-item')]
.filter(r => !r.classList.contains('trans-row-hidden') && r.dataset.missing === '1');
if (!rows.length) {
showToast(showMode === 'missing'
? 'All translations in this scope are complete'
: 'No missing translations in view', 'info');
return;
}
const activeRow = document.activeElement?.closest?.('.trans-list-item');
let start = 0;
if (activeRow) {
const idx = rows.indexOf(activeRow);
if (idx >= 0) start = idx + 1;
}
const target = rows[start] || rows[0];
openAncestors(target);
const inp = target.querySelector('[data-field="translation"]');
inp?.focus({ preventScroll: false });
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function bindEntryEditing(entries) {
document.querySelectorAll('#transGroupedRoot .trans-cell-input').forEach(inp => {
inp.addEventListener('input', () => {
const idx = parseInt(inp.dataset.idx, 10);
const entry = entries[idx];
if (!entry) return;
const isGerman = inp.dataset.field === 'german';
if (isGerman) {
entry.germanText = inp.value;
if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {};
entry.translations[SOURCE_LANG] = inp.value;
} else {
if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {};
entry.translations[selectedLang] = inp.value;
}
if (!isGerman) {
const missing = !inp.value.trim();
inp.classList.toggle('trans-input-missing', missing);
const row = inp.closest('.trans-list-item');
if (row) {
row.classList.toggle('trans-list-item-missing', missing);
row.dataset.missing = missing ? '1' : '0';
}
}
const timerKey = isGerman ? `${idx}_de` : `${idx}_${selectedLang}`;
clearTimeout(saveTimers[timerKey]);
saveTimers[timerKey] = setTimeout(() => saveTranslation(entry, inp, isGerman), 500);
setSaveStatus('saving');
});
});
}
async function saveTranslation(entry, inp, isGerman = false) {
savesInFlight++;
setSaveStatus('saving');
try {
await apiPut('translations.php', {
type: entry.type,
id: entry.entityId,
languageCode: isGerman ? SOURCE_LANG : selectedLang,
text: inp.value,
});
inp.classList.add('save-flash');
setTimeout(() => inp.classList.remove('save-flash'), 400);
updateProgress();
} catch (e) {
setSaveStatus('error');
showToast(e.message, 'error');
} finally {
savesInFlight = Math.max(0, savesInFlight - 1);
if (savesInFlight === 0) {
setSaveStatus('saved');
populateScopeSelect();
}
}
}
function updateProgress() {
const scopeEntries = entriesInScope();
const missing = countMissing(scopeEntries, selectedLang);
const pct = scopeEntries.length
? Math.round(((scopeEntries.length - missing) / scopeEntries.length) * 100)
: 0;
const fill = document.querySelector('.trans-progress-fill');
if (fill) fill.style.width = `${pct}%`;
const label = document.querySelector('.trans-progress-label');
if (label) label.textContent = `${pct}% in ${selectedLang.toUpperCase()}`;
if (!document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) {
updateVisibleCountSummary();
}
}
function updateVisibleCountSummary(visible = null, visibleMissing = null) {
const countEl = document.getElementById('transVisibleCount');
if (!countEl) return;
const scopeEntries = entriesInScope();
const text = document.getElementById('transFilter')?.value || '';
const type = document.getElementById('transTypeFilter')?.value || '';
if (text || type || showMode !== 'missing') {
if (visible != null) {
countEl.textContent = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''}`;
}
return;
}
const missing = countMissing(scopeEntries, selectedLang);
countEl.textContent = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} in scope`;
}
let filtersBound = false;
function bindFilters() {
if (filtersBound) {
applyFilters();
return;
}
filtersBound = true;
document.getElementById('transFilter')?.addEventListener('input', applyFilters);
document.getElementById('transTypeFilter')?.addEventListener('change', applyFilters);
}
function applyFilters() {
const text = (document.getElementById('transFilter')?.value || '').toLowerCase();
const type = document.getElementById('transTypeFilter')?.value || '';
const items = document.querySelectorAll('#transGroupedRoot .trans-list-item');
let visible = 0;
let visibleMissing = 0;
items.forEach(row => {
const key = row.dataset.key?.toLowerCase() || '';
const label = row.dataset.label?.toLowerCase()
|| row.querySelector('.trans-key-text')?.textContent?.toLowerCase() || '';
const rowType = row.dataset.type || '';
const german = row.querySelector('.trans-source-text')?.textContent?.toLowerCase()
|| row.querySelector('[data-field="german"]')?.value?.toLowerCase() || '';
const val = row.querySelector('[data-field="translation"]')?.value?.toLowerCase() || '';
const isMissing = row.dataset.missing === '1';
let show = (!type || rowType === type) &&
(!text || key.includes(text) || label.includes(text) || german.includes(text) || val.includes(text));
if (showMode === 'missing' && !isMissing) show = false;
if (showMode === 'complete' && isMissing) show = false;
row.classList.toggle('trans-row-hidden', !show);
if (show) {
visible++;
if (row.dataset.missing === '1') visibleMissing++;
}
});
document.querySelectorAll('#transGroupedRoot .trans-group-details').forEach(group => {
const rows = group.querySelectorAll('.trans-list-item');
const anyVisible = [...rows].some(r => !r.classList.contains('trans-row-hidden'));
group.classList.toggle('trans-group-hidden', !anyVisible);
});
document.querySelectorAll('#transGroupedRoot .trans-scope-block, #transGroupedRoot .trans-qn-section').forEach(block => {
const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
block.classList.toggle('trans-qn-hidden', !anyVisible);
});
const emptyEl = document.getElementById('transEmptyHint');
if (emptyEl) emptyEl.remove();
if (visible === 0 && items.length > 0) {
const root = document.getElementById('transGroupedRoot');
if (root) {
const hint = document.createElement('p');
hint.id = 'transEmptyHint';
hint.className = 'text-muted trans-hint trans-empty-hint';
hint.textContent = showMode === 'missing'
? 'All translations in this scope are complete for the selected language.'
: 'No rows match the current filters.';
root.insertAdjacentElement('afterend', hint);
}
}
updateVisibleCountSummary(visible, visibleMissing);
}
function bindTranslationBundleActions() {
const fileInput = document.getElementById('importTransBundleFile');
const importBtn = document.getElementById('importTransBundleBtn');
const exportBtn = document.getElementById('exportTransBundleBtn');
if (!fileInput || !importBtn || !exportBtn) return;
exportBtn.addEventListener('click', async () => {
exportBtn.disabled = true;
const prev = exportBtn.textContent;
exportBtn.textContent = 'Preparing…';
try {
const res = await apiDownloadFetch(apiUrl('translations?exportBundle=1'));
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message || err.error || 'Export failed');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const stamp = new Date().toISOString().slice(0, 10);
a.download = `translations_bundle_${stamp}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('Translations bundle downloaded', 'success');
} catch (e) {
showToast(e.message, 'error');
} finally {
exportBtn.disabled = false;
exportBtn.textContent = prev;
}
});
importBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
fileInput.value = '';
if (!file) return;
let bundle;
try {
bundle = JSON.parse(await file.text());
} catch {
showToast('Invalid JSON file', 'error');
return;
}
if (!bundle?.entries || !Array.isArray(bundle.entries)) {
showToast('Not a translations bundle (missing entries array)', 'error');
return;
}
const count = bundle.entries.length;
if (!(await confirmAction({
title: 'Import translations',
message: `Import translations for ${count} entries from this file?`,
confirmLabel: 'Import',
}))) return;
importBtn.disabled = true;
const prev = importBtn.textContent;
importBtn.textContent = 'Importing…';
try {
const result = await apiPost('translations.php', {
action: 'importBundle',
bundle,
});
const imported = result.imported ?? 0;
const skipped = result.skipped ?? 0;
let msg = `Imported ${imported} translation(s)`;
if (skipped) msg += ` (${skipped} skipped)`;
showToast(msg, 'success');
await loadTranslations();
} catch (e) {
showToast(e.message, 'error');
} finally {
importBtn.disabled = false;
importBtn.textContent = prev;
}
});
}

562
website/js/pages/users.js Normal file
View File

@ -0,0 +1,562 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import { openAdminResetPasswordModal } from '../password-modal.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 = '';
/** @type {Record<string, string>} */
let filterSearchByRole = { admin: '', supervisor: '', coach: '' };
function roleGroupsForCaller() {
return callerRole === 'supervisor'
? [{ label: 'Counselor', key: 'coach' }]
: [
{ label: 'Admins', key: 'admin' },
{ label: 'Supervisors', key: 'supervisor' },
{ label: 'Counselor', key: 'coach' },
];
}
function searchPlaceholderForRole(roleKey) {
return roleKey === 'coach'
? 'Search username or supervisor…'
: 'Search username or location…';
}
function usersByRoleMap() {
const byRole = { admin: [], supervisor: [], coach: [] };
usersList.forEach(u => {
if (byRole[u.role]) byRole[u.role].push(u);
else (byRole.other = byRole.other || []).push(u);
});
return byRole;
}
export async function usersPage() {
callerRole = getRole();
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Users', `
<button class="btn" id="downloadUsersBtn" ${usersList.length ? '' : 'disabled'}>Download CSV</button>
<button class="btn btn-primary" id="showCreateFormBtn">+ Create User</button>
`)}
<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();
}
});
document.getElementById('downloadUsersBtn').addEventListener('click', downloadUsersCSV);
await loadUsers();
}
document.addEventListener('qdb:password-reset', (e) => {
const { userID, mustChangePassword } = e.detail || {};
const u = usersList.find(x => x.userID === userID);
if (u) {
u.mustChangePassword = mustChangePassword;
renderUsers();
}
});
async function loadUsers() {
try {
const data = await apiGet('users.php');
usersList = data.users || [];
supervisorsList = data.supervisors || [];
callerRole = data.callerRole || callerRole;
renderUsers();
const dlBtn = document.getElementById('downloadUsersBtn');
if (dlBtn) dlBtn.disabled = !usersList.length;
} 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 counselors yet.' : 'Create the first user above.'}</p>
</div>
</div>`;
return;
}
const byRole = usersByRoleMap();
const groups = roleGroupsForCaller();
container.innerHTML = groups.map(group => {
const allInRole = byRole[group.key] || [];
if (!allInRole.length) return '';
return `
<div class="card user-role-card" data-role="${group.key}" style="margin-bottom:16px">
<h3 class="user-role-title" style="margin-bottom:12px">
${group.label} (<span data-role-count="${group.key}">0</span>)
</h3>
<div class="filter-bar">
<input type="search" class="filter-search user-role-search" data-role="${group.key}"
placeholder="${searchPlaceholderForRole(group.key)}"
value="${esc(filterSearchByRole[group.key] || '')}">
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Username</th>
${group.key !== 'coach' ? '<th>Location</th>' : '<th>Supervisor</th>'}
<th>Must change password</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="usersBody-${group.key}"></tbody>
</table>
</div>
</div>`;
}).join('');
container.addEventListener('input', (e) => {
const input = e.target.closest('.user-role-search');
if (!input) return;
const roleKey = input.dataset.role;
if (!roleKey) return;
filterSearchByRole[roleKey] = input.value;
updateRoleGroupTable(roleKey, myUsername);
});
groups.forEach(group => {
if ((byRole[group.key] || []).length) {
updateRoleGroupTable(group.key, myUsername);
}
});
}
function filterUsers(users, roleKey) {
let list = users;
const q = (filterSearchByRole[roleKey] || '').trim().toLowerCase();
if (q) {
list = list.filter(u => {
const hay = [u.username, u.location, u.supervisorUsername].filter(Boolean).join(' ').toLowerCase();
return hay.includes(q);
});
}
return list;
}
function updateRoleGroupTable(roleKey, myUsername) {
const tbody = document.getElementById(`usersBody-${roleKey}`);
const countEl = document.querySelector(`[data-role-count="${roleKey}"]`);
if (!tbody) return;
const byRole = usersByRoleMap();
const allInRole = byRole[roleKey] || [];
const users = filterUsers(allInRole, roleKey);
const colSpan = 5;
if (countEl) {
const q = (filterSearchByRole[roleKey] || '').trim();
countEl.textContent = q && users.length !== allInRole.length
? `${users.length} of ${allInRole.length}`
: String(users.length);
}
if (!users.length) {
tbody.innerHTML = `
<tr>
<td colspan="${colSpan}" style="text-align:center;color:var(--text-secondary);padding:24px">
${allInRole.length ? 'No users match' : 'No users'}
</td>
</tr>`;
return;
}
tbody.innerHTML = users.map(u => userRowHTML(u, myUsername)).join('');
tbody.querySelectorAll('.delete-user-btn').forEach(btn => {
btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name));
});
tbody.querySelectorAll('.reset-password-btn').forEach(btn => {
btn.addEventListener('click', () => openAdminResetPasswordModal({
userID: btn.dataset.id,
username: btn.dataset.name,
role: btn.dataset.role || '',
}));
});
tbody.querySelectorAll('.revoke-sessions-btn').forEach(btn => {
btn.addEventListener('click', () => revokeUserSessions(btn.dataset.id, btn.dataset.name));
});
tbody.querySelectorAll('.coach-supervisor-select').forEach(sel => {
sel.addEventListener('change', () => reassignCoachSupervisor(sel));
});
}
function supervisorSelectHTML(u) {
if (!supervisorsList.length) {
return '<span class="data-toolbar-hint">No supervisors</span>';
}
const cur = u.supervisorID || '';
const opts = supervisorsList.map(s => {
const label = s.location ? `${s.username} (${s.location})` : s.username;
const selected = s.supervisorID === cur ? ' selected' : '';
return `<option value="${esc(s.supervisorID)}"${selected}>${esc(label)}</option>`;
}).join('');
return `<select class="coach-supervisor-select" data-user-id="${esc(u.userID)}"
data-username="${esc(u.username)}" data-prev="${esc(cur)}" aria-label="Supervisor for ${esc(u.username)}">
${opts}
</select>`;
}
function userRowHTML(u, myUsername) {
const isSelf = u.username === myUsername;
let detail;
if (u.role === 'coach') {
detail = callerRole === 'admin'
? supervisorSelectHTML(u)
: esc(u.supervisorUsername || '—');
} else {
detail = 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" title="Must choose a new password on next sign-in">Temporary</span>'
: '';
const canDelete = !isSelf && (
callerRole === 'admin' ||
(callerRole === 'supervisor' && u.role === 'coach')
);
const canResetPassword = !isSelf && (
(callerRole === 'admin' && (u.role === 'supervisor' || u.role === 'coach')) ||
(callerRole === 'supervisor' && u.role === 'coach')
);
const canRevokeSessions = !isSelf && (
callerRole === 'admin' ||
(callerRole === 'supervisor' && u.role === 'coach')
);
const actions = [];
if (canResetPassword) {
actions.push(
`<button type="button" class="btn btn-sm reset-password-btn" data-id="${u.userID}" data-name="${esc(u.username)}" data-role="${esc(u.role)}">Reset password</button>`
);
}
if (canRevokeSessions) {
actions.push(
`<button type="button" class="btn btn-sm revoke-sessions-btn" data-id="${u.userID}" data-name="${esc(u.username)}">Sign out everywhere</button>`
);
}
if (canDelete) {
actions.push(
`<button type="button" class="btn btn-sm btn-danger delete-user-btn" data-id="${u.userID}" data-name="${esc(u.username)}">Delete</button>`
);
}
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 class="user-actions-cell">${actions.join(' ')}</td>
</tr>`;
}
async function reassignCoachSupervisor(selectEl) {
const userID = selectEl.dataset.userId;
const username = selectEl.dataset.username || '';
const prev = selectEl.dataset.prev || '';
const supervisorID = selectEl.value;
if (supervisorID === prev) {
return;
}
selectEl.disabled = true;
try {
const data = await apiPut('users.php', { userID, supervisorID });
const u = usersList.find(x => x.userID === userID);
if (u) {
u.supervisorID = data.supervisorID ?? supervisorID;
u.supervisorUsername = data.supervisorUsername ?? '';
}
selectEl.dataset.prev = supervisorID;
showToast(`Counselor "${username}" assigned to ${data.supervisorUsername || 'supervisor'}`, 'success');
} catch (e) {
selectEl.value = prev;
showToast(e.message, 'error');
} finally {
selectEl.disabled = false;
}
}
async function revokeUserSessions(userID, username) {
if (!(await confirmAction({
title: 'Sign out everywhere',
message: `Sign out "${username}" on all devices?\n\nThey must log in again on the website and mobile app.`,
confirmLabel: 'Sign out',
variant: 'danger',
}))) {
return;
}
try {
const data = await apiPut('users.php', { action: 'revokeSessions', userID });
if (!data.success) throw new Error(data.error || 'Failed');
const n = data.revokedSessions ?? 0;
showToast(`Signed out "${username}" (${n} session${n === 1 ? '' : 's'} revoked)`, 'success');
} catch (e) {
showToast(e.message, 'error');
}
}
async function deleteUser(userID, username) {
if (!(await confirmAction({
title: 'Delete user',
message: `Delete user "${username}"? This cannot be undone.`,
confirmLabel: 'Delete',
variant: 'danger',
}))) 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="Counselor" disabled>`
: `<select id="cu_role">
${allowedRoles.map(r =>
`<option value="${r}">${r === 'coach' ? 'Counselor' : 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>`
: `<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>
<p class="modal-hint" style="margin-bottom:14px">
They must choose their own password on first sign-in.
</p>
<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 = 1;
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 counselor');
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 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 === 'coach' ? 'Counselor' : 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) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

View File

@ -0,0 +1,208 @@
import { apiPatch, apiPost, invalidateSessionCheck } from './api.js';
import { getRole, getUser, showToast, formatRoleLabel } from './app.js';
/** @typedef {'admin_reset' | 'self'} PasswordModalMode */
/** @type {{ mode: PasswordModalMode, userID?: string, username?: string, role?: string } | null} */
let modalContext = null;
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}
function roleLabel(role) {
return formatRoleLabel(role);
}
function signInHintForRole(role) {
if (role === 'coach') {
return 'Counselors sign in through the mobile app and will be prompted to choose a new password there.';
}
return 'Supervisors sign in through this website and will be prompted to choose a new password at login.';
}
function ensurePasswordModal() {
let modal = document.getElementById('passwordModal');
if (modal) return modal;
modal = document.createElement('div');
modal.id = 'passwordModal';
modal.className = 'modal-overlay';
modal.hidden = true;
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'passwordModalTitle');
modal.innerHTML = `
<div class="modal-dialog">
<h2 id="passwordModalTitle">Password</h2>
<p class="modal-subtitle" id="passwordModalSubtitle"></p>
<p class="modal-hint" id="passwordModalHint"></p>
<p class="modal-hint" id="passwordModalTempHint" style="display:none">
They sign in with the password below, then must choose their own password before they can continue.
</p>
<div class="form-group" id="passwordModalOldGroup" style="display:none">
<label for="pw_old_password">Current password</label>
<input type="password" id="pw_old_password" autocomplete="current-password" placeholder="Your current password">
</div>
<div class="form-group">
<label for="pw_new_password">New password</label>
<input type="password" id="pw_new_password" autocomplete="new-password" placeholder="At least 6 characters">
</div>
<div class="form-group">
<label for="pw_new_password_confirm">Confirm new password</label>
<input type="password" id="pw_new_password_confirm" autocomplete="new-password" placeholder="Repeat password">
</div>
<p id="passwordModalError" class="error-text" style="display:none"></p>
<div class="modal-actions">
<button type="button" class="btn btn-sm" data-password-cancel>Cancel</button>
<button type="button" class="btn btn-primary btn-sm" data-password-submit>Save</button>
</div>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
if (e.target === modal) closePasswordModal();
});
modal.querySelector('[data-password-cancel]').addEventListener('click', closePasswordModal);
modal.querySelector('[data-password-submit]').addEventListener('click', submitPasswordModal);
modal.querySelector('#pw_new_password_confirm').addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitPasswordModal();
});
document.addEventListener('keydown', (e) => {
const el = document.getElementById('passwordModal');
if (!el || el.hidden) return;
if (e.key === 'Escape') closePasswordModal();
});
return modal;
}
/**
* @param {{ mode: PasswordModalMode, userID?: string, username?: string, role?: string }} options
*/
function openPasswordModal(options) {
modalContext = options;
const modal = ensurePasswordModal();
const isSelf = options.mode === 'self';
const username = options.username || getUser();
const role = options.role || getRole();
modal.querySelector('#passwordModalTitle').textContent = isSelf ? 'Change your password' : 'Reset password';
modal.querySelector('#passwordModalSubtitle').innerHTML = isSelf
? `Choose a new password for <strong>${esc(username)}</strong> (${esc(roleLabel(role))}).`
: `Set a new password for <strong>${esc(username)}</strong> (${esc(roleLabel(role))}).`;
modal.querySelector('#passwordModalHint').textContent = isSelf
? 'You will stay signed in after saving. Use this password on your next login.'
: signInHintForRole(role);
modal.querySelector('#passwordModalTempHint').style.display = isSelf ? 'none' : '';
modal.querySelector('#passwordModalOldGroup').style.display = isSelf ? '' : 'none';
modal.querySelector('#pw_old_password').value = '';
modal.querySelector('#pw_new_password').value = '';
modal.querySelector('#pw_new_password_confirm').value = '';
const errEl = modal.querySelector('#passwordModalError');
errEl.style.display = 'none';
errEl.textContent = '';
modal.querySelector('[data-password-submit]').textContent = isSelf ? 'Change password' : 'Set password';
modal.hidden = false;
document.body.classList.add('modal-open');
(isSelf ? modal.querySelector('#pw_old_password') : modal.querySelector('#pw_new_password')).focus();
}
export function closePasswordModal() {
const modal = document.getElementById('passwordModal');
if (modal) modal.hidden = true;
document.body.classList.remove('modal-open');
modalContext = null;
}
export function openAdminResetPasswordModal({ userID, username, role }) {
openPasswordModal({ mode: 'admin_reset', userID, username, role });
}
export function openChangeOwnPasswordModal() {
openPasswordModal({ mode: 'self' });
}
function showModalError(el, msg) {
el.textContent = msg;
el.style.display = '';
}
async function submitPasswordModal() {
if (!modalContext) return;
const modal = document.getElementById('passwordModal');
const errEl = modal.querySelector('#passwordModalError');
errEl.style.display = 'none';
const newPassword = modal.querySelector('#pw_new_password').value;
const confirm = modal.querySelector('#pw_new_password_confirm').value;
if (newPassword.length < 6) {
showModalError(errEl, 'Password must be at least 6 characters');
return;
}
if (newPassword !== confirm) {
showModalError(errEl, 'Passwords do not match');
return;
}
const submitBtn = modal.querySelector('[data-password-submit]');
submitBtn.disabled = true;
const prevLabel = submitBtn.textContent;
submitBtn.textContent = 'Saving…';
try {
if (modalContext.mode === 'self') {
const oldPassword = modal.querySelector('#pw_old_password').value;
if (!oldPassword) {
showModalError(errEl, 'Current password is required');
return;
}
const data = await apiPost('auth/change-password', {
username: getUser(),
old_password: oldPassword,
new_password: newPassword,
});
if (!data.success) throw new Error(data.error || 'Failed to change password');
if (data.token) localStorage.setItem('qdb_token', data.token);
if (data.user) localStorage.setItem('qdb_user', data.user);
if (data.role) localStorage.setItem('qdb_role', data.role);
invalidateSessionCheck();
closePasswordModal();
showToast('Your password was changed', 'success');
return;
}
const { userID, username } = modalContext;
const data = await apiPatch('users.php', {
userID,
password: newPassword,
mustChangePassword: 1,
});
if (!data.success) throw new Error(data.error || 'Failed to reset password');
closePasswordModal();
showToast(
`Temporary password set for "${username}" — they must change it on next sign-in`,
'success'
);
document.dispatchEvent(new CustomEvent('qdb:password-reset', {
detail: { userID, mustChangePassword: data.mustChangePassword ?? 1 },
}));
} catch (e) {
showModalError(errEl, e.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = prevLabel;
}
}

View File

@ -0,0 +1,555 @@
/**
* All-in-one interactive preview for questionnaire editing.
* All visible questions on one scrollable page; branching updates visibility live.
* Answers stay in memory only — nothing is saved to the server.
*/
const GLASS_LABELS = [
{ key: 'never_glass', label: 'Never' },
{ key: 'little_glass', label: 'A little' },
{ key: 'moderate_glass', label: 'Moderate' },
{ key: 'much_glass', label: 'Much' },
{ key: 'extreme_glass', label: 'Extreme' },
];
/** Sample codes shown in preview (read-only), mimicking the app after load/login. */
const PREVIEW_CLIENT_CODE = 'CLIENT-042';
const PREVIEW_COACH_CODE = 'coach.demo';
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}
function parseConfig(q) {
try { return JSON.parse(q.configJson || '{}'); } catch { return {}; }
}
function localId(q) {
return q.localId || (q.questionID || '').split('__').pop() || '';
}
function questionKey(q) {
const cfg = parseConfig(q);
return (q.questionKey || cfg.questionKey || '').trim();
}
function optionKeyOf(o) {
if (o.optionKey) return o.optionKey;
const dt = (o.defaultText || '').trim();
return /^[a-zA-Z][a-zA-Z0-9_]*$/.test(dt) ? dt : dt;
}
function optionLabel(o) {
return (o.labelGerman || '').trim() || o.defaultText || o.optionKey || '?';
}
function glassScaleOptions(q) {
const opts = q.answerOptions || [];
if (opts.length) {
return opts.map(o => ({ key: optionKeyOf(o), label: optionLabel(o) }));
}
return GLASS_LABELS;
}
function valueSpinnerValues(config) {
const range = config.range || { min: 0, max: 10 };
const min = Number(range.min ?? 0);
const max = Number(range.max ?? 10);
const lo = Math.min(min, max);
const hi = Math.max(min, max);
const values = [];
for (let v = lo; v <= hi; v++) values.push(v);
return values;
}
function hasBranchingOptions(q) {
const layout = q.type || '';
const config = parseConfig(q);
if (layout === 'radio_question') {
return (q.answerOptions || []).some(o => o.nextQuestionId?.trim());
}
if (layout === 'value_spinner') {
const opts = config.valueOptions || config.options || [];
return opts.some(o => o.nextQuestionId?.trim()) || Boolean(config.nextQuestionId?.trim());
}
if (layout === 'string_spinner') {
return Boolean(config.nextQuestionId?.trim() || config.otherNextQuestionId?.trim());
}
if (layout === 'multi_check_box_question') {
return Boolean(config.nextQuestionId?.trim() || config.otherNextQuestionId?.trim());
}
const withQuestionNext = new Set([
'free_text', 'date_spinner', 'slider_question', 'glass_scale_question',
'client_coach_code_question',
]);
return withQuestionNext.has(layout) && Boolean(config.nextQuestionId?.trim());
}
function resolveNextLocalId(q, answer) {
const layout = q.type || '';
const config = parseConfig(q);
if (layout === 'radio_question') {
const key = typeof answer === 'string' ? answer : '';
const opt = (q.answerOptions || []).find(o => optionKeyOf(o) === key);
return opt?.nextQuestionId?.trim() || null;
}
if (layout === 'value_spinner') {
const num = parseInt(String(answer), 10);
const branch = (config.valueOptions || config.options || [])
.find(o => Number(o.value) === num);
if (branch?.nextQuestionId?.trim()) return branch.nextQuestionId.trim();
return config.nextQuestionId?.trim() || null;
}
if (layout === 'string_spinner') {
const val = String(answer ?? '');
const otherKey = config.otherOptionKey?.trim();
const otherNext = config.otherNextQuestionId?.trim();
if (otherKey && otherNext && val === otherKey) return otherNext;
return config.nextQuestionId?.trim() || null;
}
if (layout === 'multi_check_box_question') {
const keys = Array.isArray(answer) ? answer : [];
const otherKey = config.otherOptionKey?.trim();
const otherNext = config.otherNextQuestionId?.trim();
if (otherKey && otherNext && keys.includes(otherKey)) return otherNext;
return config.nextQuestionId?.trim() || null;
}
return config.nextQuestionId?.trim() || null;
}
function readAnswerFromBody(body, q) {
if (!body) return null;
const layout = q.type || '';
const config = parseConfig(q);
switch (layout) {
case 'radio_question': {
const checked = body.querySelector('input[type=radio]:checked');
return checked ? checked.value : null;
}
case 'multi_check_box_question': {
const boxes = body.querySelectorAll('input[type=checkbox]:checked');
return [...boxes].map(el => el.value);
}
case 'value_spinner':
case 'string_spinner': {
const sel = body.querySelector('select.qp-select');
const val = sel?.value ?? '';
return val === '' ? null : val;
}
case 'slider_question': {
const range = body.querySelector('input[type=range]');
return range ? range.value : null;
}
case 'date_spinner': {
const precision = config.precision || 'full';
const year = body.querySelector('[data-part=year]')?.value;
if (!year) return null;
if (precision === 'year') return year;
const month = body.querySelector('[data-part=month]')?.value || '01';
if (precision === 'year_month') return `${year}-${month}`;
const day = body.querySelector('[data-part=day]')?.value || '01';
return `${year}-${month}-${day}`;
}
case 'free_text': {
const text = body.querySelector('input[type=text], textarea')?.value?.trim();
return text || null;
}
case 'glass_scale_question': {
const symptoms = config.symptoms || [];
if (!symptoms.length) return {};
const result = {};
for (const sym of symptoms) {
const picked = body.querySelector(`input[data-symptom="${CSS.escape(sym)}"]:checked`);
if (!picked) return null;
result[sym] = picked.value;
}
return result;
}
case 'client_coach_code_question': {
const client = body.querySelector('[data-field=client]')?.value?.trim();
const coach = body.querySelector('[data-field=coach]')?.value?.trim();
if (!client && !coach) return null;
return { client_code: client || '', coach_code: coach || '' };
}
case 'last_page':
return true;
default:
return null;
}
}
function hasAnswerInUi(q, sectionEl) {
const body = sectionEl?.querySelector('.qp-question-body');
const layout = q.type || '';
const config = parseConfig(q);
const answer = readAnswerFromBody(body, q);
switch (layout) {
case 'radio_question':
return answer !== null;
case 'multi_check_box_question': {
const min = config.minSelection || 0;
const count = Array.isArray(answer) ? answer.length : 0;
return count >= Math.max(min, 0) && (count > 0 || !q.isRequired);
}
case 'value_spinner':
case 'string_spinner':
return answer !== null && answer !== '';
case 'slider_question':
return answer !== null;
case 'date_spinner': {
const year = body?.querySelector('[data-part=year]')?.value;
if (!year) return false;
if (config.precision === 'year') return true;
const month = body?.querySelector('[data-part=month]')?.value;
if (!month) return false;
if (config.precision === 'year_month') return true;
return Boolean(body?.querySelector('[data-part=day]')?.value);
}
case 'free_text':
return Boolean(answer);
case 'glass_scale_question': {
const symptoms = config.symptoms || [];
if (!symptoms.length) return true;
return answer !== null && typeof answer === 'object'
&& symptoms.every(s => answer[s]);
}
case 'client_coach_code_question':
return Boolean(answer?.client_code && answer?.coach_code);
case 'last_page':
return true;
default:
return true;
}
}
function branchAnswerFromSection(q, sectionEl) {
if (hasBranchingOptions(q) && !hasAnswerInUi(q, sectionEl)) return null;
const body = sectionEl?.querySelector('.qp-question-body');
return readAnswerFromBody(body, q);
}
function visibleQuestionIndices(questions, sectionByIdx) {
const visible = new Set();
let i = 0;
while (i < questions.length) {
const q = questions[i];
if (q.type === 'last_page') {
i++;
continue;
}
visible.add(i);
const sectionEl = sectionByIdx.get(i);
const answer = sectionEl ? branchAnswerFromSection(q, sectionEl) : null;
const nextId = answer !== null ? resolveNextLocalId(q, answer) : null;
if (nextId) {
const isLastPageTarget = questions.some(
qq => qq.type === 'last_page' && localId(qq) === nextId,
);
if (isLastPageTarget) break;
const target = questions.findIndex(qq => localId(qq) === nextId);
i = target >= 0 ? target : i + 1;
} else if (hasBranchingOptions(q) && answer === null) {
break;
} else {
i++;
}
}
return visible;
}
function symptomRowLabel(q, symptomKey) {
const rows = q.glassSymptoms || [];
const row = rows.find(r => r.key === symptomKey);
return (row?.labelDe || '').trim() || symptomKey;
}
function renderQuestionBody(q, sectionIdx) {
const layout = q.type || '';
const config = parseConfig(q);
const qText = q.defaultText || questionKey(q) || 'Question';
const prefix = `qp_${sectionIdx}`;
let inner = '';
if (config.noteBefore) inner += `<p class="qp-note">${esc(config.noteBefore)}</p>`;
inner += `<p class="qp-question-text">${esc(qText)}</p>`;
switch (layout) {
case 'radio_question':
inner += `<div class="qp-options">`;
for (const o of (q.answerOptions || [])) {
const key = optionKeyOf(o);
inner += `<label class="qp-option"><input type="radio" name="${prefix}_radio" value="${esc(key)}"> ${esc(optionLabel(o))}</label>`;
}
inner += `</div>`;
break;
case 'multi_check_box_question':
inner += `<div class="qp-options">`;
for (const o of (q.answerOptions || [])) {
const key = optionKeyOf(o);
inner += `<label class="qp-option"><input type="checkbox" value="${esc(key)}"> ${esc(optionLabel(o))}</label>`;
}
if (config.otherOptionKey && config.otherNextQuestionId) {
const ok = config.otherOptionKey;
inner += `<label class="qp-option"><input type="checkbox" value="${esc(ok)}"> ${esc(ok)} (Other)</label>`;
}
inner += `</div>`;
if (config.minSelection) {
inner += `<p class="qp-hint">Select at least ${config.minSelection} option(s).</p>`;
}
break;
case 'value_spinner': {
const values = valueSpinnerValues(config);
inner += `<select class="qp-select"><option value="">— choose —</option>`;
for (const v of values) inner += `<option value="${v}">${v}</option>`;
inner += `</select>`;
break;
}
case 'slider_question': {
const range = config.range || { min: 0, max: 100 };
const min = Number(range.min ?? 0);
const max = Number(range.max ?? 100);
const step = Number(config.step ?? 1) || 1;
inner += `
<div class="qp-slider">
<input type="range" min="${min}" max="${max}" step="${step}" value="${min}">
<output class="qp-slider-value">${min}${config.unitLabel ? ' ' + esc(config.unitLabel) : ''}</output>
</div>`;
break;
}
case 'date_spinner': {
const precision = config.precision || 'full';
const years = [];
const now = new Date().getFullYear();
for (let yr = now + 1; yr >= 1900; yr--) years.push(yr);
inner += `<div class="qp-date-row">`;
if (precision === 'full') {
inner += `<select data-part="day" class="qp-select qp-select-sm"><option value="">Day</option>`;
for (let day = 1; day <= 31; day++) {
const ds = String(day).padStart(2, '0');
inner += `<option value="${ds}">${day}</option>`;
}
inner += `</select>`;
}
if (precision !== 'year') {
inner += `<select data-part="month" class="qp-select qp-select-sm"><option value="">Month</option>`;
for (let mi = 0; mi < 12; mi++) {
const ms = String(mi + 1).padStart(2, '0');
inner += `<option value="${ms}">${MONTHS[mi]}</option>`;
}
inner += `</select>`;
}
inner += `<select data-part="year" class="qp-select qp-select-sm"><option value="">Year</option>`;
for (const yr of years) inner += `<option value="${yr}">${yr}</option>`;
inner += `</select></div>`;
break;
}
case 'string_spinner': {
inner += `<select class="qp-select"><option value="">— choose —</option>`;
for (const opt of (config.options || [])) {
inner += `<option value="${esc(opt)}">${esc(opt)}</option>`;
}
if (config.otherOptionKey && config.otherNextQuestionId) {
const ok = config.otherOptionKey;
inner += `<option value="${esc(ok)}">${esc(ok)} (Other)</option>`;
}
inner += `</select>`;
break;
}
case 'free_text':
inner += `<input type="text" class="qp-text-input" maxlength="${config.maxLength || 500}">`;
break;
case 'glass_scale_question': {
const symptoms = config.symptoms || [];
const scaleOpts = glassScaleOptions(q);
if (!symptoms.length) {
inner += `<p class="qp-hint">Informational screen (no symptom rows).</p>`;
} else {
inner += `<div class="qp-glass-table"><table><thead><tr><th>Symptom</th>`;
for (const so of scaleOpts) inner += `<th>${esc(so.label)}</th>`;
inner += `</tr></thead><tbody>`;
symptoms.forEach((sym, si) => {
inner += `<tr><td>${esc(symptomRowLabel(q, sym))}</td>`;
scaleOpts.forEach(so => {
inner += `<td><input type="radio" name="${prefix}_g_${si}" data-symptom="${esc(sym)}" value="${esc(so.key)}"></td>`;
});
inner += `</tr>`;
});
inner += `</tbody></table></div>`;
}
break;
}
case 'client_coach_code_question':
inner += `
<p class="qp-hint qp-review-note">In the app, client and Counselor codes are loaded automatically — Counselor review them here, not type them in.</p>
<div class="qp-form-stack qp-review-codes">
<label>Client code
<input type="text" class="qp-readonly-field" data-field="client"
value="${esc(PREVIEW_CLIENT_CODE)}" readonly tabindex="-1" aria-readonly="true">
</label>
<label>Counselor code
<input type="text" class="qp-readonly-field" data-field="coach"
value="${esc(PREVIEW_COACH_CODE)}" readonly tabindex="-1" aria-readonly="true">
</label>
</div>`;
break;
case 'last_page':
inner += `<p class="qp-hint">${esc(config.textKey || 'End of questionnaire.')}</p>`;
break;
default:
inner += `<p class="qp-hint">Preview not available for layout <code>${esc(layout)}</code>.</p>`;
}
if (config.noteAfter) inner += `<p class="qp-note qp-note-after">${esc(config.noteAfter)}</p>`;
return inner;
}
function layoutLabel(type) {
const labels = {
radio_question: 'Radio',
multi_check_box_question: 'Checkbox',
glass_scale_question: 'Glass scale',
value_spinner: 'Value spinner',
slider_question: 'Slider',
date_spinner: 'Date',
string_spinner: 'String spinner',
free_text: 'Free text',
client_coach_code_question: 'Client / Counselor code',
last_page: 'Last page',
};
return labels[type] || type || 'Unknown';
}
/**
* @param {HTMLElement} container
* @param {{ questionnaireName?: string, questions: object[] }} opts
*/
export function mountQuestionnairePreview(container, opts) {
const questions = opts.questions || [];
function render() {
if (!questions.length) {
container.innerHTML = `
<div class="qp-empty">
<p>No questions to preview. Add questions on the Questions tab first.</p>
</div>`;
return;
}
const sections = questions.map((q, idx) => {
const lid = localId(q);
const num = idx + 1;
return `
<section class="qp-section" data-idx="${idx}" data-qid="${esc(lid)}" hidden>
<header class="qp-section-header">
<span class="qp-section-num">${num}</span>
<div class="qp-section-meta">
<code class="qp-id">${esc(lid)}</code>
<span class="qp-type">${esc(layoutLabel(q.type))}</span>
${q.isRequired ? '<span class="badge badge-active">Required</span>' : ''}
</div>
</header>
<div class="qp-question-body">
${renderQuestionBody(q, idx)}
</div>
</section>`;
}).join('');
container.innerHTML = `
<div class="qp-shell">
<div class="qp-banner">Preview mode — all questions on one page. Answers are not saved.</div>
<div class="qp-toolbar">
<span class="qp-visible-count" id="qpVisibleCount"></span>
<button type="button" class="btn btn-sm" data-action="restart">Reset all</button>
</div>
<div class="qp-scroll card">
<form class="qp-form" id="qpForm" novalidate>
${sections}
</form>
</div>
</div>`;
wireEvents();
updateVisibility();
}
function sectionElements() {
return [...container.querySelectorAll('.qp-section')];
}
function sectionByIdxMap() {
const map = new Map();
for (const el of sectionElements()) {
map.set(parseInt(el.dataset.idx, 10), el);
}
return map;
}
function updateVisibility() {
const map = sectionByIdxMap();
const visible = visibleQuestionIndices(questions, map);
let shown = 0;
for (const el of sectionElements()) {
const idx = parseInt(el.dataset.idx, 10);
const isVisible = visible.has(idx);
el.hidden = !isVisible;
el.classList.toggle('qp-section-hidden', !isVisible);
if (isVisible) shown++;
}
const counter = container.querySelector('#qpVisibleCount');
if (counter) {
counter.textContent = `Showing ${shown} of ${questions.length} questions`;
}
}
function wireEvents() {
const form = container.querySelector('#qpForm');
if (!form) return;
form.addEventListener('input', () => updateVisibility());
form.addEventListener('change', () => updateVisibility());
form.querySelectorAll('input[type=range]').forEach(slider => {
const output = slider.closest('.qp-slider')?.querySelector('.qp-slider-value');
if (!output) return;
slider.addEventListener('input', () => {
output.textContent = slider.value;
});
});
container.querySelector('[data-action=restart]')?.addEventListener('click', () => {
form.reset();
form.querySelectorAll('input[type=radio], input[type=checkbox]').forEach(el => {
el.checked = false;
});
form.querySelectorAll('.qp-slider-value').forEach((output, i) => {
const slider = form.querySelectorAll('input[type=range]')[i];
if (slider) output.textContent = slider.value;
});
updateVisibility();
});
}
render();
return { restart: () => container.querySelector('[data-action=restart]')?.click() };
}

56
website/js/router.js Normal file
View File

@ -0,0 +1,56 @@
const routes = [];
let currentCleanup = null;
export function addRoute(pattern, handler) {
let regex;
const paramNames = [];
if (pattern instanceof RegExp) {
regex = pattern;
} else {
const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => {
paramNames.push(name);
return '/([^/]+)';
});
regex = new RegExp('^' + parts + '$');
}
routes.push({ regex, paramNames, handler });
}
export function navigate(hash) {
window.location.hash = hash;
}
export function currentHash() {
return window.location.hash.slice(1) || '/';
}
async function resolve() {
if (typeof currentCleanup === 'function') {
currentCleanup();
currentCleanup = null;
}
const path = currentHash();
for (const route of routes) {
const match = path.match(route.regex);
if (match) {
const params = {};
route.paramNames.forEach((name, i) => {
params[name] = decodeURIComponent(match[i + 1]);
});
try {
currentCleanup = await route.handler(params) || null;
} catch (e) {
console.error('Route handler error:', e);
}
return;
}
}
// fallback: home dashboard
navigate('#/');
}
export function startRouter() {
window.addEventListener('hashchange', resolve);
resolve();
}

View File

@ -0,0 +1,52 @@
/**
* Client-side search for .data-table bodies.
* @param {HTMLTableSectionElement} tbody
* @param {string} query
* @param {(row: HTMLTableRowElement) => string} getRowText
* @returns {number} visible row count (excludes placeholder rows)
*/
export function filterTableBodyRows(tbody, query, getRowText) {
if (!tbody) return 0;
const q = query.trim().toLowerCase();
let visible = 0;
tbody.querySelectorAll('tr').forEach(row => {
if (row.dataset.filterPlaceholder === '1') {
row.style.display = q ? 'none' : '';
return;
}
const text = getRowText(row).toLowerCase();
const show = !q || text.includes(q);
row.style.display = show ? '' : 'none';
if (show) visible++;
});
return visible;
}
/**
* @param {HTMLInputElement} input
* @param {HTMLTableSectionElement} tbody
* @param {(row: HTMLTableRowElement) => string} getRowText
* @param {HTMLElement|null} countEl
* @param {{ total: number, noneLabel?: string }} [countOpts]
*/
export function wireTableSearch(input, tbody, getRowText, countEl, countOpts = {}) {
if (!input || !tbody) return;
const update = () => {
const visible = filterTableBodyRows(tbody, input.value, getRowText);
const total = countOpts.total ?? tbody.querySelectorAll('tr:not([data-filter-placeholder])').length;
if (!countEl) return;
const q = input.value.trim();
if (!q) {
countEl.textContent = countOpts.noneLabel
? `${total} ${countOpts.noneLabel}`
: '';
return;
}
const noun = countOpts.noneLabel || 'rows';
countEl.textContent = visible
? `${visible} of ${total} ${noun}`
: `No ${noun} match`;
};
input.addEventListener('input', update);
update();
}

125
website/js/test-data.js Normal file
View File

@ -0,0 +1,125 @@
const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/;
/** Local question id from full questionID (hash__q5 → q5). */
export function questionLocalKey(questionID, fallback = '') {
const id = String(questionID ?? '');
const sep = id.lastIndexOf('__');
if (sep >= 0) return id.slice(sep + 2);
return fallback || id;
}
function parseQuestionConfig(q) {
if (!q || typeof q !== 'object') return {};
let cfg = q.configJson;
if (typeof cfg === 'string') {
try {
cfg = JSON.parse(cfg);
} catch {
cfg = {};
}
}
return cfg && typeof cfg === 'object' ? cfg : {};
}
/** Stable question key for table headers / CSV (matches server qdb_question_column_label). */
export function questionDisplayKey(q) {
if (!q) return '';
if (q.questionKey) return String(q.questionKey);
const cfg = parseQuestionConfig(q);
const fromCfg = String(cfg.questionKey || '').trim();
if (fromCfg && STABLE_KEY_RE.test(fromCfg)) return fromCfg;
const dt = String(q.defaultText || '').trim();
if (dt && STABLE_KEY_RE.test(dt)) return dt;
return questionLocalKey(q.questionID, dt);
}
/** German question text for header tooltips. */
export function questionGermanLabel(q) {
const dt = String(q?.defaultText || '').trim();
const key = questionDisplayKey(q);
if (dt && dt !== key) return dt;
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 ?? '');
if (id === 'client_code') return 'client_code';
const dash = id.indexOf('-');
if (dash < 0) return id;
return id.slice(dash + 1);
}

View File

@ -0,0 +1,285 @@
/** German (de) is the canonical source language for questionnaire content. */
export const SOURCE_LANG = 'de';
export function normalizeTranslations(raw) {
if (!raw) return {};
if (Array.isArray(raw)) {
const map = {};
raw.forEach(row => {
if (row && row.languageCode) map[row.languageCode] = row.text ?? '';
});
return map;
}
return typeof raw === 'object' ? { ...raw } : {};
}
export function normalizeEntry(entry) {
const translations = normalizeTranslations(entry.translations);
const germanText = (entry.germanText || translations[SOURCE_LANG] || entry.key || '').trim()
|| entry.key
|| '';
return { ...entry, translations, germanText };
}
export function germanFor(entry) {
return entry.germanText || normalizeTranslations(entry.translations)[SOURCE_LANG] || entry.key || '';
}
const HEX_ID_RE = /^[a-f0-9]{32}$/i;
/** Key column label (short id or readable preview — not the app lookup key). */
export function entryDisplayKey(entry) {
const dk = entry.displayKey || '';
if (dk && !HEX_ID_RE.test(dk)) return dk;
if (entry.type === 'question' || entry.type === 'answer_option') {
const key = (entry.key || '').trim();
if (key) {
return key.length > 56 ? `${key.slice(0, 56)}` : key;
}
}
if (entry.type === 'question' || entry.type === 'answer_option') {
const id = entry.entityId || '';
const sep = id.lastIndexOf('__');
if (sep >= 0) return id.slice(sep + 2);
}
return dk || entry.key || '';
}
export function translationFor(entry, lang) {
return normalizeTranslations(entry.translations)[lang] || '';
}
export function isEntryMissing(entry, lang) {
return !translationFor(entry, lang).trim();
}
export function countMissing(entries, lang) {
return entries.filter(e => isEntryMissing(e, lang)).length;
}
export function sectionEntries(allEntries, section) {
let entries = allEntries.slice(section.startIdx, section.startIdx + section.entryCount);
if (section.isApp) {
entries = entries.filter(e => e.type === 'app_string');
}
return entries;
}
export function targetLanguages(languages) {
return (languages || []).filter(l => l.languageCode !== SOURCE_LANG);
}
/** Sort rows within a group: missing translations first, then questionnaire order */
export function sortGroupItems(items, targetLang) {
return [...items].sort((a, b) => {
const aMissing = !translationFor(a.entry, targetLang).trim();
const bMissing = !translationFor(b.entry, targetLang).trim();
if (aMissing !== bMissing) return aMissing ? -1 : 1;
const orderA = a.entry.sortOrder ?? 0;
const orderB = b.entry.sortOrder ?? 0;
if (orderA !== orderB) return orderA - orderB;
const typeCmp = (a.entry.type === 'question' ? 0 : 1) - (b.entry.type === 'question' ? 0 : 1);
if (typeCmp !== 0) return typeCmp;
return germanFor(a.entry).localeCompare(germanFor(b.entry), 'de');
});
}
/**
* Groups for one questionnaire: UI strings, then questions + options (interleaved).
* @param {number} indexOffset - global index for save handlers
*/
export function buildTranslationGroups(entries, targetLang, indexOffset = 0) {
const items = entries.map((entry, i) => ({ entry, idx: indexOffset + i }));
const appStrings = items.filter(({ entry }) => entry.type === 'app_string');
const qStrings = items.filter(({ entry }) => entry.type === 'string');
const content = items.filter(({ entry }) =>
entry.type !== 'string' && entry.type !== 'app_string'
);
const groups = [];
if (appStrings.length) {
groups.push({
id: 'app_strings',
title: 'App strings',
items: sortGroupItems(appStrings, targetLang),
});
}
if (qStrings.length) {
groups.push({
id: 'strings',
title: 'UI strings',
items: sortGroupItems(qStrings, targetLang),
});
}
if (content.length) {
groups.push({
id: 'content',
title: 'Questions',
items: sortGroupItems(content, targetLang),
});
}
return groups;
}
/** Render grouped sections (optional questionnaire wrapper title). */
export function renderGroupedTranslationsHTML(groups, targetLang, sourceLabel, editable, options = {}) {
const { showColumnHeader = true, questionnaireTitle = null } = options;
if (!groups.length) return '';
const header = showColumnHeader ? translationListHeaderHTML(
targetLang,
sourceLabel
) : '';
const qnHeader = questionnaireTitle
? `<h2 class="trans-qn-title">${escHtml(questionnaireTitle)}</h2>`
: '';
const body = groups.map(g => `
<section class="trans-group" data-group="${escHtml(g.id)}">
<h3 class="trans-group-title">${escHtml(g.title)}</h3>
<div class="trans-list trans-list-in-group">
${g.items.map(({ entry, idx }) =>
translationRowHTML(entry, idx, { targetLang, editable })
).join('')}
</div>
</section>
`).join('');
const wrapClass = questionnaireTitle ? 'trans-qn-block' : 'trans-groups-wrap';
return `<div class="${wrapClass}">${qnHeader}${header}${body}</div>`;
}
/** Only global app UI rows (never questionnaire questions/options). */
export function filterGlobalAppEntries(entries) {
return (entries || []).filter(e => e && e.type === 'app_string');
}
/** Flatten app strings + questionnaires from ?all=1 */
export function flattenAllQuestionnaires(questionnaires, appStrings = []) {
const allEntries = [];
const sections = [];
const appNorm = filterGlobalAppEntries((appStrings || []).map(normalizeEntry));
if (appNorm.length) {
const startIdx = 0;
appNorm.forEach(e => allEntries.push(e));
sections.push({
questionnaireID: '__app__',
name: 'App UI',
startIdx,
entryCount: appNorm.length,
isApp: true,
});
}
for (const qn of questionnaires) {
const normalized = (qn.entries || []).map(normalizeEntry);
if (!normalized.length) continue;
const startIdx = allEntries.length;
normalized.forEach(e => allEntries.push(e));
sections.push({
questionnaireID: qn.questionnaireID,
name: qn.name || 'Questionnaire',
startIdx,
entryCount: normalized.length,
isApp: false,
});
}
return { allEntries, sections };
}
export function typeBadge(type) {
const map = { question: 'Q', answer_option: 'Opt', string: 'Str', app_string: 'App' };
const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str', app_string: 'badge-app' };
return `<span class="trans-type-badge ${cls[type] || ''}">${map[type] || type}</span>`;
}
export function escHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function sourceLanguageLabel(languages) {
const row = (languages || []).find(l => l.languageCode === SOURCE_LANG);
return row?.name ? `${row.name} (${SOURCE_LANG})` : 'German';
}
/** Column headers: label | main language (German) | target language */
export function translationListHeaderHTML(targetLangLabel, sourceLabel = 'German') {
return `
<div class="trans-list-header trans-list-header-sticky">
<span class="trans-col-key">Label</span>
<span class="trans-col-source">${escHtml(sourceLabel)}</span>
<span class="trans-col-target">${escHtml(targetLangLabel)}</span>
</div>`;
}
export function translationRowHTML(entry, idx, { targetLang, editable = true }) {
const lookupKey = entry.key || '';
const label = entryDisplayKey(entry);
const german = germanFor(entry);
const val = translationFor(entry, targetLang);
const missing = !val.trim();
const disabled = editable ? '' : 'disabled';
const sourceCell = editable
? `<input type="text" class="trans-cell-input" data-idx="${idx}" data-field="german"
value="${escHtml(german)}" placeholder="German text…" autocomplete="off">`
: `<span class="trans-source-text" title="${escHtml(german)}">${escHtml(german)}</span>`;
const germanPreview = german.length > 72 ? `${german.slice(0, 72)}` : german;
return `
<div class="trans-list-item ${missing ? 'trans-list-item-missing' : ''}"
data-idx="${idx}" data-type="${entry.type}" data-key="${escHtml(lookupKey)}" data-label="${escHtml(label)}" data-missing="${missing ? '1' : '0'}">
<div class="trans-list-key">
${typeBadge(entry.type)}
<div class="trans-key-labels">
<span class="trans-key-primary" title="${escHtml(german)}">${escHtml(germanPreview || label)}</span>
<span class="trans-key-secondary" title="${escHtml(lookupKey)}">${escHtml(lookupKey)}</span>
</div>
</div>
<div class="trans-list-source">${sourceCell}</div>
<input type="text" class="trans-cell-input ${missing ? 'trans-input-missing' : ''}"
data-idx="${idx}" data-field="translation" value="${escHtml(val)}"
placeholder="Translation…" ${disabled} autocomplete="off">
</div>`;
}
/**
* Grouped rows with collapsible sections and missing counts in summaries.
*/
export function renderCollapsibleGroupedTranslationsHTML(groups, targetLang, sourceLabel, editable, options = {}) {
const { openGroupsWithMissing = false } = options;
if (!groups.length) return '';
const body = groups.map(g => {
const missing = g.items.filter(({ entry }) => isEntryMissing(entry, targetLang)).length;
const total = g.items.length;
const open = openGroupsWithMissing && missing > 0;
const summary = missing
? `${g.title} · ${missing} missing`
: `${g.title} · complete (${total})`;
return `
<details class="trans-group-details"${open ? ' open' : ''}>
<summary class="trans-group-summary">${escHtml(summary)}</summary>
<div class="trans-group-rows">
${g.items.map(({ entry, idx }) =>
translationRowHTML(entry, idx, { targetLang, editable })
).join('')}
</div>
</details>`;
}).join('');
return `<div class="trans-groups-flat">${body}</div>`;
}