initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
461
website/js/pages/assignments.js
Normal file
461
website/js/pages/assignments.js
Normal 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
938
website/js/pages/clients.js
Normal 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
423
website/js/pages/coaches.js
Normal 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
643
website/js/pages/dev.js
Normal 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 & 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 & 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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<');
|
||||
}
|
||||
2107
website/js/pages/editor.js
Normal file
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
233
website/js/pages/export.js
Normal 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
108
website/js/pages/home.js
Normal 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;
|
||||
}
|
||||
376
website/js/pages/insights.js
Normal file
376
website/js/pages/insights.js
Normal 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: '0–7 days', value: 0 },
|
||||
{ label: '8–30 days', value: 0 },
|
||||
{ label: '31–90 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
268
website/js/pages/login.js
Normal 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ä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…');
|
||||
}
|
||||
});
|
||||
}
|
||||
832
website/js/pages/questionnaires.js
Normal file
832
website/js/pages/questionnaires.js
Normal 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">☰</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
468
website/js/pages/results.js
Normal 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;
|
||||
}
|
||||
707
website/js/pages/translations.js
Normal file
707
website/js/pages/translations.js
Normal 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">×</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
562
website/js/pages/users.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user