This commit is contained in:
35
common.php
35
common.php
@ -309,25 +309,40 @@ function require_valid_token_web(): array {
|
||||
* Returns [string $clause, array $params].
|
||||
* $clientAlias is the table alias or name that has a coachID column (e.g. 'client').
|
||||
*/
|
||||
function rbac_client_filter(array $tokenRecord, string $clientAlias = 'client'): array {
|
||||
/**
|
||||
* @param string $archiveMode active — non-archived only (default); archived — archived only; all — no archive filter
|
||||
*/
|
||||
function rbac_client_filter(
|
||||
array $tokenRecord,
|
||||
string $clientAlias = 'client',
|
||||
string $archiveMode = 'active'
|
||||
): array {
|
||||
$role = $tokenRecord['role'] ?? '';
|
||||
$entityID = $tokenRecord['entityID'] ?? '';
|
||||
switch ($role) {
|
||||
case 'admin':
|
||||
return ['1=1', []];
|
||||
$clause = '1=1';
|
||||
$params = [];
|
||||
break;
|
||||
case 'supervisor':
|
||||
return [
|
||||
"$clientAlias.coachID IN (SELECT coachID FROM coach WHERE supervisorID = :rbac_eid)",
|
||||
[':rbac_eid' => $entityID]
|
||||
];
|
||||
$clause = "$clientAlias.coachID IN (SELECT coachID FROM coach WHERE supervisorID = :rbac_eid)";
|
||||
$params = [':rbac_eid' => $entityID];
|
||||
break;
|
||||
case 'coach':
|
||||
return [
|
||||
"$clientAlias.coachID = :rbac_eid",
|
||||
[':rbac_eid' => $entityID]
|
||||
];
|
||||
$clause = "$clientAlias.coachID = :rbac_eid";
|
||||
$params = [':rbac_eid' => $entityID];
|
||||
break;
|
||||
default:
|
||||
return ['0=1', []];
|
||||
}
|
||||
|
||||
$archiveClause = match ($archiveMode) {
|
||||
'archived' => "COALESCE($clientAlias.archived, 0) = 1",
|
||||
'all' => '1=1',
|
||||
default => "COALESCE($clientAlias.archived, 0) = 0",
|
||||
};
|
||||
|
||||
return ["($clause) AND ($archiveClause)", $params];
|
||||
}
|
||||
|
||||
// --- Translations: German (de) is the canonical source language ---
|
||||
|
||||
13
db_init.php
13
db_init.php
@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) {
|
||||
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
|
||||
}
|
||||
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
||||
define('QDB_VERSION', 10);
|
||||
define('QDB_VERSION', 11);
|
||||
|
||||
function qdb_table_exists(PDO $pdo, string $table): bool {
|
||||
$stmt = $pdo->prepare(
|
||||
@ -44,6 +44,17 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (qdb_table_exists($pdo, 'client')) {
|
||||
if (!qdb_column_exists($pdo, 'client', 'archived')) {
|
||||
$pdo->exec('ALTER TABLE client ADD COLUMN archived INTEGER NOT NULL DEFAULT 0');
|
||||
$changed = true;
|
||||
}
|
||||
if (!qdb_column_exists($pdo, 'client', 'archivedAt')) {
|
||||
$pdo->exec('ALTER TABLE client ADD COLUMN archivedAt INTEGER NOT NULL DEFAULT 0');
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE questionnaire_submission (
|
||||
|
||||
@ -382,7 +382,9 @@ if ($fetchScoringReview) {
|
||||
$clientCodes = [$clientCode];
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT clientCode FROM client WHERE coachID = :cid ORDER BY clientCode'
|
||||
'SELECT clientCode FROM client
|
||||
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
|
||||
ORDER BY clientCode'
|
||||
);
|
||||
$stmt->execute([':cid' => $coachID]);
|
||||
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
@ -435,7 +437,7 @@ if ($fetchClients) {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT clientCode
|
||||
FROM client
|
||||
WHERE coachID = :cid
|
||||
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
|
||||
ORDER BY clientCode
|
||||
");
|
||||
$stmt->execute([':cid' => $coachID]);
|
||||
|
||||
@ -47,7 +47,9 @@ case 'auth/login':
|
||||
$assignedClients = [];
|
||||
if (($user['role'] ?? '') === 'coach' && ($user['entityID'] ?? '') !== '') {
|
||||
$cStmt = $pdo->prepare(
|
||||
"SELECT clientCode FROM client WHERE coachID = :cid ORDER BY clientCode"
|
||||
"SELECT clientCode FROM client
|
||||
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
|
||||
ORDER BY clientCode"
|
||||
);
|
||||
$cStmt->execute([':cid' => $user['entityID']]);
|
||||
$assignedClients = array_map(
|
||||
|
||||
@ -19,9 +19,14 @@ case 'GET':
|
||||
json_success($detail);
|
||||
}
|
||||
|
||||
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
|
||||
$archiveFilter = trim((string)($_GET['archiveFilter'] ?? 'active'));
|
||||
if (!in_array($archiveFilter, ['active', 'archived', 'all'], true)) {
|
||||
$archiveFilter = 'active';
|
||||
}
|
||||
[$clause, $params] = rbac_client_filter($tokenRec, 'cl', $archiveFilter);
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername,
|
||||
"SELECT cl.clientCode, cl.coachID, cl.archived, cl.archivedAt,
|
||||
co.username AS coachUsername,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM completed_questionnaire cq WHERE cq.clientCode = cl.clientCode
|
||||
) OR EXISTS (
|
||||
@ -30,7 +35,7 @@ case 'GET':
|
||||
FROM client cl
|
||||
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||
WHERE $clause
|
||||
ORDER BY hasResponseData DESC, cl.clientCode ASC"
|
||||
ORDER BY cl.archived ASC, hasResponseData DESC, cl.clientCode ASC"
|
||||
);
|
||||
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
|
||||
$stmt->execute();
|
||||
@ -88,8 +93,9 @@ case 'POST':
|
||||
json_error('DUPLICATE', 'Client code already exists', 409);
|
||||
}
|
||||
|
||||
$pdo->prepare("INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)")
|
||||
->execute([':cc' => $clientCode, ':cid' => $coachID]);
|
||||
$pdo->prepare(
|
||||
"INSERT INTO client (clientCode, coachID, archived, archivedAt) VALUES (:cc, :cid, 0, 0)"
|
||||
)->execute([':cc' => $clientCode, ':cid' => $coachID]);
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['clientCode' => $clientCode, 'coachID' => $coachID]);
|
||||
@ -153,6 +159,44 @@ case 'PUT':
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PATCH':
|
||||
$body = read_json_body();
|
||||
$clientCode = trim((string)($body['clientCode'] ?? ''));
|
||||
if ($clientCode === '' || !array_key_exists('archived', $body)) {
|
||||
json_error('MISSING_FIELDS', 'clientCode and archived are required', 400);
|
||||
}
|
||||
$archived = !empty($body['archived']) ? 1 : 0;
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
[$clause, $params] = rbac_client_filter($tokenRec, 'cl', 'all');
|
||||
$chk = $pdo->prepare(
|
||||
"SELECT clientCode, archived FROM client cl WHERE cl.clientCode = :cc AND ($clause)"
|
||||
);
|
||||
$chk->execute(array_merge([':cc' => $clientCode], $params));
|
||||
$row = $chk->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||
}
|
||||
|
||||
$archivedAt = $archived ? time() : 0;
|
||||
$pdo->prepare(
|
||||
'UPDATE client SET archived = :a, archivedAt = :at WHERE clientCode = :cc'
|
||||
)->execute([':a' => $archived, ':at' => $archivedAt, ':cc' => $clientCode]);
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'clientCode' => $clientCode,
|
||||
'archived' => $archived,
|
||||
'archivedAt' => $archivedAt,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
$body = read_json_body();
|
||||
$clientCode = trim($body['clientCode'] ?? '');
|
||||
|
||||
@ -214,7 +214,16 @@ function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$lastAt = $r['lastActivityAt'] !== null ? (int)$r['lastActivityAt'] : null;
|
||||
$completedTs = $r['lastCompletedAt'] ? (int)$r['lastCompletedAt'] : null;
|
||||
$uploadTs = $r['lastActivityAt'] !== null ? (int)$r['lastActivityAt'] : null;
|
||||
$lastAt = null;
|
||||
if ($completedTs !== null && $uploadTs !== null) {
|
||||
$lastAt = max($completedTs, $uploadTs);
|
||||
} elseif ($completedTs !== null) {
|
||||
$lastAt = $completedTs;
|
||||
} else {
|
||||
$lastAt = $uploadTs;
|
||||
}
|
||||
$daysSince = $lastAt !== null
|
||||
? (int)floor(($now - $lastAt) / 86400)
|
||||
: null;
|
||||
@ -223,9 +232,7 @@ function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
|
||||
'coachUsername' => $r['coachUsername'] ?? $r['coachID'],
|
||||
'lastQuestionnaireID' => $r['lastQuestionnaireID'],
|
||||
'lastQuestionnaireName' => $r['lastQuestionnaireName'] ?? $r['lastQuestionnaireID'],
|
||||
'lastCompletedAt' => $r['lastCompletedAt']
|
||||
? date('Y-m-d H:i', (int)$r['lastCompletedAt']) : '',
|
||||
'lastActivityAt' => $lastAt ? date('Y-m-d H:i', $lastAt) : '',
|
||||
'lastCompletedAt' => $completedTs ? date('Y-m-d H:i', $completedTs) : '',
|
||||
'daysSinceLastActivity' => $daysSince,
|
||||
'note' => $r['followupNote'] ?? '',
|
||||
];
|
||||
@ -280,7 +287,8 @@ function qdb_coach_activity_list(PDO $pdo, array $tokenRec): array {
|
||||
$sql = "
|
||||
SELECT co.coachID, co.username,
|
||||
sv.username AS supervisorUsername,
|
||||
(SELECT COUNT(*) FROM client c WHERE c.coachID = co.coachID) AS clientCount,
|
||||
(SELECT COUNT(*) FROM client c
|
||||
WHERE c.coachID = co.coachID AND COALESCE(c.archived, 0) = 0) AS clientCount,
|
||||
(SELECT COUNT(*) FROM questionnaire_submission qs
|
||||
INNER JOIN client c ON c.clientCode = qs.clientCode
|
||||
WHERE c.coachID = co.coachID) AS totalSubmissions,
|
||||
|
||||
@ -290,7 +290,9 @@ function qdb_export_app_bulk_answers_for_coach(PDO $pdo, array $tokenRec): array
|
||||
return [];
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT clientCode FROM client WHERE coachID = :cid ORDER BY clientCode'
|
||||
'SELECT clientCode FROM client
|
||||
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
|
||||
ORDER BY clientCode'
|
||||
);
|
||||
$stmt->execute([':cid' => $coachID]);
|
||||
$out = [];
|
||||
|
||||
@ -614,9 +614,9 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
|
||||
json_error('INVALID_FIELD', 'clientCode is required', 400);
|
||||
}
|
||||
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl', 'all');
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT cl.clientCode, cl.coachID,
|
||||
"SELECT cl.clientCode, cl.coachID, cl.archived, cl.archivedAt,
|
||||
co.username AS coachUsername,
|
||||
sv.username AS supervisorUsername
|
||||
FROM client cl
|
||||
|
||||
@ -32,6 +32,8 @@ CREATE TABLE IF NOT EXISTS coach (
|
||||
CREATE TABLE IF NOT EXISTS client (
|
||||
clientCode TEXT NOT NULL PRIMARY KEY,
|
||||
coachID TEXT NOT NULL,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archivedAt INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(coachID) REFERENCES coach(coachID)
|
||||
);
|
||||
|
||||
|
||||
@ -41,6 +41,56 @@ final class ClientsLifecycleTest extends QdbTestCase
|
||||
$this->assertSame($code, $res['data']['clientCode']);
|
||||
}
|
||||
|
||||
public function testArchiveHidesClientFromListsAndFollowUp(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$code = $this->fixture()->clientCode;
|
||||
|
||||
$archive = $this->api()->withToken($token, 'PATCH', 'clients', [
|
||||
'clientCode' => $code,
|
||||
'archived' => true,
|
||||
]);
|
||||
$this->assertApiOk($archive);
|
||||
$this->assertSame(1, (int)($archive['data']['archived'] ?? 0));
|
||||
|
||||
$active = $this->api()->withToken($token, 'GET', 'clients');
|
||||
$this->assertApiOk($active);
|
||||
$activeCodes = array_column($active['data']['clients'], 'clientCode');
|
||||
$this->assertNotContains($code, $activeCodes);
|
||||
|
||||
$archivedOnly = $this->api()->withToken($token, 'GET', 'clients', null, [
|
||||
'archiveFilter' => 'archived',
|
||||
]);
|
||||
$this->assertApiOk($archivedOnly);
|
||||
$archivedCodes = array_column($archivedOnly['data']['clients'], 'clientCode');
|
||||
$this->assertContains($code, $archivedCodes);
|
||||
|
||||
$assign = $this->api()->withToken($token, 'GET', 'assignments');
|
||||
$this->assertApiOk($assign);
|
||||
$assignCodes = array_column($assign['data']['clients'], 'clientCode');
|
||||
$this->assertNotContains($code, $assignCodes);
|
||||
|
||||
$stale = $this->api()->withToken($token, 'GET', 'analytics', null, ['staleClients' => '1']);
|
||||
$this->assertApiOk($stale);
|
||||
$staleCodes = array_column($stale['data']['clients'], 'clientCode');
|
||||
$this->assertNotContains($code, $staleCodes);
|
||||
|
||||
$restore = $this->api()->withToken($token, 'PATCH', 'clients', [
|
||||
'clientCode' => $code,
|
||||
'archived' => false,
|
||||
]);
|
||||
$this->assertApiOk($restore);
|
||||
$this->assertSame(0, (int)($restore['data']['archived'] ?? 1));
|
||||
|
||||
$activeAgain = $this->api()->withToken($token, 'GET', 'clients');
|
||||
$this->assertApiOk($activeAgain);
|
||||
$this->assertContains($code, array_column($activeAgain['data']['clients'], 'clientCode'));
|
||||
}
|
||||
|
||||
public function testAdminDeletesClient(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
|
||||
@ -11,8 +11,12 @@ final class RbacTest extends TestCase
|
||||
public function testAdminSeesAllClients(): void
|
||||
{
|
||||
[$clause, $params] = rbac_client_filter(['role' => 'admin', 'entityID' => 'a1'], 'cl');
|
||||
$this->assertSame('1=1', $clause);
|
||||
$this->assertStringContainsString('1=1', $clause);
|
||||
$this->assertStringContainsString('archived', $clause);
|
||||
$this->assertSame([], $params);
|
||||
|
||||
[$allClause] = rbac_client_filter(['role' => 'admin', 'entityID' => 'a1'], 'cl', 'all');
|
||||
$this->assertStringNotContainsString('archived', $allClause);
|
||||
}
|
||||
|
||||
public function testSupervisorFilterBindsEntity(): void
|
||||
|
||||
@ -2431,6 +2431,107 @@ body.modal-open {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
.clients-list-filters {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.clients-archive-filter {
|
||||
flex: 0 0 auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.client-actions-cell {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table-row-actions {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.table-row-actions .btn {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.table-row-actions .btn:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.table-row-actions .btn + .btn {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-row-actions .archive-client-btn,
|
||||
.table-row-actions .restore-client-btn {
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.table-row-actions .archive-client-btn:hover,
|
||||
.table-row-actions .restore-client-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.table-row-actions .delete-client-btn {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.followup-note-cell {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.followup-note-cell .followup-note-input {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.followup-note-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.followup-actions-cell {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.client-row-archived td {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.client-archived-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.client-detail-panel {
|
||||
padding: 12px 8px 8px 32px;
|
||||
background: var(--surface-muted);
|
||||
|
||||
43
website/js/client-archive.js
Normal file
43
website/js/client-archive.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { apiPatch } from './api.js';
|
||||
import { showToast } from './app.js';
|
||||
|
||||
export function isClientArchived(c) {
|
||||
return Number(c.archived) === 1;
|
||||
}
|
||||
|
||||
export async function confirmAndPatchClientArchive(clientCode, archived) {
|
||||
const msg = archived
|
||||
? `Archive client "${clientCode}"? They will be hidden from assignments, follow-up, and the mobile app. You can restore them later from the Clients page.`
|
||||
: `Restore client "${clientCode}" to active lists?`;
|
||||
if (!confirm(msg)) return false;
|
||||
|
||||
try {
|
||||
await apiPatch('clients.php', { clientCode, archived });
|
||||
showToast(
|
||||
archived ? `Client "${clientCode}" archived` : `Client "${clientCode}" restored`,
|
||||
'success'
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.clientCode Escaped client code for data-code attributes
|
||||
* @param {boolean} opts.archived
|
||||
* @param {boolean} [opts.showDelete]
|
||||
* @param {string} [opts.rawCode] Unescaped code for button labels (optional)
|
||||
*/
|
||||
export function clientTableActionsHTML({ clientCode, archived, showDelete = false }) {
|
||||
const archiveBtn = archived
|
||||
? `<button type="button" class="btn btn-sm restore-client-btn" data-code="${clientCode}" title="Restore to active lists">Restore</button>`
|
||||
: `<button type="button" class="btn btn-sm archive-client-btn" data-code="${clientCode}" title="Mark as finished and hide from lists">Archive</button>`;
|
||||
const deleteBtn = showDelete
|
||||
? `<button type="button" class="btn btn-sm btn-danger delete-client-btn" data-code="${clientCode}" title="Permanently delete client and all data">Delete</button>`
|
||||
: '';
|
||||
|
||||
return `<div class="table-row-actions" role="group" aria-label="Client actions">${archiveBtn}${deleteBtn}</div>`;
|
||||
}
|
||||
@ -1,12 +1,19 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
|
||||
import { pageHeaderHTML, showToast } from '../app.js';
|
||||
import {
|
||||
isClientArchived,
|
||||
confirmAndPatchClientArchive,
|
||||
clientTableActionsHTML,
|
||||
} from '../client-archive.js';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const ARCHIVE_FILTER_KEY = 'clientsArchiveFilter';
|
||||
|
||||
let clientsList = [];
|
||||
let coachesList = [];
|
||||
let scoringProfileCatalog = [];
|
||||
let filterSearch = '';
|
||||
let archiveFilter = localStorage.getItem(ARCHIVE_FILTER_KEY) || 'active';
|
||||
let page = 0;
|
||||
let expandedClientCode = null;
|
||||
let expandedQnKey = null;
|
||||
@ -36,8 +43,11 @@ export async function clientsPage() {
|
||||
|
||||
async function loadClients() {
|
||||
try {
|
||||
const archiveQuery = archiveFilter !== 'active'
|
||||
? `?archiveFilter=${encodeURIComponent(archiveFilter)}`
|
||||
: '';
|
||||
const [clientsData, assignData] = await Promise.all([
|
||||
apiGet('clients.php'),
|
||||
apiGet(`clients.php${archiveQuery}`),
|
||||
apiGet('assignments.php'),
|
||||
]);
|
||||
clientsList = clientsData.clients || [];
|
||||
@ -55,13 +65,23 @@ function renderClients() {
|
||||
const container = document.getElementById('clientsContent');
|
||||
|
||||
if (!clientsList.length) {
|
||||
const emptyMsg = archiveFilter === 'archived'
|
||||
? '<h3>No archived clients</h3><p>Archived clients are hidden from assignments and follow-up.</p>'
|
||||
: archiveFilter === 'all'
|
||||
? '<h3>No clients found</h3><p>Create the first client above.</p>'
|
||||
: '<h3>No active clients</h3><p>Create a client above or switch the filter to view archived clients.</p>';
|
||||
container.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="empty-state">
|
||||
<h3>No clients found</h3>
|
||||
<p>Create the first client above.</p>
|
||||
<div class="filter-bar clients-list-filters">
|
||||
${archiveFilterSelectHTML()}
|
||||
<input type="search" id="clientListSearch" class="filter-search" placeholder="Search client or counselor…" value="${esc(filterSearch)}">
|
||||
</div>
|
||||
<div class="empty-state">${emptyMsg}</div>
|
||||
</div>`;
|
||||
bindArchiveFilterControl();
|
||||
document.getElementById('clientListSearch')?.addEventListener('input', (e) => {
|
||||
filterSearch = e.target.value.trim();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -70,7 +90,8 @@ function renderClients() {
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar clients-list-filters">
|
||||
${archiveFilterSelectHTML()}
|
||||
<input type="search" id="clientListSearch" class="filter-search" placeholder="Search client or counselor…" value="${esc(filterSearch)}">
|
||||
<span class="data-toolbar-hint" id="clientListCount"></span>
|
||||
</div>
|
||||
@ -94,6 +115,7 @@ function renderClients() {
|
||||
<div class="pagination-bar" id="clientsPagination"></div>
|
||||
</div>`;
|
||||
|
||||
bindArchiveFilterControl();
|
||||
document.getElementById('clientListSearch').addEventListener('input', (e) => {
|
||||
filterSearch = e.target.value.trim();
|
||||
page = 0;
|
||||
@ -104,6 +126,31 @@ function renderClients() {
|
||||
renderClientTableBody();
|
||||
}
|
||||
|
||||
function archiveFilterSelectHTML() {
|
||||
return `
|
||||
<select id="clientArchiveFilter" class="clients-archive-filter" aria-label="Client status filter">
|
||||
<option value="active" ${archiveFilter === 'active' ? 'selected' : ''}>Active</option>
|
||||
<option value="archived" ${archiveFilter === 'archived' ? 'selected' : ''}>Archived</option>
|
||||
<option value="all" ${archiveFilter === 'all' ? 'selected' : ''}>All</option>
|
||||
</select>`;
|
||||
}
|
||||
|
||||
function bindArchiveFilterControl() {
|
||||
const sel = document.getElementById('clientArchiveFilter');
|
||||
if (!sel) return;
|
||||
sel.addEventListener('change', async (e) => {
|
||||
archiveFilter = e.target.value;
|
||||
localStorage.setItem(ARCHIVE_FILTER_KEY, archiveFilter);
|
||||
page = 0;
|
||||
expandedClientCode = null;
|
||||
expandedQnKey = null;
|
||||
expandedVersionKey = null;
|
||||
detailByClient = {};
|
||||
document.getElementById('clientsContent').innerHTML = '<div class="spinner"></div>';
|
||||
await loadClients();
|
||||
});
|
||||
}
|
||||
|
||||
function getFilteredClientsList() {
|
||||
let list = clientsList;
|
||||
if (filterSearch) {
|
||||
@ -149,6 +196,12 @@ function renderClientTableBody() {
|
||||
body.querySelectorAll('.delete-client-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteClient(btn.dataset.code));
|
||||
});
|
||||
body.querySelectorAll('.archive-client-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => setClientArchived(btn.dataset.code, true));
|
||||
});
|
||||
body.querySelectorAll('.restore-client-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => setClientArchived(btn.dataset.code, false));
|
||||
});
|
||||
bindCoachBandPickerEvents(body);
|
||||
}
|
||||
|
||||
@ -652,10 +705,18 @@ function clientHasResponseData(c) {
|
||||
return Number(c.hasResponseData) === 1;
|
||||
}
|
||||
|
||||
function clientArchivedLabel(c) {
|
||||
if (!isClientArchived(c) || !c.archivedAt) return '';
|
||||
const d = new Date(Number(c.archivedAt) * 1000);
|
||||
const when = Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
|
||||
return when ? ` <span class="client-archived-badge" title="Archived ${esc(when)}">Archived</span>` : '';
|
||||
}
|
||||
|
||||
function clientRowHTML(c) {
|
||||
const coach = c.coachUsername
|
||||
? `<strong>${esc(c.coachUsername)}</strong>`
|
||||
: `<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>`;
|
||||
const archived = isClientArchived(c);
|
||||
const canExpand = clientHasResponseData(c);
|
||||
const expanded = canExpand && expandedClientCode === c.clientCode;
|
||||
const expandControl = canExpand
|
||||
@ -665,17 +726,20 @@ function clientRowHTML(c) {
|
||||
? `<td class="client-profile-dots-cell">${profileDotsHTML(c.scoringProfiles || [], c.clientCode)}</td>`
|
||||
: '';
|
||||
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
|
||||
const actions = clientTableActionsHTML({
|
||||
clientCode: esc(c.clientCode),
|
||||
archived,
|
||||
showDelete: true,
|
||||
});
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<tr class="${archived ? 'client-row-archived' : ''}">
|
||||
<td>
|
||||
${expandControl}<strong>${esc(c.clientCode)}</strong>
|
||||
${expandControl}<strong>${esc(c.clientCode)}</strong>${clientArchivedLabel(c)}
|
||||
</td>
|
||||
<td>${coach}</td>
|
||||
${profileCol}
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
|
||||
</td>
|
||||
<td class="client-actions-cell">${actions}</td>
|
||||
</tr>
|
||||
${expanded ? `
|
||||
<tr class="client-detail-row">
|
||||
@ -728,6 +792,30 @@ function toggleVersion(clientCode, questionnaireID, submissionID) {
|
||||
renderClientTableBody();
|
||||
}
|
||||
|
||||
async function setClientArchived(clientCode, archived) {
|
||||
const ok = await confirmAndPatchClientArchive(clientCode, archived);
|
||||
if (!ok) return;
|
||||
|
||||
if (archived && archiveFilter === 'active') {
|
||||
clientsList = clientsList.filter(c => c.clientCode !== clientCode);
|
||||
} else if (!archived && archiveFilter === 'archived') {
|
||||
clientsList = clientsList.filter(c => c.clientCode !== clientCode);
|
||||
} else {
|
||||
const row = clientsList.find(c => c.clientCode === clientCode);
|
||||
if (row) {
|
||||
row.archived = archived ? 1 : 0;
|
||||
row.archivedAt = archived ? Math.floor(Date.now() / 1000) : 0;
|
||||
}
|
||||
}
|
||||
if (expandedClientCode === clientCode && archived) {
|
||||
expandedClientCode = null;
|
||||
expandedQnKey = null;
|
||||
expandedVersionKey = null;
|
||||
}
|
||||
if (!clientsList.length) renderClients();
|
||||
else renderClientTableBody();
|
||||
}
|
||||
|
||||
async function deleteClient(clientCode) {
|
||||
if (!confirm(`Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`)) return;
|
||||
try {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { apiGet, apiPut } from '../api.js';
|
||||
import { pageHeaderHTML, showToast } from '../app.js';
|
||||
import { clientTableActionsHTML, confirmAndPatchClientArchive } from '../client-archive.js';
|
||||
|
||||
let overview = null;
|
||||
let staleClients = [];
|
||||
@ -159,7 +160,7 @@ function renderInsights() {
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h3 style="margin:0 0 8px">Needs follow-up</h3>
|
||||
<p class="data-toolbar-hint" style="margin:0 0 12px">
|
||||
Clients who completed at least one questionnaire, sorted by longest time since any upload activity.
|
||||
Clients who completed at least one questionnaire, sorted by longest time since last activity.
|
||||
</p>
|
||||
<div class="filter-bar">
|
||||
<input type="search" id="followupListSearch" class="filter-search"
|
||||
@ -170,8 +171,8 @@ function renderInsights() {
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th><th>Counselor</th><th>Last completed</th><th>Last at</th>
|
||||
<th>Last activity</th><th>Days idle</th><th>Note</th>
|
||||
<th>Client</th><th>Counselor</th><th>Last questionnaire</th>
|
||||
<th>Completed at</th><th>Days idle</th><th>Note</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="followupTableBody"></tbody>
|
||||
@ -196,7 +197,6 @@ function followupSearchText(c) {
|
||||
c.lastQuestionnaireID,
|
||||
c.note,
|
||||
c.lastCompletedAt,
|
||||
c.lastActivityAt,
|
||||
c.daysSinceLastActivity != null ? String(c.daysSinceLastActivity) : '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
@ -208,18 +208,23 @@ function filteredFollowupClients() {
|
||||
}
|
||||
|
||||
function followupRowHTML(c) {
|
||||
const code = esc(c.clientCode);
|
||||
return `
|
||||
<tr data-client="${esc(c.clientCode)}">
|
||||
<td><code>${esc(c.clientCode)}</code></td>
|
||||
<tr data-client="${code}">
|
||||
<td><code>${code}</code></td>
|
||||
<td>${esc(c.coachUsername)}</td>
|
||||
<td>${esc(c.lastQuestionnaireName || '—')}</td>
|
||||
<td>${esc(c.lastCompletedAt || '—')}</td>
|
||||
<td>${esc(c.lastActivityAt || '—')}</td>
|
||||
<td>${c.daysSinceLastActivity != null ? c.daysSinceLastActivity : '—'}</td>
|
||||
<td>
|
||||
<textarea class="followup-note-input" rows="2" data-client="${esc(c.clientCode)}"
|
||||
<td class="followup-note-cell">
|
||||
<textarea class="followup-note-input" rows="2" data-client="${code}"
|
||||
placeholder="Follow-up note…">${esc(c.note || '')}</textarea>
|
||||
<button type="button" class="btn btn-sm save-note-btn" data-client="${esc(c.clientCode)}">Save</button>
|
||||
<div class="followup-note-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary save-note-btn" data-client="${code}">Save note</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="followup-actions-cell">
|
||||
${clientTableActionsHTML({ clientCode: code, archived: false, showDelete: false })}
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
@ -262,6 +267,17 @@ function renderFollowupTableBody() {
|
||||
saveNote(btn.dataset.client, ta);
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelectorAll('.archive-client-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => archiveFromFollowup(btn.dataset.code));
|
||||
});
|
||||
}
|
||||
|
||||
async function archiveFromFollowup(clientCode) {
|
||||
const ok = await confirmAndPatchClientArchive(clientCode, true);
|
||||
if (!ok) return;
|
||||
staleClients = staleClients.filter(c => c.clientCode !== clientCode);
|
||||
renderFollowupTableBody();
|
||||
}
|
||||
|
||||
async function saveNote(clientCode, ta) {
|
||||
|
||||
Reference in New Issue
Block a user