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

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