added client creation and fixed assignment
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,6 +2,7 @@
|
|||||||
tokens.jsonl
|
tokens.jsonl
|
||||||
valid_tokens.txt
|
valid_tokens.txt
|
||||||
.env
|
.env
|
||||||
|
*.sh
|
||||||
|
|
||||||
# Encrypted database, temp files, backups
|
# Encrypted database, temp files, backups
|
||||||
uploads/*
|
uploads/*
|
||||||
|
|||||||
@ -38,6 +38,7 @@ $routes = [
|
|||||||
'translations' => __DIR__ . '/../handlers/translations.php',
|
'translations' => __DIR__ . '/../handlers/translations.php',
|
||||||
'users' => __DIR__ . '/../handlers/users.php',
|
'users' => __DIR__ . '/../handlers/users.php',
|
||||||
'assignments' => __DIR__ . '/../handlers/assignments.php',
|
'assignments' => __DIR__ . '/../handlers/assignments.php',
|
||||||
|
'clients' => __DIR__ . '/../handlers/clients.php',
|
||||||
'results' => __DIR__ . '/../handlers/results.php',
|
'results' => __DIR__ . '/../handlers/results.php',
|
||||||
'export' => __DIR__ . '/../handlers/export.php',
|
'export' => __DIR__ . '/../handlers/export.php',
|
||||||
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
|
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
|
||||||
|
|||||||
@ -30,7 +30,7 @@ case 'GET':
|
|||||||
$coaches = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$coaches = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
}
|
}
|
||||||
|
|
||||||
[$clause, $params] = rbac_client_filter($tokenRec);
|
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
|
||||||
$clientStmt = $pdo->prepare(
|
$clientStmt = $pdo->prepare(
|
||||||
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
|
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
|
||||||
FROM client cl
|
FROM client cl
|
||||||
|
|||||||
123
handlers/clients.php
Normal file
123
handlers/clients.php
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$tokenRec = require_valid_token();
|
||||||
|
require_role(['admin', 'supervisor'], $tokenRec);
|
||||||
|
|
||||||
|
$callerRole = $tokenRec['role'];
|
||||||
|
$callerEntityID = $tokenRec['entityID'] ?? '';
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
|
||||||
|
case 'GET':
|
||||||
|
try {
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
|
||||||
|
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
|
||||||
|
FROM client cl
|
||||||
|
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||||
|
WHERE $clause
|
||||||
|
ORDER BY cl.clientCode"
|
||||||
|
);
|
||||||
|
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
|
||||||
|
$stmt->execute();
|
||||||
|
$clients = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_success(['clients' => $clients]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
|
error_log($e->getMessage());
|
||||||
|
json_error('SERVER_ERROR', 'Server error', 500);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
$body = read_json_body();
|
||||||
|
$clientCode = trim($body['clientCode'] ?? '');
|
||||||
|
$coachID = trim($body['coachID'] ?? '');
|
||||||
|
|
||||||
|
if ($clientCode === '' || $coachID === '') {
|
||||||
|
json_error('MISSING_FIELDS', 'clientCode and coachID are required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||||
|
|
||||||
|
// Validate coach exists and caller is allowed to assign to them
|
||||||
|
if ($callerRole === 'supervisor') {
|
||||||
|
$chkCoach = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
|
||||||
|
$chkCoach->execute([':cid' => $coachID, ':sid' => $callerEntityID]);
|
||||||
|
} else {
|
||||||
|
$chkCoach = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
|
||||||
|
$chkCoach->execute([':cid' => $coachID]);
|
||||||
|
}
|
||||||
|
if (!$chkCoach->fetch()) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('NOT_FOUND', 'Coach not found or not authorized', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$chk = $pdo->prepare("SELECT clientCode FROM client WHERE clientCode = :cc");
|
||||||
|
$chk->execute([':cc' => $clientCode]);
|
||||||
|
if ($chk->fetch()) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('DUPLICATE', 'Client code already exists', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare("INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)")
|
||||||
|
->execute([':cc' => $clientCode, ':cid' => $coachID]);
|
||||||
|
|
||||||
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
json_success(['clientCode' => $clientCode, 'coachID' => $coachID]);
|
||||||
|
} 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':
|
||||||
|
$body = read_json_body();
|
||||||
|
$clientCode = trim($body['clientCode'] ?? '');
|
||||||
|
|
||||||
|
if ($clientCode === '') {
|
||||||
|
json_error('MISSING_FIELDS', 'clientCode is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||||
|
|
||||||
|
// Verify the client exists and the caller can see it (RBAC)
|
||||||
|
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
$chk = $pdo->prepare(
|
||||||
|
"SELECT clientCode FROM client cl WHERE cl.clientCode = :cc AND ($clause)"
|
||||||
|
);
|
||||||
|
$chk->execute(array_merge([':cc' => $clientCode], $params));
|
||||||
|
if (!$chk->fetch()) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$pdo->prepare("DELETE FROM client_answer WHERE clientCode = :cc")
|
||||||
|
->execute([':cc' => $clientCode]);
|
||||||
|
$pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode = :cc")
|
||||||
|
->execute([':cc' => $clientCode]);
|
||||||
|
$pdo->prepare("DELETE FROM client WHERE clientCode = :cc")
|
||||||
|
->execute([':cc' => $clientCode]);
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
json_success([]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
||||||
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
|
error_log($e->getMessage());
|
||||||
|
json_error('SERVER_ERROR', 'Server error', 500);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||||
|
}
|
||||||
@ -27,6 +27,12 @@
|
|||||||
Users
|
Users
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li data-nav-roles="admin supervisor">
|
||||||
|
<a href="#/clients">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
Clients
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li data-nav-roles="admin supervisor">
|
<li data-nav-roles="admin supervisor">
|
||||||
<a href="#/assignments">
|
<a href="#/assignments">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { resultsPage } from './pages/results.js';
|
|||||||
import { exportPage } from './pages/export.js';
|
import { exportPage } from './pages/export.js';
|
||||||
import { usersPage } from './pages/users.js';
|
import { usersPage } from './pages/users.js';
|
||||||
import { assignmentsPage } from './pages/assignments.js';
|
import { assignmentsPage } from './pages/assignments.js';
|
||||||
|
import { clientsPage } from './pages/clients.js';
|
||||||
|
|
||||||
// Auth state
|
// Auth state
|
||||||
export function isLoggedIn() {
|
export function isLoggedIn() {
|
||||||
@ -86,6 +87,7 @@ addRoute('/questionnaire/:id', authGuard(editorPage));
|
|||||||
addRoute('/export', authGuard(exportPage));
|
addRoute('/export', authGuard(exportPage));
|
||||||
addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
|
addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
|
||||||
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
|
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
|
||||||
|
addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage));
|
||||||
|
|
||||||
// Nav link handling & logout
|
// Nav link handling & logout
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
206
website/js/pages/clients.js
Normal file
206
website/js/pages/clients.js
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { apiGet, apiPost, apiDelete } from '../api.js';
|
||||||
|
import { showToast } from '../app.js';
|
||||||
|
|
||||||
|
let clientsList = [];
|
||||||
|
let coachesList = [];
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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 [clientsData, assignData] = await Promise.all([
|
||||||
|
apiGet('clients.php'),
|
||||||
|
apiGet('assignments.php'),
|
||||||
|
]);
|
||||||
|
clientsList = clientsData.clients || [];
|
||||||
|
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) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No clients found</h3>
|
||||||
|
<p>Create the first client above.</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Client Code</th>
|
||||||
|
<th>Current Coach</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${clientsList.map(c => clientRowHTML(c)).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
container.querySelectorAll('.delete-client-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => deleteClient(btn.dataset.code));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientRowHTML(c) {
|
||||||
|
const coach = c.coachUsername
|
||||||
|
? `<strong>${esc(c.coachUsername)}</strong>`
|
||||||
|
: `<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${esc(c.clientCode)}</strong></td>
|
||||||
|
<td>${coach}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClient(clientCode) {
|
||||||
|
if (!confirm(`Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
await apiDelete('clients.php', { clientCode });
|
||||||
|
clientsList = clientsList.filter(c => c.clientCode !== clientCode);
|
||||||
|
showToast(`Client "${clientCode}" deleted`, 'success');
|
||||||
|
renderClients();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateForm() {
|
||||||
|
if (!coachesList.length) {
|
||||||
|
showToast('No coaches available. Create a coach 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 Coach</label>
|
||||||
|
<select id="cc_coach">
|
||||||
|
<option value="">— select coach —</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 coach'); 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user