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

@ -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);

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

View File

@ -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) {