enhanced navigation and added coach reassignment

This commit is contained in:
2026-06-01 14:11:06 +02:00
parent 9ed66ca327
commit 6c17166558
17 changed files with 423 additions and 70 deletions

View File

@ -168,6 +168,75 @@ case 'POST':
} }
break; break;
case 'PUT':
case 'PATCH':
if ($callerRole !== 'admin') {
json_error('FORBIDDEN', 'Only admins can reassign coaches', 403);
}
$body = read_json_body();
$targetUserID = trim($body['userID'] ?? '');
$supervisorID = trim($body['supervisorID'] ?? '');
if ($targetUserID === '' || $supervisorID === '') {
json_error('MISSING_FIELDS', 'userID and supervisorID are required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$row = $pdo->prepare(
"SELECT u.userID, u.username, u.role, u.entityID, c.supervisorID
FROM users u
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
WHERE u.userID = :uid"
);
$row->execute([':uid' => $targetUserID]);
$target = $row->fetch(PDO::FETCH_ASSOC);
if (!$target) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'User not found', 404);
}
if (($target['role'] ?? '') !== 'coach') {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', 'Only coaches can be reassigned to a supervisor', 400);
}
$svCheck = $pdo->prepare('SELECT username FROM supervisor WHERE supervisorID = :sid');
$svCheck->execute([':sid' => $supervisorID]);
$svUsername = $svCheck->fetchColumn();
if ($svUsername === false) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Supervisor not found', 404);
}
if (($target['supervisorID'] ?? '') === $supervisorID) {
qdb_discard($tmpDb, $lockFp);
json_success([
'userID' => $targetUserID,
'supervisorID' => $supervisorID,
'supervisorUsername' => $svUsername,
]);
}
$pdo->prepare('UPDATE coach SET supervisorID = :sid WHERE coachID = :cid')
->execute([':sid' => $supervisorID, ':cid' => $target['entityID']]);
qdb_save($tmpDb, $lockFp);
json_success([
'userID' => $targetUserID,
'supervisorID' => $supervisorID,
'supervisorUsername' => $svUsername,
]);
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
}
break;
case 'DELETE': case 'DELETE':
$body = read_json_body(); $body = read_json_body();
$targetUserID = trim($body['userID'] ?? ''); $targetUserID = trim($body['userID'] ?? '');

View File

@ -333,13 +333,49 @@ code {
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
} }
.page-header-start {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.page-header-titles {
min-width: 0;
}
.page-header h1 { .page-header h1 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
margin: 0;
} }
.page-header .actions { .page-header .actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: 8px; gap: 8px;
align-items: center;
}
.btn-home {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
min-width: 40px;
padding: 0;
flex-shrink: 0;
}
.btn-home svg {
width: 20px;
height: 20px;
}
.coach-supervisor-select {
max-width: 100%;
min-width: 160px;
font-size: 0.85rem;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text);
} }
/* Questionnaire dashboard root (not .q-grid — avoids grid layout on nested cards) */ /* Questionnaire dashboard root (not .q-grid — avoids grid layout on nested cards) */
@ -1725,6 +1761,58 @@ table.data-table tbody tr.row-orphan-category td {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px; gap: 16px;
} }
.page-header-sub {
margin: 6px 0 0;
font-size: 0.9rem;
color: var(--text-secondary);
font-weight: normal;
}
.home-kpi-link {
text-decoration: none;
color: inherit;
transition: border-color 0.15s, background 0.15s;
}
.home-kpi-link:hover {
border-color: var(--primary-border);
background: var(--surface-hover);
}
.home-dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.home-link-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.home-link-card {
display: block;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-muted);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, background 0.15s;
}
.home-link-card:hover {
border-color: var(--primary-border);
background: var(--surface-hover);
}
.home-link-title {
display: block;
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 4px;
}
.home-link-desc {
display: block;
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.35;
}
.insights-kpi { .insights-kpi {
padding: 20px; padding: 20px;
text-align: center; text-align: center;

View File

@ -17,12 +17,18 @@
<small>Questionnaire Management</small> <small>Questionnaire Management</small>
</div> </div>
<ul class="sidebar-nav"> <ul class="sidebar-nav">
<li> <li data-nav-roles="admin supervisor">
<a href="#/"> <a href="#/">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Dashboard Dashboard
</a> </a>
</li> </li>
<li>
<a href="#/questionnaires">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/></svg>
Questionnaires
</a>
</li>
<li data-nav-roles="admin supervisor"> <li data-nav-roles="admin supervisor">
<a href="#/users"> <a href="#/users">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>

View File

@ -1,6 +1,7 @@
import { addRoute, startRouter, navigate } from './router.js'; import { addRoute, startRouter, navigate } from './router.js';
import { loginPage } from './pages/login.js'; import { loginPage } from './pages/login.js';
import { dashboardPage } from './pages/dashboard.js'; import { homeDashboardPage } from './pages/home.js';
import { questionnairesPage } from './pages/questionnaires.js';
import { editorPage } from './pages/editor.js'; import { editorPage } from './pages/editor.js';
import { resultsPage } from './pages/results.js'; import { resultsPage } from './pages/results.js';
import { exportPage } from './pages/export.js'; import { exportPage } from './pages/export.js';
@ -44,6 +45,39 @@ function rejectCoachSession() {
navigate('#/login'); navigate('#/login');
} }
/** Link to home dashboard (#/). */
export const HOME_HREF = '#/';
const HOME_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 9.5L12 3l9 6.5V20a1 1 0 01-1 1h-5v-7H9v7H4a1 1 0 01-1-1V9.5z"/></svg>`;
/** House icon button — returns to dashboard. */
export function homeNavButton(title = 'Dashboard') {
return `<a href="${HOME_HREF}" class="btn btn-home" title="${title}" aria-label="${title}">${HOME_ICON_SVG}</a>`;
}
/**
* Standard page header with home button.
* @param {string} title HTML-safe title text
* @param {string} [actionsHTML] optional buttons inside .actions
* @param {{ subtitle?: string }} [opts]
*/
export function pageHeaderHTML(title, actionsHTML = '', opts = {}) {
const subtitle = opts.subtitle
? `<p class="page-header-sub">${opts.subtitle}</p>`
: '';
return `
<div class="page-header">
<div class="page-header-start">
${homeNavButton()}
<div class="page-header-titles">
<h1>${title}</h1>
${subtitle}
</div>
</div>
${actionsHTML ? `<div class="actions">${actionsHTML}</div>` : ''}
</div>`;
}
export function showToast(message, type = 'info') { export function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer'); const container = document.getElementById('toastContainer');
const toast = document.createElement('div'); const toast = document.createElement('div');
@ -82,8 +116,15 @@ function updateNav() {
// Highlight active nav link // Highlight active nav link
const hash = window.location.hash.slice(1) || '/'; const hash = window.location.hash.slice(1) || '/';
document.querySelectorAll('.sidebar-nav a').forEach(a => { document.querySelectorAll('.sidebar-nav a').forEach(a => {
const href = a.getAttribute('href').slice(1); // strip leading # const href = a.getAttribute('href').slice(1);
a.classList.toggle('active', hash === href || (href !== '/' && hash.startsWith(href))); let active = hash === href;
if (!active && href === '/questionnaires' && hash.startsWith('/questionnaire')) {
active = true;
}
if (!active && href !== '/' && hash.startsWith(href)) {
active = true;
}
a.classList.toggle('active', active);
}); });
} else { } else {
nav.style.display = 'none'; nav.style.display = 'none';
@ -101,8 +142,8 @@ function roleGuard(roles, handler) {
// Routes // Routes
addRoute('/login', loginPage); addRoute('/login', loginPage);
addRoute('/', authGuard(dashboardPage)); addRoute('/', roleGuard(['admin', 'supervisor'], homeDashboardPage));
addRoute('/dashboard', authGuard(dashboardPage)); addRoute('/questionnaires', authGuard(questionnairesPage));
addRoute('/questionnaire/new', authGuard(editorPage)); addRoute('/questionnaire/new', authGuard(editorPage));
addRoute('/questionnaire/:id/results', authGuard(resultsPage)); addRoute('/questionnaire/:id/results', authGuard(resultsPage));
addRoute('/questionnaire/:id', authGuard(editorPage)); addRoute('/questionnaire/:id', authGuard(editorPage));

View File

@ -1,5 +1,5 @@
import { apiGet, apiPost } from '../api.js'; import { apiGet, apiPost } from '../api.js';
import { showToast } from '../app.js'; import { pageHeaderHTML, showToast } from '../app.js';
import { isDevTestClientCode, isDevTestUsername, testDataRowClassForClient } from '../test-data.js'; import { isDevTestClientCode, isDevTestUsername, testDataRowClassForClient } from '../test-data.js';
const CLIENT_PAGE_SIZE = 50; const CLIENT_PAGE_SIZE = 50;
@ -22,10 +22,7 @@ export async function assignmentsPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> ${pageHeaderHTML('Assign Clients')}
<h1>Assign Clients</h1>
<div class="actions"><a href="#/" class="btn">&larr; Dashboard</a></div>
</div>
<div id="assignContent"><div class="spinner"></div></div> <div id="assignContent"><div class="spinner"></div></div>
`; `;

View File

@ -1,5 +1,5 @@
import { apiGet, apiPost, apiDelete } from '../api.js'; import { apiGet, apiPost, apiDelete } from '../api.js';
import { showToast } from '../app.js'; import { pageHeaderHTML, showToast } from '../app.js';
import { isDevTestClientCode, testDataRowClassForClient } from '../test-data.js'; import { isDevTestClientCode, testDataRowClassForClient } from '../test-data.js';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@ -18,12 +18,7 @@ export async function clientsPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> ${pageHeaderHTML('Clients', '<button class="btn btn-primary" id="showCreateFormBtn">+ Create Client</button>')}
<h1>Clients</h1>
<div class="actions">
<button class="btn btn-primary" id="showCreateFormBtn">+ Create Client</button>
</div>
</div>
<div id="createClientWrapper" style="display:none"></div> <div id="createClientWrapper" style="display:none"></div>
<div id="clientsContent"><div class="spinner"></div></div> <div id="clientsContent"><div class="spinner"></div></div>
`; `;

View File

@ -1,5 +1,5 @@
import { apiGet } from '../api.js'; import { apiGet } from '../api.js';
import { showToast } from '../app.js'; import { pageHeaderHTML, showToast } from '../app.js';
const FETCH_LIMIT = 100; const FETCH_LIMIT = 100;
const CLIENT_GROUP_PAGE = 10; const CLIENT_GROUP_PAGE = 10;
@ -16,10 +16,7 @@ let filterSearch = '';
export async function coachesPage() { export async function coachesPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> ${pageHeaderHTML('Coach activity')}
<h1>Coach activity</h1>
<div class="actions"><a href="#/" class="btn">&larr; Dashboard</a></div>
</div>
<div id="coachesContent"><div class="spinner"></div></div> <div id="coachesContent"><div class="spinner"></div></div>
`; `;

View File

@ -1,5 +1,5 @@
import { apiPost } from '../api.js'; import { apiPost } from '../api.js';
import { getRole, showToast } from '../app.js'; import { getRole, pageHeaderHTML, showToast } from '../app.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
export function devPage() { export function devPage() {
@ -10,9 +10,7 @@ export function devPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> ${pageHeaderHTML('Admin Settings')}
<h1>Admin Settings</h1>
</div>
<div class="card" style="max-width:720px;margin-bottom:16px"> <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> <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"> <p class="field-hint" style="margin:0 0 14px">

View File

@ -1,5 +1,5 @@
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js'; import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
import { canEdit, showToast } from '../app.js'; import { canEdit, homeNavButton, showToast } from '../app.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
import { import {
SOURCE_LANG, SOURCE_LANG,
@ -50,9 +50,14 @@ export async function editorPage(params) {
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> <div class="page-header">
<div class="page-header-start">
${homeNavButton()}
<div class="page-header-titles">
<h1 id="editorTitle">${isNew ? 'New Questionnaire' : 'Loading...'}</h1> <h1 id="editorTitle">${isNew ? 'New Questionnaire' : 'Loading...'}</h1>
</div>
</div>
<div class="actions"> <div class="actions">
<a href="#/" class="btn">&larr; Back</a> <a href="#/questionnaires" class="btn">Questionnaires</a>
${!isNew ? `<a href="#/questionnaire/${params.id}/results" class="btn">View Results</a>` : ''} ${!isNew ? `<a href="#/questionnaire/${params.id}/results" class="btn">View Results</a>` : ''}
</div> </div>
</div> </div>
@ -77,7 +82,7 @@ export async function editorPage(params) {
questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id); questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id);
if (!questionnaire) { if (!questionnaire) {
showToast('Questionnaire not found', 'error'); showToast('Questionnaire not found', 'error');
navigate('#/'); navigate('#/questionnaires');
return; return;
} }
questions = quData.questions || []; questions = quData.questions || [];

View File

@ -1,15 +1,12 @@
import { apiGet } from '../api.js'; import { apiGet } from '../api.js';
import { canEdit, showToast } from '../app.js'; import { canEdit, pageHeaderHTML, showToast } from '../app.js';
let questionnairesList = []; let questionnairesList = [];
let filterSearch = ''; let filterSearch = '';
export async function exportPage() { export async function exportPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> ${pageHeaderHTML('Export Data')}
<h1>Export Data</h1>
<div class="actions"><a href="#/" class="btn">&larr; Dashboard</a></div>
</div>
<div class="card" id="exportContent"><div class="spinner"></div></div> <div class="card" id="exportContent"><div class="spinner"></div></div>
`; `;

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

@ -0,0 +1,108 @@
import { apiGet } from '../api.js';
import { getRole, pageHeaderHTML, showToast } from '../app.js';
export async function homeDashboardPage() {
const app = document.getElementById('app');
const role = getRole();
const isAdmin = role === 'admin';
app.innerHTML = `
${pageHeaderHTML('Dashboard', '', {
subtitle: `Overview for your ${role === 'admin' ? 'organization' : 'supervised coaches'}`,
})}
<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: 'Questionnaires', desc: 'Edit forms, order, and categories' },
{ href: '#/clients', label: 'Clients', desc: 'Client codes, coaches, response history' },
{ href: '#/coaches', label: 'Coach activity', desc: 'Uploads and workload per coach' },
{ 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 coaches' },
{ href: '#/users', label: 'Users', desc: 'Admins, supervisors, and coaches' },
{ 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">Questionnaire 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>Questionnaire</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 questionnaires.</p>'}
</div>
`;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

View File

@ -1,5 +1,5 @@
import { apiGet, apiPut } from '../api.js'; import { apiGet, apiPut } from '../api.js';
import { showToast } from '../app.js'; import { pageHeaderHTML, showToast } from '../app.js';
let overview = null; let overview = null;
let staleClients = []; let staleClients = [];
@ -8,10 +8,7 @@ let followupSearch = '';
export async function insightsPage() { export async function insightsPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> ${pageHeaderHTML('Insights')}
<h1>Insights</h1>
<div class="actions"><a href="#/" class="btn">&larr; Dashboard</a></div>
</div>
<div id="insightsContent"><div class="spinner"></div></div> <div id="insightsContent"><div class="spinner"></div></div>
`; `;

View File

@ -1,14 +1,11 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { canEdit, showToast, getRole } from '../app.js'; import { canEdit, pageHeaderHTML, showToast, getRole } from '../app.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
export async function dashboardPage() { export async function questionnairesPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> ${pageHeaderHTML('Questionnaires', '<span id="headerActions"></span>')}
<h1>Questionnaires</h1>
<div class="actions" id="headerActions"></div>
</div>
<div id="qGrid" class="q-dashboard-root"><div class="spinner"></div></div> <div id="qGrid" class="q-dashboard-root"><div class="spinner"></div></div>
<div id="categoryKeysPanel"></div> <div id="categoryKeysPanel"></div>
`; `;
@ -120,7 +117,7 @@ function renderCategoryKeysPanel(categoryKeys) {
: `Deleted "${key}"`, : `Deleted "${key}"`,
'success' 'success'
); );
await dashboardPage(); await questionnairesPage();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
btn.disabled = false; btn.disabled = false;
@ -222,7 +219,14 @@ function groupQuestionnairesByCategory(questionnaires, categoryKeys) {
sortKey: Math.min(...items.map(q => q.orderIndex ?? 0)), sortKey: Math.min(...items.map(q => q.orderIndex ?? 0)),
})); }));
sections.sort((a, b) => a.sortKey - b.sortKey); 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; return sections;
} }
@ -418,7 +422,7 @@ function bindImportBundle() {
}); });
const n = result.imported ?? count; const n = result.imported ?? count;
showToast(`Imported ${n} questionnaire(s)`, 'success'); showToast(`Imported ${n} questionnaire(s)`, 'success');
await dashboardPage(); await questionnairesPage();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
} finally { } finally {

View File

@ -1,5 +1,5 @@
import { apiGet } from '../api.js'; import { apiGet } from '../api.js';
import { showToast } from '../app.js'; import { homeNavButton, showToast } from '../app.js';
import { import {
buildResultColumns, buildResultColumns,
isDevTestClientCode, isDevTestClientCode,
@ -41,10 +41,15 @@ export async function resultsPage(params) {
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> <div class="page-header">
<div class="page-header-start">
${homeNavButton()}
<div class="page-header-titles">
<h1 id="resultsTitle">Results</h1> <h1 id="resultsTitle">Results</h1>
</div>
</div>
<div class="actions"> <div class="actions">
<a href="#/questionnaire/${params.id}" class="btn">&larr; Back to Editor</a> <a href="#/questionnaire/${params.id}" class="btn">Editor</a>
<a href="#/" class="btn">Dashboard</a> <a href="#/questionnaires" class="btn">Questionnaires</a>
</div> </div>
</div> </div>
<div id="resultsContent"><div class="spinner"></div></div> <div id="resultsContent"><div class="spinner"></div></div>

View File

@ -1,5 +1,5 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { canEdit, showToast } from '../app.js'; import { canEdit, pageHeaderHTML, showToast } from '../app.js';
import { import {
SOURCE_LANG, SOURCE_LANG,
normalizeEntry, normalizeEntry,
@ -21,10 +21,7 @@ let saveTimers = {};
export async function translationsPage() { export async function translationsPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> ${pageHeaderHTML('Translations', '<span id="transHeaderActions"></span>')}
<h1>Translations</h1>
<div class="actions" id="transHeaderActions"></div>
</div>
<div id="transPageRoot"><div class="spinner"></div></div> <div id="transPageRoot"><div class="spinner"></div></div>
`; `;

View File

@ -1,5 +1,5 @@
import { apiGet, apiPost, apiDelete } from '../api.js'; import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { getRole, getUser, showToast } from '../app.js'; import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js';
import { isDevTestUsername, testDataRowClassForUser } from '../test-data.js'; import { isDevTestUsername, testDataRowClassForUser } from '../test-data.js';
// Roles an admin can create; supervisors can only create coaches // Roles an admin can create; supervisors can only create coaches
@ -45,13 +45,10 @@ export async function usersPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
app.innerHTML = ` app.innerHTML = `
<div class="page-header"> ${pageHeaderHTML('Users', `
<h1>Users</h1>
<div class="actions">
<button class="btn" id="downloadUsersBtn" ${usersList.length ? '' : 'disabled'}>Download CSV</button> <button class="btn" id="downloadUsersBtn" ${usersList.length ? '' : 'disabled'}>Download CSV</button>
<button class="btn btn-primary" id="showCreateFormBtn">+ Create User</button> <button class="btn btn-primary" id="showCreateFormBtn">+ Create User</button>
</div> `)}
</div>
<div id="createUserWrapper" style="display:none"></div> <div id="createUserWrapper" style="display:none"></div>
<div id="usersContent"><div class="spinner"></div></div> <div id="usersContent"><div class="spinner"></div></div>
`; `;
@ -216,13 +213,37 @@ function updateRoleGroupTable(roleKey, myUsername) {
tbody.querySelectorAll('.delete-user-btn').forEach(btn => { tbody.querySelectorAll('.delete-user-btn').forEach(btn => {
btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name)); btn.addEventListener('click', () => deleteUser(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) { function userRowHTML(u, myUsername) {
const isSelf = u.username === myUsername; const isSelf = u.username === myUsername;
const detail = u.role === 'coach' let detail;
? esc(u.supervisorUsername || '') if (u.role === 'coach') {
: esc(u.location || '—'); detail = callerRole === 'admin'
? supervisorSelectHTML(u)
: esc(u.supervisorUsername || '—');
} else {
detail = esc(u.location || '—');
}
const created = u.createdAt const created = u.createdAt
? new Date(parseInt(u.createdAt, 10) * 1000).toLocaleDateString() ? new Date(parseInt(u.createdAt, 10) * 1000).toLocaleDateString()
: '—'; : '—';
@ -252,6 +273,34 @@ function userRowHTML(u, myUsername) {
</tr>`; </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(`Coach "${username}" assigned to ${data.supervisorUsername || 'supervisor'}`, 'success');
} catch (e) {
selectEl.value = prev;
showToast(e.message, 'error');
} finally {
selectEl.disabled = false;
}
}
async function deleteUser(userID, username) { async function deleteUser(userID, username) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return; if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
try { try {

View File

@ -46,7 +46,7 @@ async function resolve() {
return; return;
} }
} }
// fallback: dashboard // fallback: home dashboard
navigate('#/'); navigate('#/');
} }