From 5dff24a232022390c052adbb09523b947301d74e Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Fri, 12 Jun 2026 20:55:12 +0200 Subject: [PATCH] added functionality to archive clients --- common.php | 35 +++++-- db_init.php | 13 ++- handlers/app_questionnaires.php | 6 +- handlers/auth.php | 4 +- handlers/clients.php | 54 ++++++++++- lib/analytics.php | 18 +++- lib/app_answers.php | 4 +- lib/submissions.php | 4 +- schema.sql | 2 + tests/Integration/ClientsLifecycleTest.php | 50 ++++++++++ tests/Unit/RbacTest.php | 6 +- website/css/style.css | 101 +++++++++++++++++++ website/js/client-archive.js | 43 ++++++++ website/js/pages/clients.js | 108 +++++++++++++++++++-- website/js/pages/insights.js | 36 +++++-- 15 files changed, 436 insertions(+), 48 deletions(-) create mode 100644 website/js/client-archive.js diff --git a/common.php b/common.php index 12dfaf6..15e0f8a 100644 --- a/common.php +++ b/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 --- diff --git a/db_init.php b/db_init.php index b5a567f..7ac22dd 100644 --- a/db_init.php +++ b/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 ( diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index b40f8f1..a1b9e5b 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -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]); diff --git a/handlers/auth.php b/handlers/auth.php index 9bcb302..3778018 100644 --- a/handlers/auth.php +++ b/handlers/auth.php @@ -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( diff --git a/handlers/clients.php b/handlers/clients.php index 5a7b8f4..2b4950b 100644 --- a/handlers/clients.php +++ b/handlers/clients.php @@ -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'] ?? ''); diff --git a/lib/analytics.php b/lib/analytics.php index 75d67b1..bd59592 100644 --- a/lib/analytics.php +++ b/lib/analytics.php @@ -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, diff --git a/lib/app_answers.php b/lib/app_answers.php index 6737a56..8c85783 100644 --- a/lib/app_answers.php +++ b/lib/app_answers.php @@ -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 = []; diff --git a/lib/submissions.php b/lib/submissions.php index ada1579..6f49cf4 100644 --- a/lib/submissions.php +++ b/lib/submissions.php @@ -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 diff --git a/schema.sql b/schema.sql index 6e0bdce..5d082fa 100644 --- a/schema.sql +++ b/schema.sql @@ -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) ); diff --git a/tests/Integration/ClientsLifecycleTest.php b/tests/Integration/ClientsLifecycleTest.php index 318587e..2225b9d 100644 --- a/tests/Integration/ClientsLifecycleTest.php +++ b/tests/Integration/ClientsLifecycleTest.php @@ -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( diff --git a/tests/Unit/RbacTest.php b/tests/Unit/RbacTest.php index a45bd86..1ad807b 100644 --- a/tests/Unit/RbacTest.php +++ b/tests/Unit/RbacTest.php @@ -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 diff --git a/website/css/style.css b/website/css/style.css index 1c09b9c..5d9f399 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -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); diff --git a/website/js/client-archive.js b/website/js/client-archive.js new file mode 100644 index 0000000..97b90de --- /dev/null +++ b/website/js/client-archive.js @@ -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 + ? `` + : ``; + const deleteBtn = showDelete + ? `` + : ''; + + return `
${archiveBtn}${deleteBtn}
`; +} diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js index dfbb55c..e263b72 100644 --- a/website/js/pages/clients.js +++ b/website/js/pages/clients.js @@ -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' + ? '

No archived clients

Archived clients are hidden from assignments and follow-up.

' + : archiveFilter === 'all' + ? '

No clients found

Create the first client above.

' + : '

No active clients

Create a client above or switch the filter to view archived clients.

'; container.innerHTML = `
-
-

No clients found

-

Create the first client above.

+
+ ${archiveFilterSelectHTML()} +
+
${emptyMsg}
`; + bindArchiveFilterControl(); + document.getElementById('clientListSearch')?.addEventListener('input', (e) => { + filterSearch = e.target.value.trim(); + }); return; } @@ -70,7 +90,8 @@ function renderClients() { container.innerHTML = `
-
+
+ ${archiveFilterSelectHTML()}
@@ -94,6 +115,7 @@ function renderClients() {
`; + bindArchiveFilterControl(); document.getElementById('clientListSearch').addEventListener('input', (e) => { filterSearch = e.target.value.trim(); page = 0; @@ -104,6 +126,31 @@ function renderClients() { renderClientTableBody(); } +function archiveFilterSelectHTML() { + return ` + `; +} + +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 = '
'; + 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 ? ` Archived` : ''; +} + function clientRowHTML(c) { const coach = c.coachUsername ? `${esc(c.coachUsername)}` : `Unassigned`; + const archived = isClientArchived(c); const canExpand = clientHasResponseData(c); const expanded = canExpand && expandedClientCode === c.clientCode; const expandControl = canExpand @@ -665,17 +726,20 @@ function clientRowHTML(c) { ? `${profileDotsHTML(c.scoringProfiles || [], c.clientCode)}` : ''; const colCount = scoringProfileCatalog.length > 0 ? 4 : 3; + const actions = clientTableActionsHTML({ + clientCode: esc(c.clientCode), + archived, + showDelete: true, + }); return ` - + - ${expandControl}${esc(c.clientCode)} + ${expandControl}${esc(c.clientCode)}${clientArchivedLabel(c)} ${coach} ${profileCol} - - - + ${actions} ${expanded ? ` @@ -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 { diff --git a/website/js/pages/insights.js b/website/js/pages/insights.js index a2ede30..7aed228 100644 --- a/website/js/pages/insights.js +++ b/website/js/pages/insights.js @@ -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() {

Needs follow-up

- 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.

- ClientCounselorLast completedLast at - Last activityDays idleNote + ClientCounselorLast questionnaire + Completed atDays idleNote @@ -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 ` - - ${esc(c.clientCode)} + + ${code} ${esc(c.coachUsername)} ${esc(c.lastQuestionnaireName || '—')} ${esc(c.lastCompletedAt || '—')} - ${esc(c.lastActivityAt || '—')} ${c.daysSinceLastActivity != null ? c.daysSinceLastActivity : '—'} - - - +
+ +
+ + + ${clientTableActionsHTML({ clientCode: code, archived: false, showDelete: false })} `; } @@ -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) {