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

@ -1,5 +1,5 @@
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';
const CLIENT_PAGE_SIZE = 50;
@ -22,10 +22,7 @@ export async function assignmentsPage() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Assign Clients</h1>
<div class="actions"><a href="#/" class="btn">&larr; Dashboard</a></div>
</div>
${pageHeaderHTML('Assign Clients')}
<div id="assignContent"><div class="spinner"></div></div>
`;

View File

@ -1,5 +1,5 @@
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';
const PAGE_SIZE = 50;
@ -18,12 +18,7 @@ export async function clientsPage() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Clients</h1>
<div class="actions">
<button class="btn btn-primary" id="showCreateFormBtn">+ Create Client</button>
</div>
</div>
${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>
`;

View File

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

View File

@ -1,5 +1,5 @@
import { apiPost } from '../api.js';
import { getRole, showToast } from '../app.js';
import { getRole, pageHeaderHTML, showToast } from '../app.js';
import { navigate } from '../router.js';
export function devPage() {
@ -10,9 +10,7 @@ export function devPage() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Admin Settings</h1>
</div>
${pageHeaderHTML('Admin Settings')}
<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">

View File

@ -1,5 +1,5 @@
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 {
SOURCE_LANG,
@ -50,9 +50,14 @@ export async function editorPage(params) {
app.innerHTML = `
<div class="page-header">
<h1 id="editorTitle">${isNew ? 'New Questionnaire' : 'Loading...'}</h1>
<div class="page-header-start">
${homeNavButton()}
<div class="page-header-titles">
<h1 id="editorTitle">${isNew ? 'New Questionnaire' : 'Loading...'}</h1>
</div>
</div>
<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>` : ''}
</div>
</div>
@ -77,7 +82,7 @@ export async function editorPage(params) {
questionnaire = allQuestionnaires.find(q => q.questionnaireID === params.id);
if (!questionnaire) {
showToast('Questionnaire not found', 'error');
navigate('#/');
navigate('#/questionnaires');
return;
}
questions = quData.questions || [];

View File

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

View File

@ -1,14 +1,11 @@
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';
export async function dashboardPage() {
export async function questionnairesPage() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Questionnaires</h1>
<div class="actions" id="headerActions"></div>
</div>
${pageHeaderHTML('Questionnaires', '<span id="headerActions"></span>')}
<div id="qGrid" class="q-dashboard-root"><div class="spinner"></div></div>
<div id="categoryKeysPanel"></div>
`;
@ -120,7 +117,7 @@ function renderCategoryKeysPanel(categoryKeys) {
: `Deleted "${key}"`,
'success'
);
await dashboardPage();
await questionnairesPage();
} catch (err) {
showToast(err.message, 'error');
btn.disabled = false;
@ -222,7 +219,14 @@ function groupQuestionnairesByCategory(questionnaires, categoryKeys) {
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;
}
@ -418,7 +422,7 @@ function bindImportBundle() {
});
const n = result.imported ?? count;
showToast(`Imported ${n} questionnaire(s)`, 'success');
await dashboardPage();
await questionnairesPage();
} catch (e) {
showToast(e.message, 'error');
} finally {

View File

@ -1,5 +1,5 @@
import { apiGet } from '../api.js';
import { showToast } from '../app.js';
import { homeNavButton, showToast } from '../app.js';
import {
buildResultColumns,
isDevTestClientCode,
@ -41,10 +41,15 @@ export async function resultsPage(params) {
app.innerHTML = `
<div class="page-header">
<h1 id="resultsTitle">Results</h1>
<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">&larr; Back to Editor</a>
<a href="#/" class="btn">Dashboard</a>
<a href="#/questionnaire/${params.id}" class="btn">Editor</a>
<a href="#/questionnaires" class="btn">Questionnaires</a>
</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 { canEdit, showToast } from '../app.js';
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
import {
SOURCE_LANG,
normalizeEntry,
@ -21,10 +21,7 @@ let saveTimers = {};
export async function translationsPage() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Translations</h1>
<div class="actions" id="transHeaderActions"></div>
</div>
${pageHeaderHTML('Translations', '<span id="transHeaderActions"></span>')}
<div id="transPageRoot"><div class="spinner"></div></div>
`;

View File

@ -1,5 +1,5 @@
import { apiGet, apiPost, apiDelete } from '../api.js';
import { getRole, getUser, showToast } from '../app.js';
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js';
import { isDevTestUsername, testDataRowClassForUser } from '../test-data.js';
// Roles an admin can create; supervisors can only create coaches
@ -45,13 +45,10 @@ export async function usersPage() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Users</h1>
<div class="actions">
${pageHeaderHTML('Users', `
<button class="btn" id="downloadUsersBtn" ${usersList.length ? '' : 'disabled'}>Download CSV</button>
<button class="btn btn-primary" id="showCreateFormBtn">+ Create User</button>
</div>
</div>
`)}
<div id="createUserWrapper" style="display:none"></div>
<div id="usersContent"><div class="spinner"></div></div>
`;
@ -216,13 +213,37 @@ function updateRoleGroupTable(roleKey, myUsername) {
tbody.querySelectorAll('.delete-user-btn').forEach(btn => {
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) {
const isSelf = u.username === myUsername;
const detail = u.role === 'coach'
? esc(u.supervisorUsername || '')
: esc(u.location || '—');
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()
: '—';
@ -252,6 +273,34 @@ function userRowHTML(u, myUsername) {
</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) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
try {