added functionality to archive clients
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-12 20:55:12 +02:00
parent 59ea2cd667
commit 5dff24a232
15 changed files with 436 additions and 48 deletions

View File

@ -309,25 +309,40 @@ function require_valid_token_web(): array {
* Returns [string $clause, array $params]. * Returns [string $clause, array $params].
* $clientAlias is the table alias or name that has a coachID column (e.g. 'client'). * $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'] ?? ''; $role = $tokenRecord['role'] ?? '';
$entityID = $tokenRecord['entityID'] ?? ''; $entityID = $tokenRecord['entityID'] ?? '';
switch ($role) { switch ($role) {
case 'admin': case 'admin':
return ['1=1', []]; $clause = '1=1';
$params = [];
break;
case 'supervisor': case 'supervisor':
return [ $clause = "$clientAlias.coachID IN (SELECT coachID FROM coach WHERE supervisorID = :rbac_eid)";
"$clientAlias.coachID IN (SELECT coachID FROM coach WHERE supervisorID = :rbac_eid)", $params = [':rbac_eid' => $entityID];
[':rbac_eid' => $entityID] break;
];
case 'coach': case 'coach':
return [ $clause = "$clientAlias.coachID = :rbac_eid";
"$clientAlias.coachID = :rbac_eid", $params = [':rbac_eid' => $entityID];
[':rbac_eid' => $entityID] break;
];
default: default:
return ['0=1', []]; 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 --- // --- Translations: German (de) is the canonical source language ---

View File

@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) {
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
} }
define('QDB_SCHEMA', __DIR__ . '/schema.sql'); define('QDB_SCHEMA', __DIR__ . '/schema.sql');
define('QDB_VERSION', 10); define('QDB_VERSION', 11);
function qdb_table_exists(PDO $pdo, string $table): bool { function qdb_table_exists(PDO $pdo, string $table): bool {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
@ -44,6 +44,17 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
$changed = true; $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')) { if (!qdb_table_exists($pdo, 'questionnaire_submission')) {
$pdo->exec(" $pdo->exec("
CREATE TABLE questionnaire_submission ( CREATE TABLE questionnaire_submission (

View File

@ -382,7 +382,9 @@ if ($fetchScoringReview) {
$clientCodes = [$clientCode]; $clientCodes = [$clientCode];
} else { } else {
$stmt = $pdo->prepare( $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]); $stmt->execute([':cid' => $coachID]);
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN); $clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
@ -435,7 +437,7 @@ if ($fetchClients) {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT clientCode SELECT clientCode
FROM client FROM client
WHERE coachID = :cid WHERE coachID = :cid AND COALESCE(archived, 0) = 0
ORDER BY clientCode ORDER BY clientCode
"); ");
$stmt->execute([':cid' => $coachID]); $stmt->execute([':cid' => $coachID]);

View File

@ -47,7 +47,9 @@ case 'auth/login':
$assignedClients = []; $assignedClients = [];
if (($user['role'] ?? '') === 'coach' && ($user['entityID'] ?? '') !== '') { if (($user['role'] ?? '') === 'coach' && ($user['entityID'] ?? '') !== '') {
$cStmt = $pdo->prepare( $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']]); $cStmt->execute([':cid' => $user['entityID']]);
$assignedClients = array_map( $assignedClients = array_map(

View File

@ -19,9 +19,14 @@ case 'GET':
json_success($detail); 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( $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 ( CASE WHEN EXISTS (
SELECT 1 FROM completed_questionnaire cq WHERE cq.clientCode = cl.clientCode SELECT 1 FROM completed_questionnaire cq WHERE cq.clientCode = cl.clientCode
) OR EXISTS ( ) OR EXISTS (
@ -30,7 +35,7 @@ case 'GET':
FROM client cl FROM client cl
LEFT JOIN coach co ON co.coachID = cl.coachID LEFT JOIN coach co ON co.coachID = cl.coachID
WHERE $clause 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); foreach ($params as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute(); $stmt->execute();
@ -88,8 +93,9 @@ case 'POST':
json_error('DUPLICATE', 'Client code already exists', 409); json_error('DUPLICATE', 'Client code already exists', 409);
} }
$pdo->prepare("INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)") $pdo->prepare(
->execute([':cc' => $clientCode, ':cid' => $coachID]); "INSERT INTO client (clientCode, coachID, archived, archivedAt) VALUES (:cc, :cid, 0, 0)"
)->execute([':cc' => $clientCode, ':cid' => $coachID]);
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['clientCode' => $clientCode, 'coachID' => $coachID]); json_success(['clientCode' => $clientCode, 'coachID' => $coachID]);
@ -153,6 +159,44 @@ case 'PUT':
} }
break; 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': case 'DELETE':
$body = read_json_body(); $body = read_json_body();
$clientCode = trim($body['clientCode'] ?? ''); $clientCode = trim($body['clientCode'] ?? '');

View File

@ -214,7 +214,16 @@ function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
$out = []; $out = [];
foreach ($rows as $r) { 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 $daysSince = $lastAt !== null
? (int)floor(($now - $lastAt) / 86400) ? (int)floor(($now - $lastAt) / 86400)
: null; : null;
@ -223,9 +232,7 @@ function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
'coachUsername' => $r['coachUsername'] ?? $r['coachID'], 'coachUsername' => $r['coachUsername'] ?? $r['coachID'],
'lastQuestionnaireID' => $r['lastQuestionnaireID'], 'lastQuestionnaireID' => $r['lastQuestionnaireID'],
'lastQuestionnaireName' => $r['lastQuestionnaireName'] ?? $r['lastQuestionnaireID'], 'lastQuestionnaireName' => $r['lastQuestionnaireName'] ?? $r['lastQuestionnaireID'],
'lastCompletedAt' => $r['lastCompletedAt'] 'lastCompletedAt' => $completedTs ? date('Y-m-d H:i', $completedTs) : '',
? date('Y-m-d H:i', (int)$r['lastCompletedAt']) : '',
'lastActivityAt' => $lastAt ? date('Y-m-d H:i', $lastAt) : '',
'daysSinceLastActivity' => $daysSince, 'daysSinceLastActivity' => $daysSince,
'note' => $r['followupNote'] ?? '', 'note' => $r['followupNote'] ?? '',
]; ];
@ -280,7 +287,8 @@ function qdb_coach_activity_list(PDO $pdo, array $tokenRec): array {
$sql = " $sql = "
SELECT co.coachID, co.username, SELECT co.coachID, co.username,
sv.username AS supervisorUsername, 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 (SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID) AS totalSubmissions, WHERE c.coachID = co.coachID) AS totalSubmissions,

View File

@ -290,7 +290,9 @@ function qdb_export_app_bulk_answers_for_coach(PDO $pdo, array $tokenRec): array
return []; return [];
} }
$stmt = $pdo->prepare( $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]); $stmt->execute([':cid' => $coachID]);
$out = []; $out = [];

View File

@ -614,9 +614,9 @@ function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array
json_error('INVALID_FIELD', 'clientCode is required', 400); 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( $stmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID, "SELECT cl.clientCode, cl.coachID, cl.archived, cl.archivedAt,
co.username AS coachUsername, co.username AS coachUsername,
sv.username AS supervisorUsername sv.username AS supervisorUsername
FROM client cl FROM client cl

View File

@ -32,6 +32,8 @@ CREATE TABLE IF NOT EXISTS coach (
CREATE TABLE IF NOT EXISTS client ( CREATE TABLE IF NOT EXISTS client (
clientCode TEXT NOT NULL PRIMARY KEY, clientCode TEXT NOT NULL PRIMARY KEY,
coachID TEXT NOT NULL, coachID TEXT NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
archivedAt INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(coachID) REFERENCES coach(coachID) FOREIGN KEY(coachID) REFERENCES coach(coachID)
); );

View File

@ -41,6 +41,56 @@ final class ClientsLifecycleTest extends QdbTestCase
$this->assertSame($code, $res['data']['clientCode']); $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 public function testAdminDeletesClient(): void
{ {
$token = $this->api()->loginWebAndGetToken( $token = $this->api()->loginWebAndGetToken(

View File

@ -11,8 +11,12 @@ final class RbacTest extends TestCase
public function testAdminSeesAllClients(): void public function testAdminSeesAllClients(): void
{ {
[$clause, $params] = rbac_client_filter(['role' => 'admin', 'entityID' => 'a1'], 'cl'); [$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); $this->assertSame([], $params);
[$allClause] = rbac_client_filter(['role' => 'admin', 'entityID' => 'a1'], 'cl', 'all');
$this->assertStringNotContainsString('archived', $allClause);
} }
public function testSupervisorFilterBindsEntity(): void public function testSupervisorFilterBindsEntity(): void

View File

@ -2431,6 +2431,107 @@ body.modal-open {
border-top: none; border-top: none;
padding-top: 0; 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 { .client-detail-panel {
padding: 12px 8px 8px 32px; padding: 12px 8px 8px 32px;
background: var(--surface-muted); background: var(--surface-muted);

View 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>`;
}

View File

@ -1,12 +1,19 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js'; import { pageHeaderHTML, showToast } from '../app.js';
import {
isClientArchived,
confirmAndPatchClientArchive,
clientTableActionsHTML,
} from '../client-archive.js';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const ARCHIVE_FILTER_KEY = 'clientsArchiveFilter';
let clientsList = []; let clientsList = [];
let coachesList = []; let coachesList = [];
let scoringProfileCatalog = []; let scoringProfileCatalog = [];
let filterSearch = ''; let filterSearch = '';
let archiveFilter = localStorage.getItem(ARCHIVE_FILTER_KEY) || 'active';
let page = 0; let page = 0;
let expandedClientCode = null; let expandedClientCode = null;
let expandedQnKey = null; let expandedQnKey = null;
@ -36,8 +43,11 @@ export async function clientsPage() {
async function loadClients() { async function loadClients() {
try { try {
const archiveQuery = archiveFilter !== 'active'
? `?archiveFilter=${encodeURIComponent(archiveFilter)}`
: '';
const [clientsData, assignData] = await Promise.all([ const [clientsData, assignData] = await Promise.all([
apiGet('clients.php'), apiGet(`clients.php${archiveQuery}`),
apiGet('assignments.php'), apiGet('assignments.php'),
]); ]);
clientsList = clientsData.clients || []; clientsList = clientsData.clients || [];
@ -55,13 +65,23 @@ function renderClients() {
const container = document.getElementById('clientsContent'); const container = document.getElementById('clientsContent');
if (!clientsList.length) { 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 = ` container.innerHTML = `
<div class="card"> <div class="card">
<div class="empty-state"> <div class="filter-bar clients-list-filters">
<h3>No clients found</h3> ${archiveFilterSelectHTML()}
<p>Create the first client above.</p> <input type="search" id="clientListSearch" class="filter-search" placeholder="Search client or counselor…" value="${esc(filterSearch)}">
</div> </div>
<div class="empty-state">${emptyMsg}</div>
</div>`; </div>`;
bindArchiveFilterControl();
document.getElementById('clientListSearch')?.addEventListener('input', (e) => {
filterSearch = e.target.value.trim();
});
return; return;
} }
@ -70,7 +90,8 @@ function renderClients() {
container.innerHTML = ` container.innerHTML = `
<div class="card"> <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)}"> <input type="search" id="clientListSearch" class="filter-search" placeholder="Search client or counselor…" value="${esc(filterSearch)}">
<span class="data-toolbar-hint" id="clientListCount"></span> <span class="data-toolbar-hint" id="clientListCount"></span>
</div> </div>
@ -94,6 +115,7 @@ function renderClients() {
<div class="pagination-bar" id="clientsPagination"></div> <div class="pagination-bar" id="clientsPagination"></div>
</div>`; </div>`;
bindArchiveFilterControl();
document.getElementById('clientListSearch').addEventListener('input', (e) => { document.getElementById('clientListSearch').addEventListener('input', (e) => {
filterSearch = e.target.value.trim(); filterSearch = e.target.value.trim();
page = 0; page = 0;
@ -104,6 +126,31 @@ function renderClients() {
renderClientTableBody(); 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() { function getFilteredClientsList() {
let list = clientsList; let list = clientsList;
if (filterSearch) { if (filterSearch) {
@ -149,6 +196,12 @@ function renderClientTableBody() {
body.querySelectorAll('.delete-client-btn').forEach(btn => { body.querySelectorAll('.delete-client-btn').forEach(btn => {
btn.addEventListener('click', () => deleteClient(btn.dataset.code)); 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); bindCoachBandPickerEvents(body);
} }
@ -652,10 +705,18 @@ function clientHasResponseData(c) {
return Number(c.hasResponseData) === 1; 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) { function clientRowHTML(c) {
const coach = c.coachUsername const coach = c.coachUsername
? `<strong>${esc(c.coachUsername)}</strong>` ? `<strong>${esc(c.coachUsername)}</strong>`
: `<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>`; : `<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>`;
const archived = isClientArchived(c);
const canExpand = clientHasResponseData(c); const canExpand = clientHasResponseData(c);
const expanded = canExpand && expandedClientCode === c.clientCode; const expanded = canExpand && expandedClientCode === c.clientCode;
const expandControl = canExpand const expandControl = canExpand
@ -665,17 +726,20 @@ function clientRowHTML(c) {
? `<td class="client-profile-dots-cell">${profileDotsHTML(c.scoringProfiles || [], c.clientCode)}</td>` ? `<td class="client-profile-dots-cell">${profileDotsHTML(c.scoringProfiles || [], c.clientCode)}</td>`
: ''; : '';
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3; const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
const actions = clientTableActionsHTML({
clientCode: esc(c.clientCode),
archived,
showDelete: true,
});
return ` return `
<tr> <tr class="${archived ? 'client-row-archived' : ''}">
<td> <td>
${expandControl}<strong>${esc(c.clientCode)}</strong> ${expandControl}<strong>${esc(c.clientCode)}</strong>${clientArchivedLabel(c)}
</td> </td>
<td>${coach}</td> <td>${coach}</td>
${profileCol} ${profileCol}
<td> <td class="client-actions-cell">${actions}</td>
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
</td>
</tr> </tr>
${expanded ? ` ${expanded ? `
<tr class="client-detail-row"> <tr class="client-detail-row">
@ -728,6 +792,30 @@ function toggleVersion(clientCode, questionnaireID, submissionID) {
renderClientTableBody(); 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) { async function deleteClient(clientCode) {
if (!confirm(`Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`)) return; if (!confirm(`Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`)) return;
try { try {

View File

@ -1,5 +1,6 @@
import { apiGet, apiPut } from '../api.js'; import { apiGet, apiPut } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js'; import { pageHeaderHTML, showToast } from '../app.js';
import { clientTableActionsHTML, confirmAndPatchClientArchive } from '../client-archive.js';
let overview = null; let overview = null;
let staleClients = []; let staleClients = [];
@ -159,7 +160,7 @@ function renderInsights() {
<div class="card" style="margin-top:20px"> <div class="card" style="margin-top:20px">
<h3 style="margin:0 0 8px">Needs follow-up</h3> <h3 style="margin:0 0 8px">Needs follow-up</h3>
<p class="data-toolbar-hint" style="margin:0 0 12px"> <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> </p>
<div class="filter-bar"> <div class="filter-bar">
<input type="search" id="followupListSearch" class="filter-search" <input type="search" id="followupListSearch" class="filter-search"
@ -170,8 +171,8 @@ function renderInsights() {
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Client</th><th>Counselor</th><th>Last completed</th><th>Last at</th> <th>Client</th><th>Counselor</th><th>Last questionnaire</th>
<th>Last activity</th><th>Days idle</th><th>Note</th> <th>Completed at</th><th>Days idle</th><th>Note</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody id="followupTableBody"></tbody> <tbody id="followupTableBody"></tbody>
@ -196,7 +197,6 @@ function followupSearchText(c) {
c.lastQuestionnaireID, c.lastQuestionnaireID,
c.note, c.note,
c.lastCompletedAt, c.lastCompletedAt,
c.lastActivityAt,
c.daysSinceLastActivity != null ? String(c.daysSinceLastActivity) : '', c.daysSinceLastActivity != null ? String(c.daysSinceLastActivity) : '',
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
} }
@ -208,18 +208,23 @@ function filteredFollowupClients() {
} }
function followupRowHTML(c) { function followupRowHTML(c) {
const code = esc(c.clientCode);
return ` return `
<tr data-client="${esc(c.clientCode)}"> <tr data-client="${code}">
<td><code>${esc(c.clientCode)}</code></td> <td><code>${code}</code></td>
<td>${esc(c.coachUsername)}</td> <td>${esc(c.coachUsername)}</td>
<td>${esc(c.lastQuestionnaireName || '—')}</td> <td>${esc(c.lastQuestionnaireName || '—')}</td>
<td>${esc(c.lastCompletedAt || '—')}</td> <td>${esc(c.lastCompletedAt || '—')}</td>
<td>${esc(c.lastActivityAt || '—')}</td>
<td>${c.daysSinceLastActivity != null ? c.daysSinceLastActivity : '—'}</td> <td>${c.daysSinceLastActivity != null ? c.daysSinceLastActivity : '—'}</td>
<td> <td class="followup-note-cell">
<textarea class="followup-note-input" rows="2" data-client="${esc(c.clientCode)}" <textarea class="followup-note-input" rows="2" data-client="${code}"
placeholder="Follow-up note…">${esc(c.note || '')}</textarea> 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> </td>
</tr>`; </tr>`;
} }
@ -262,6 +267,17 @@ function renderFollowupTableBody() {
saveNote(btn.dataset.client, ta); 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) { async function saveNote(clientCode, ta) {