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

3975
website/css/style.css Normal file

File diff suppressed because it is too large Load Diff

99
website/index.html Normal file
View File

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#f3f7f6">
<title>NAT-AS Ressourcenbarometer</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="layout">
<nav class="sidebar" id="mainNav" style="display:none">
<div class="sidebar-brand">
NAT-AS Ressourcenbarometer
<small>Administration</small>
</div>
<ul class="sidebar-nav">
<li data-nav-roles="admin supervisor">
<a href="#/">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Overview
</a>
</li>
<li>
<a href="#/questionnaires">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/></svg>
Session measures
</a>
</li>
<li data-nav-roles="admin supervisor">
<a href="#/users">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
Users
</a>
</li>
<li data-nav-roles="admin supervisor">
<a href="#/coaches">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="8.5" cy="7" r="4"/><polyline points="17 11 19 13 23 9"/></svg>
Counselor activity
</a>
</li>
<li data-nav-roles="admin supervisor">
<a href="#/clients">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Clients
</a>
</li>
<li data-nav-roles="admin supervisor">
<a href="#/assignments">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>
Assign Clients
</a>
</li>
<li data-nav-roles="admin supervisor">
<a href="#/insights">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
Insights
</a>
</li>
<li data-nav-roles="admin supervisor">
<a href="#/translations">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
Translations
</a>
</li>
<li>
<a href="#/export">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export Data
</a>
</li>
<li data-nav-roles="admin">
<a href="#/dev">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>
Admin Settings
</a>
</li>
</ul>
<div class="sidebar-footer">
<div class="user-info" id="userInfo"></div>
<button type="button" id="changePasswordBtn" class="btn btn-sm" style="width:100%;justify-content:center;margin-bottom:8px;">Change password</button>
<a href="#" id="logoutBtn" class="btn btn-sm" style="width:100%;justify-content:center;">Log out</a>
</div>
</nav>
<main class="main-content" id="app">
<!-- Pages render here -->
</main>
</div>
<div id="toastContainer"></div>
<button type="button" id="mobileChangePasswordBtn" class="btn btn-sm mobile-change-password-btn" aria-label="Change password">Password</button>
<button type="button" id="mobileLogoutBtn" class="btn btn-sm mobile-logout-btn" aria-label="Log out">Log out</button>
<script type="module" src="js/app.js"></script>
</body>
</html>

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>`;
}

Binary file not shown.

BIN
website/media/uni-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -0,0 +1,158 @@
{
"meta": {
"_comment": "Diese Zeiel erklärt im folgenden wie die einzelnen Fragetypen definiert werden können und was für features diese mit sich bringen.",
"id": "questionnaire_0_readme"
},
"questions": [
{
"id": "q1",
"_comment": "Frage mit Radio Buttons. In diesem Fragetypen kann man maximal eine Antwort wählen. Über nextQuestionId kann man die nächste Frage definieren., Über pointsMap kann man die Punkte für die Antwort der Frage festlegen.",
"layout": "radio_question",
"question": "consent_instruction",
"options": [
{ "key": "consent_signed"},
{ "key": "consent_not_signed",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q2",
"_comment": "Client- und Coach-Code (z. B. wenn Einwilligung nicht unterschrieben).",
"layout": "client_coach_code_question",
"question": "no_consent_entered",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q3",
"_comment": "Frage mit Spinner, dessen Antwortmöglichkeiten über options selbst definiert werden können.",
"layout": "string_spinner",
"question": "other_accommodation",
"options": ["Unterkunft 1", "Unterkunft 2", "Unterkunft 3"]
},
{
"id": "q3b",
"_comment": "Freitext-Eingabe (einzeilig oder mehrzeilig). maxLength und hint sind optional. Eingaben werden gegen SQL-Injection-Muster gefiltert.",
"layout": "free_text",
"question": "other_accommodation",
"hint": "enter_text_to_continue",
"maxLength": 500,
"multiline": true
},
{
"id": "q4",
"_comment": "Datumsfrage. precision: full (YYYY-MM-DD), year_month (YYYY-MM), year (YYYY).",
"layout": "date_spinner",
"question": "departure_country",
"precision": "year_month"
},
{
"id": "q5",
"_comment": "Datumsfrage mit constraints. precision steuert Tag/Monat/Jahr-Spinner.",
"layout": "date_spinner",
"question": "since_in_germany",
"precision": "year_month",
"constraints": {
"notBefore": "departure_country",
"notAfter": "departure_country"
}
},
{
"id": "q5b",
"_comment": "Glas-Skala (Symptome als Zeilen, 5 Stufen). scaleType: glass (Standard) oder thermometer.",
"layout": "glass_scale_question",
"question": "example_symptom_scale",
"scaleType": "thermometer",
"symptoms": ["symptom_a", "symptom_b"]
},
{
"id": "q6",
"_comment": "Frage mit Spinner, dessen Antwortmöglichkeiten über range selbst definiert werden können.",
"layout": "value_spinner",
"question": "number_family_members",
"range": {
"min": 1,
"max": 15
}
},
{
"id": "q7",
"_comment": "Frage nach dem Code des Klienten und den Code des Coaches.",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q8",
"_comment": "Frage mit Checkboxen, dessen Antwortmöglichkeiten über options selbst definiert werden können. Über pointsMap können die Punkte für die jeweiligen Antworten festgelegt werden. Über minSelection kann die minimal Anzahl an auszufüllenden Antworten festgelegt werden.",
"layout": "multi_check_box_question",
"question": "languages_spoken",
"options": [
{ "key": "language_albanian" },
{ "key": "language_pashto" },
{ "key": "language_russian" },
{ "key": "language_other"}
],
"pointsMap": {
"language_albanian": 1,
"language_pashto": 1,
"language_russian": 1,
"language_other": 1
},
"minSelection": 1
},
{
"id": "q9",
"_comment":"Glass scale frage, bei der es für jede Frage, welche über symptoms festgelegt werden kann eine Antwort festgelegt werden muss. Die Antwort ganz links gibt immer 0 Punkte und die Antwort ganz rechts gibt immer 4 Punkte.",
"layout": "glass_scale_question",
"question": "glass_explanation",
"symptoms": [
"q1_symptom",
"q2_symptom",
"q3_symptom",
"q4_symptom",
"q5_symptom",
"q6_symptom",
"q7_symptom",
"q8_symptom",
"q9_symptom"
]
},
{
"id": "last_page",
"_comment":"Letzte Seite, welche den Abschluss des Fragebogens markiert.",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,306 @@
{
"meta": {
"id": "questionnaire_1_demographic_information"
},
"questions": [
{
"id": "q1",
"layout": "radio_question",
"question": "consent_instruction",
"options": [
{ "key": "consent_signed",
"nextQuestionId": "q6"},
{ "key": "consent_not_signed",
"nextQuestionId": "q2"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q2",
"layout": "client_coach_code_question",
"question": "no_consent_entered",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
},
{
"id": "q28",
"layout": "radio_question",
"question": "accommodation",
"options": [
{ "key": "steinstrasse",
"nextQuestionId": "last_page"},
{ "key": "doerfle",
"nextQuestionId": "last_page"},
{ "key": "zoll_emmishofer",
"nextQuestionId": "last_page"},
{ "key": "other",
"nextQuestionId": "q4"}
]
},
{
"id": "q4",
"layout": "string_spinner",
"question": "other_accommodation",
"options": ["Unterkunft 1", "Unterkunft 2", "Unterkunft 3"]
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
},
{
"id": "q6",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q29",
"layout": "radio_question",
"question": "accommodation",
"options": [
{ "key": "steinstrasse",
"nextQuestionId": "q9"},
{ "key": "doerfle",
"nextQuestionId": "q9"},
{ "key": "zoll_emmishofer",
"nextQuestionId": "q9"},
{ "key": "other",
"nextQuestionId": "q8"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q8",
"layout": "string_spinner",
"question": "other_accommodation",
"options": ["Unterkunft 1", "Unterkunft 2", "Unterkunft 3"]
},
{
"id": "q9",
"layout": "value_spinner",
"question": "age",
"range": {
"min": 18,
"max": 122
}
},
{
"id": "q10",
"layout": "radio_question",
"question": "gender",
"options": [
{ "key": "gender_male"},
{ "key": "gender_female"},
{ "key": "gender_diverse"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q11",
"layout": "string_spinner",
"question": "country_of_origin"
},
{
"id": "q13",
"layout": "date_spinner",
"question": "departure_country",
"precision": "year_month"
},
{
"id": "q14",
"layout": "date_spinner",
"question": "since_in_germany",
"precision": "year_month",
"constraints": {
"notBefore": "departure_country"
}
},
{
"id": "q16",
"layout": "radio_question",
"question": "living_situation",
"options": [
{ "key": "alone",
"nextQuestionId": "q18"},
{ "key": "with_family",
"nextQuestionId": "q17"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q17",
"layout": "value_spinner",
"question": "number_family_members",
"range": {
"min": 1,
"max": 15
}
},
{
"id": "q18",
"layout": "multi_check_box_question",
"question": "languages_spoken",
"options": [
{ "key": "language_albanian" },
{ "key": "language_pashto" },
{ "key": "language_russian" },
{ "key": "language_serbo" },
{ "key": "language_somali" },
{ "key": "language_tamil" },
{ "key": "language_tigrinya" },
{ "key": "language_turkish" },
{ "key": "language_ukrainian" },
{ "key": "language_urdu" },
{ "key": "language_arabic" },
{ "key": "language_dari_farsi" },
{ "key": "language_chinese" },
{ "key": "language_english" },
{ "key": "language_macedonian" },
{ "key": "language_kurmanji" },
{ "key": "language_hindi" },
{ "key": "language_french"},
{ "key": "language_other"}
],
"pointsMap": {
"language_albanian": 0,
"language_pashto": 0,
"language_russian": 0,
"language_serbo": 0,
"language_somali": 0,
"language_tamil": 0,
"language_tigrinya": 0,
"language_turkish": 0,
"language_ukrainian": 0,
"language_urdu": 0,
"language_arabic": 0,
"language_dari_farsi": 0,
"language_chinese": 0,
"language_english": 0,
"language_macedonian": 0,
"language_kurmanji": 0,
"language_hindi": 0,
"language_french": 0,
"language_other": 0
},
"minSelection": 1
},
{
"id": "q19",
"layout": "radio_question",
"question": "german_skills",
"options": [
{ "key": "skill_none"},
{ "key": "skill_basic"},
{ "key": "skill_a1"},
{ "key": "skill_a2"},
{ "key": "skill_b1"},
{ "key": "skill_b2"},
{ "key": "skill_c1"},
{ "key": "skill_c2"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q30",
"layout": "value_spinner",
"question": "school_years_total",
"range": {
"min": 0,
"max": 15
},
"options": [
{ "value": 0, "nextQuestionId": "q23" }
]
},
{
"id": "q20",
"layout": "value_spinner",
"question": "school_years_origin",
"range": {
"min": 0,
"max": 15
}
},
{
"id": "q21",
"layout": "value_spinner",
"question": "school_years_transit",
"range": {
"min": 0,
"max": 15
}
},
{
"id": "q22",
"layout": "value_spinner",
"question": "school_years_germany",
"range": {
"min": 0,
"max": 15
}
},
{
"id": "q23",
"layout": "radio_question",
"question": "vocational_training",
"options": [
{ "key": "answer_no"},
{ "key": "answer_started"},
{ "key": "answer_completed"}
],
"pointsMap": {
"consent_signed": 0,
"consent_not_signed": 0
}
},
{
"id": "q24",
"layout": "date_spinner",
"question": "provisional_accommodation_since",
"precision": "year_month",
"constraints": {
"notBefore": "since_in_germany"
}
},
{
"id": "q25",
"layout": "date_spinner",
"question": "asylum_procedure_since",
"precision": "year_month",
"constraints": {
"notBefore": "since_in_germany"
}
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,137 @@
{
"meta": {
"id": "questionnaire_2_rhs"
},
"questions": [
{
"id": "q0",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q3",
"layout": "glass_scale_question",
"question": "glass_explanation",
"symptoms": [
"q1_symptom",
"q2_symptom",
"q3_symptom",
"q4_symptom",
"q5_symptom",
"q6_symptom",
"q7_symptom",
"q8_symptom",
"q9_symptom"
]
},
{
"id": "q4",
"layout": "glass_scale_question",
"question": "how_strong_past_month",
"symptoms": [
"q10_reexperience_trauma",
"q11_physical_reaction",
"q12_emotional_numbness",
"q13_easily_startled"
]
},
{
"id": "q5",
"layout": "radio_question",
"textKey": "resilience_reflection_prompt",
"question": "q14_intro",
"options": [
{ "key": "resilience_fully_capable" },
{ "key": "resilience_mostly_capable" },
{ "key": "resilience_some_capable_some_not" },
{ "key": "resilience_mostly_not_capable" },
{ "key": "resilience_not_capable" }
],
"pointsMap": {
"resilience_fully_capable": 0,
"resilience_mostly_capable": 1,
"resilience_some_capable_some_not": 2,
"resilience_mostly_not_capable": 3,
"resilience_not_capable": 4
}
},
{
"id": "q6",
"layout": "value_spinner",
"question": "pain_rating_instruction",
"range": {
"min": 0,
"max": 10
}
},
{
"id": "q7",
"layout": "radio_question",
"question": "violence_question_1",
"options": [
{ "key": "no",
"nextQuestionId": "q10"},
{ "key": "yes" }
]
},
{
"id": "q8",
"layout": "radio_question",
"question": "times_happend",
"options": [
{ "key": "once" },
{ "key": "multiple_times" }
]
},
{
"id": "q9",
"layout": "value_spinner",
"question": "age_at_incident",
"range": {
"min": 1,
"max": 122
}
},
{
"id": "q10",
"layout": "radio_question",
"question": "conflict_since_arrival",
"options": [
{ "key": "no",
"nextQuestionId": "last_page"},
{ "key": "yes" }
]
},
{
"id": "q11",
"layout": "radio_question",
"question": "times_happend2",
"options": [
{ "key": "once" },
{ "key": "multiple_times" }
]
},
{
"id": "q12",
"layout": "value_spinner",
"question": "age_at_incident2",
"range": {
"min": 1,
"max": 122
}
},
{
"id": "q25",
"layout": "date_spinner",
"question": "asylum_procedure_since"
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,255 @@
{
"meta": {
"id": "questionnaire_3_integration_index"
},
"questions": [
{
"id": "q0",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q1",
"layout": "radio_question",
"textKey": "intro_life_in_germany",
"question": "feeling_connected_to_germany_question",
"options": [
{ "key": "very_connected" },
{ "key": "quite_connected" },
{ "key": "moderately_connected" },
{ "key": "somewhat_loose" },
{ "key": "not_connected_at_all" }
],
"pointsMap": {
"very_connected": 3,
"quite_connected": 4,
"moderately_connected": 5,
"somewhat_loose": 1,
"not_connected_at_all": 2
}
},
{
"id": "q2",
"layout": "radio_question",
"question": "understanding_political_issues",
"options": [
{ "key": "very_good" },
{ "key": "good" },
{ "key": "fairly_good" },
{ "key": "somewhat" },
{ "key": "not_at_all" }
],
"pointsMap": {
"very_good": 1,
"good": 2,
"fairly_good": 3,
"somewhat": 4,
"not_at_all": 5
}
},
{
"id": "q3",
"layout": "radio_question",
"question": "unexpected_expense_question",
"options": [
{ "key": "expense_50" },
{ "key": "expense_100" },
{ "key": "expense_300" },
{ "key": "expense_500" },
{ "key": "expense_800" }
],
"pointsMap": {
"expense_50": 1,
"expense_100": 2,
"expense_300": 3,
"expense_500": 4,
"expense_800": 5
}
},
{
"id": "q4",
"layout": "radio_question",
"question": "dining_with_germans_question",
"options": [
{ "key": "never" },
{ "key": "once_a_year" },
{ "key": "once_a_month" },
{ "key": "once_a_week" },
{ "key": "almost_every_day" }
],
"pointsMap": {
"never": 1,
"once_a_year": 2,
"once_a_month": 3,
"once_a_week": 4,
"almost_every_day": 5
}
},
{
"id": "q5",
"layout": "radio_question",
"question": "reading_german_articles_question",
"options": [
{ "key": "very_good" },
{ "key": "good" },
{ "key": "moderately_good" },
{ "key": "not_good" },
{ "key": "not_at_all" }
],
"pointsMap": {
"very_good": 1,
"good": 2,
"moderately_good": 3,
"not_good": 4,
"not_at_all": 5
}
},
{
"id": "q6",
"layout": "radio_question",
"question": "visiting_doctor_question",
"options": [
{ "key": "very_difficult" },
{ "key": "rather_difficult" },
{ "key": "neither_nor" },
{ "key": "rather_easy" },
{ "key": "very_easy" }
],
"pointsMap": {
"very_difficult": 1,
"rather_difficult": 2,
"neither_nor": 3,
"rather_easy": 4,
"very_easy": 5
}
},
{
"id": "q7",
"layout": "radio_question",
"question": "feeling_as_outsider_question",
"options": [
{ "key": "never" },
{ "key": "rarely" },
{ "key": "sometimes" },
{ "key": "often" },
{ "key": "always" }
],
"pointsMap": {
"never": 1,
"rarely": 2,
"sometimes": 3,
"often": 4,
"always": 5
}
},
{
"id": "q8",
"layout": "radio_question",
"question": "recent_occupation_question",
"options": [
{ "key": "employed" },
{ "key": "education" },
{ "key": "internship" },
{ "key": "unemployed_searching" },
{ "key": "unemployed_not_searching" },
{ "key": "sick_or_disabled" },
{ "key": "unpaid_housework" },
{ "key": "other_activity" }
],
"pointsMap": {
"employed": 1,
"education": 2,
"internship": 3,
"unemployed_searching": 4,
"unemployed_not_searching": 8,
"sick_or_disabled": 5,
"unpaid_housework": 6,
"other_activity": 7
}
},
{
"id": "q9",
"layout": "radio_question",
"question": "discussing_politics_question",
"options": [
{ "key": "never" },
{ "key": "once_a_year" },
{ "key": "once_a_month" },
{ "key": "once_a_week" },
{ "key": "almost_every_day" }
],
"pointsMap": {
"never": 1,
"once_a_year": 2,
"once_a_month": 3,
"once_a_week": 4,
"almost_every_day": 5
}
},
{
"id": "q10",
"layout": "radio_question",
"question": "contact_with_germans_question",
"options": [
{ "key": "zero" },
{ "key": "one_to_three" },
{ "key": "three_to_six" },
{ "key": "seven_to_fourteen" },
{ "key": "fifteen_or_more" }
],
"pointsMap": {
"zero": 1,
"one_to_three": 2,
"three_to_six": 3,
"seven_to_fourteen": 4,
"fifteen_or_more": 5
}
},
{
"id": "q11",
"layout": "radio_question",
"question": "speaking_german_opinion_question",
"options": [
{ "key": "very_good" },
{ "key": "good" },
{ "key": "moderately_good" },
{ "key": "not_good" },
{ "key": "not_at_all" }
],
"pointsMap": {
"very_good": 1,
"good": 2,
"moderately_good": 3,
"not_good": 4,
"not_at_all": 5
}
},
{
"id": "q12",
"layout": "radio_question",
"question": "job_search_question",
"options": [
{ "key": "very_difficult" },
{ "key": "rather_difficult" },
{ "key": "neither_nor" },
{ "key": "rather_easy" },
{ "key": "very_easy" }
],
"pointsMap": {
"very_difficult": 1,
"rather_difficult": 2,
"neither_nor": 3,
"rather_easy": 4,
"very_easy": 5
}
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,182 @@
{
"meta": {
"id": "questionnaire_4_consultation_results"
},
"questions": [
{
"id": "q0",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q1",
"layout": "date_spinner",
"question": "date_consultation_health_interview_result"
},
{
"id": "q2",
"layout": "radio_question",
"question": "consultation_decision",
"options": [
{
"key": "green",
"nextQuestionId": "q3"},
{"key": "yellow",
"nextQuestionId": "q4"},
{ "key": "red",
"nextQuestionId": "q9"}
],
"pointsMap": {
"green": 0,
"yellow": 0,
"red": 0
}
},
{
"id": "q3",
"layout": "radio_question",
"question": "consent_conversation_in_6_months",
"options": [
{ "key": "yes",
"nextQuestionId": "last_page"},
{ "key": "no",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q4",
"layout": "radio_question",
"question": "participation_in_coaching",
"options": [
{"key": "yes",
"nextQuestionId": "q5"},
{"key": "no",
"nextQuestionId": "q6"},
{"key": "time_to_think_about_it",
"nextQuestionId": "q7"}
],
"pointsMap": {
"yes": 0,
"no": 0,
"time_to_think_about_it": 0
}
},
{
"id": "q5",
"layout": "radio_question",
"question": "consent_coaching_given",
"options": [
{"key": "consent_yes",
"nextQuestionId": "q3"},
{"key": "consent_no",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"consent_yes": 0,
"consent_no": 0
}
},
{
"id": "q6",
"layout": "radio_question",
"question": "consent_conversation_in_6_months",
"options": [
{ "key": "yes",
"nextQuestionId": "last_page"},
{ "key": "no",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q7",
"layout": "date_spinner",
"question": "decision_after_reflection_period"
},
{
"id": "q8",
"layout": "radio_question",
"question": "consent_conversation_in_6_months",
"options": [
{ "key": "yes",
"nextQuestionId": "last_page"},
{ "key": "no",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q9",
"layout": "radio_question",
"question": "professional_referral",
"options": [
{ "key": "yes"},
{ "key": "no"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q10",
"layout": "radio_question",
"question": "confidentiality_agreement" ,
"options": [
{ "key": "available_yes"},
{ "key": "available_no",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q11",
"layout": "radio_question",
"question": "health_insurance_card",
"options": [
{ "key": "yes"},
{ "key": "no"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q12",
"layout": "radio_question",
"question": "consent_conversation_in_6_months",
"options": [
{ "key": "yes",
"nextQuestionId": "last_page"},
{ "key": "no",
"nextQuestionId": "last_page"}
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,84 @@
{
"meta": {
"id": "questionnaire_5_final_interview"
},
"questions": [
{
"id": "q0",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q1",
"layout": "radio_question",
"question": "consent_followup_6_months",
"options": [
{ "key": "yes" },
{ "key": "no" }
]
},
{
"id": "q2",
"layout": "date_spinner",
"question": "date_final_interview"
},
{
"id": "q3",
"layout": "value_spinner",
"question": "amount_nat_appointments",
"range": {
"min": 0,
"max": 20
}
},
{
"id": "q4",
"layout": "value_spinner",
"question": "amount_session_flowers",
"range": {
"min": 0,
"max": 20
}
},
{
"id": "q5",
"layout": "value_spinner",
"question": "amount_session_stones",
"range": {
"min": 0,
"max": 20
}
},
{
"id": "q6",
"layout": "radio_question",
"question": "termination_nat_coaching",
"options": [
{"key": "termination_nat_regular",
"nextQuestionId": "last_page"},
{"key": "termination_nat_referral",
"nextQuestionId": "last_page"},
{"key": "termination_nat_dropout",
"nextQuestionId": "q7"}
]
},
{
"id": "q7",
"layout": "radio_question",
"question": "client_canceled_NAT",
"options": [
{"key": "after_meeting"},
{"key": "without_giving_reasons"},
{"key": "with_reasons_given"}
]
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,349 @@
{
"meta": {
"id": "questionnaire_6_follow_up_survey"
},
"questions": [
{
"id": "q0",
"layout": "client_coach_code_question",
"question": "client_code_entry_question",
"hint1": "client_code",
"hint2": "coach_code"
},
{
"id": "q1",
"layout": "radio_question",
"question": "follow_up",
"options": [
{
"key": "follow_up_completed",
"nextQuestionId": "q2"
},
{
"key": "follow_up_failed_contact",
"nextQuestionId": "last_page"
},
{
"key": "follow_up_consent_withdrawn",
"nextQuestionId": "last_page"
}
],
"pointsMap": {
"follow_up_completed": 0,
"follow_up_failed_contact": 0,
"follow_up_consent_withdrawn": 0
}
},
{
"id": "q2",
"layout": "radio_question",
"question": "special_burden_question",
"options": [
{ "key": "yes" },
{ "key": "no" }
],
"pointsMap": {
"yes": 0,
"no": 0
}
},
{
"id": "q3",
"layout": "glass_scale_question",
"question": "glass_explanation",
"symptoms": [
"q1_symptom",
"q2_symptom",
"q3_symptom",
"q4_symptom",
"q5_symptom",
"q6_symptom",
"q7_symptom",
"q8_symptom",
"q9_symptom"
]
},
{
"id": "q4",
"layout": "glass_scale_question",
"question": "how_strong_past_month",
"symptoms": [
"q10_reexperience_trauma",
"q11_physical_reaction",
"q12_emotional_numbness",
"q13_easily_startled"
]
},
{
"id": "q5",
"layout": "radio_question",
"textKey": "resilience_reflection_prompt",
"question": "q14_intro",
"options": [
{ "key": "resilience_fully_capable" },
{ "key": "resilience_mostly_capable" },
{ "key": "resilience_some_capable_some_not" },
{ "key": "resilience_mostly_not_capable" },
{ "key": "resilience_not_capable" }
],
"pointsMap": {
"resilience_fully_capable": 0,
"resilience_mostly_capable": 0,
"resilience_some_capable_some_not": 0,
"resilience_mostly_not_capable": 0,
"resilience_not_capable": 0
}
},
{
"id": "q6",
"layout": "value_spinner",
"question": "pain_rating_instruction",
"range": {
"min": 0,
"max": 10
}
},
{
"id": "q7",
"layout": "radio_question",
"textKey": "intro_life_in_germany",
"question": "feeling_connected_to_germany_question",
"options": [
{ "key": "very_connected" },
{ "key": "quite_connected" },
{ "key": "moderately_connected" },
{ "key": "somewhat_loose" },
{ "key": "not_connected_at_all" }
],
"pointsMap": {
"very_connected": 0,
"quite_connected": 0,
"moderately_connected": 0,
"somewhat_loose": 0,
"not_connected_at_all": 0
}
},
{
"id": "q8",
"layout": "radio_question",
"question": "understanding_political_issues",
"options": [
{ "key": "very_good" },
{ "key": "good" },
{ "key": "fairly_good" },
{ "key": "somewhat" },
{ "key": "not_at_all" }
],
"pointsMap": {
"very_good": 0,
"good": 0,
"fairly_good": 0,
"somewhat": 0,
"not_at_all": 0
}
},
{
"id": "q9",
"layout": "radio_question",
"question": "unexpected_expense_question",
"options": [
{ "key": "expense_50" },
{ "key": "expense_100" },
{ "key": "expense_300" },
{ "key": "expense_500" },
{ "key": "expense_800" }
],
"pointsMap": {
"expense_50": 0,
"expense_100": 0,
"expense_300": 0,
"expense_500": 0,
"expense_800": 0
}
},
{
"id": "q10",
"layout": "radio_question",
"question": "dining_with_germans_question",
"options": [
{ "key": "never" },
{ "key": "once_a_year" },
{ "key": "once_a_month" },
{ "key": "once_a_week" },
{ "key": "almost_every_day" }
],
"pointsMap": {
"never": 0,
"once_a_year": 0,
"once_a_month": 0,
"once_a_week": 0,
"almost_every_day": 0
}
},
{
"id": "q11",
"layout": "radio_question",
"question": "reading_german_articles_question",
"options": [
{ "key": "very_good" },
{ "key": "good" },
{ "key": "moderately_good" },
{ "key": "not_good" },
{ "key": "not_at_all" }
],
"pointsMap": {
"very_good": 0,
"good": 0,
"moderately_good": 0,
"not_good": 0,
"not_at_all": 0
}
},
{
"id": "q12",
"layout": "radio_question",
"question": "visiting_doctor_question",
"options": [
{ "key": "very_difficult" },
{ "key": "rather_difficult" },
{ "key": "neither_nor" },
{ "key": "rather_easy" },
{ "key": "very_easy" }
],
"pointsMap": {
"very_difficult": 0,
"rather_difficult": 0,
"neither_nor": 0,
"rather_easy": 0,
"very_easy": 0
}
},
{
"id": "q13",
"layout": "radio_question",
"question": "feeling_as_outsider_question",
"options": [
{ "key": "never" },
{ "key": "rarely" },
{ "key": "sometimes" },
{ "key": "often" },
{ "key": "always" }
],
"pointsMap": {
"never": 0,
"rarely": 0,
"sometimes": 0,
"often": 0,
"always": 0
}
},
{
"id": "q14",
"layout": "radio_question",
"question": "recent_occupation_question",
"options": [
{ "key": "employed" },
{ "key": "education" },
{ "key": "internship" },
{ "key": "unemployed_searching" },
{ "key": "unemployed_not_searching" },
{ "key": "sick_or_disabled" },
{ "key": "unpaid_housework" },
{ "key": "other_activity" }
],
"pointsMap": {
"employed": 0,
"education": 0,
"internship": 0,
"unemployed_searching": 0,
"unemployed_not_searching": 0,
"sick_or_disabled": 0,
"unpaid_housework": 0,
"other_activity": 0
}
},
{
"id": "q15",
"layout": "radio_question",
"question": "discussing_politics_question",
"options": [
{ "key": "never" },
{ "key": "once_a_year" },
{ "key": "once_a_month" },
{ "key": "once_a_week" },
{ "key": "almost_every_day" }
],
"pointsMap": {
"never": 0,
"once_a_year": 0,
"once_a_month": 0,
"once_a_week": 0,
"almost_every_day": 0
}
},
{
"id": "q16",
"layout": "radio_question",
"question": "contact_with_germans_question",
"options": [
{ "key": "zero" },
{ "key": "one_to_three" },
{ "key": "three_to_six" },
{ "key": "seven_to_fourteen" },
{ "key": "fifteen_or_more" }
],
"pointsMap": {
"zero": 0,
"one_to_three": 0,
"three_to_six": 0,
"seven_to_fourteen": 0,
"fifteen_or_more": 0
}
},
{
"id": "q17",
"layout": "radio_question",
"question": "speaking_german_opinion_question",
"options": [
{ "key": "very_good" },
{ "key": "good" },
{ "key": "fairly_good" },
{ "key": "not_good" },
{ "key": "not_at_all" }
],
"pointsMap": {
"very_good": 0,
"good": 0,
"fairly_good": 0,
"not_good": 0,
"not_at_all": 0
}
},
{
"id": "q18",
"layout": "radio_question",
"question": "job_search_question",
"options": [
{ "key": "very_difficult" },
{ "key": "rather_difficult" },
{ "key": "neither_nor" },
{ "key": "rather_easy" },
{ "key": "very_easy" }
],
"pointsMap": {
"very_difficult": 0,
"rather_difficult": 0,
"neither_nor": 0,
"rather_easy": 0,
"very_easy": 0
}
},
{
"id": "last_page",
"layout": "last_page",
"textKey": "finish_data_entry",
"question": "data_final_warning"
}
]
}

View File

@ -0,0 +1,67 @@
[
{
"file": "questionnaire_1_demographic_information.json",
"showPoints": false,
"condition": {
"alwaysAvailable": true
}
},
{
"file": "questionnaire_2_rhs.json",
"showPoints": true,
"condition": {
"alwaysAvailable": true
}
},
{
"file": "questionnaire_3_integration_index.json",
"showPoints": true,
"condition": {
"alwaysAvailable": true
}
},
{
"file": "questionnaire_4_consultation_results.json",
"showPoints": false,
"condition": {
"requiresCompleted": [
"questionnaire_1_demographic_information",
"questionnaire_2_rhs",
"questionnaire_3_integration_index"
],
"questionnaire": "questionnaire_1_demographic_information",
"questionId": "consent_instruction",
"operator": "==",
"value": "consent_signed"
}
},
{
"file": "questionnaire_5_final_interview.json",
"showPoints": false,
"condition": {
"requiresCompleted": ["questionnaire_4_consultation_results"],
"questionnaire": "questionnaire_4_consultation_results",
"questionId": "consultation_decision",
"operator": "==",
"value": "yellow"
}
},
{
"file": "questionnaire_6_follow_up_survey.json",
"showPoints": true,
"condition": {
"anyOf": [
{
"requiresCompleted": ["questionnaire_5_final_interview"]
},
{
"requiresCompleted": ["questionnaire_4_consultation_results"],
"questionnaire": "questionnaire_4_consultation_results",
"questionId": "consultation_decision",
"operator": "!=",
"value": "yellow"
}
]
}
}
]