enhanced navigation and added coach reassignment
This commit is contained in:
@ -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'] ?? '');
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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">← Dashboard</a></div>
|
|
||||||
</div>
|
|
||||||
<div id="assignContent"><div class="spinner"></div></div>
|
<div id="assignContent"><div class="spinner"></div></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -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">← Dashboard</a></div>
|
|
||||||
</div>
|
|
||||||
<div id="coachesContent"><div class="spinner"></div></div>
|
<div id="coachesContent"><div class="spinner"></div></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
<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">
|
<div class="actions">
|
||||||
<a href="#/" class="btn">← 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 || [];
|
||||||
|
|||||||
@ -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">← 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
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('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;
|
||||||
|
}
|
||||||
@ -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">← Dashboard</a></div>
|
|
||||||
</div>
|
|
||||||
<div id="insightsContent"><div class="spinner"></div></div>
|
<div id="insightsContent"><div class="spinner"></div></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
@ -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">
|
||||||
<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">
|
<div class="actions">
|
||||||
<a href="#/questionnaire/${params.id}" class="btn">← 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>
|
||||||
|
|||||||
@ -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>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -46,7 +46,7 @@ async function resolve() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// fallback: dashboard
|
// fallback: home dashboard
|
||||||
navigate('#/');
|
navigate('#/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user