Files
barometer-server/website/js/pages/sessions.js
Tom Hempel e589b46a8a
Some checks failed
PHPUnit / test (push) Has been cancelled
fixed session numbering
2026-07-01 08:44:29 +02:00

349 lines
15 KiB
JavaScript

import { apiGet, apiPost, apiPut, apiDelete, apiPatch } from '../api.js';
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
export async function sessionsPage() {
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Sessions', '<span id="sessionHeaderActions"></span>', {
subtitle: 'Define which session measures belong to each program session',
})}
<div id="sessionsContent"><div class="spinner"></div></div>
`;
if (canEdit()) {
document.getElementById('sessionHeaderActions').innerHTML =
'<button type="button" class="btn btn-primary" id="newSessionBtn">+ New session</button>';
}
try {
const [sessionsData, questionnairesData] = await Promise.all([
apiGet('program_sessions'),
apiGet('questionnaires'),
]);
renderSessions(
sessionsData.sessions || [],
questionnairesData.questionnaires || [],
sessionsData.settings || {},
);
} catch (e) {
showToast(e.message, 'error');
document.getElementById('sessionsContent').innerHTML =
`<p class="error-text">${esc(e.message)}</p>`;
}
}
function renderSessions(sessions, questionnaires, settings) {
const el = document.getElementById('sessionsContent');
const sorted = [...sessions].sort((a, b) =>
effectiveSessionNumber(a) - effectiveSessionNumber(b)
|| String(a.name).localeCompare(String(b.name)),
);
const autoRepeat = !!settings.autoRepeatSessions;
el.innerHTML = `
<div class="card scoring-profiles-card" style="margin-bottom:16px">
<div class="scoring-profile-header">
<div>
<h3 class="scoring-section-title">Cycle behavior</h3>
<p class="scoring-section-desc">
When enabled, completing every active session starts a new cycle at session 1.
Counselors re-complete the assigned session measures for the next cycle.
</p>
</div>
</div>
${canEdit() ? `
<label class="checkbox-inline scoring-profile-active-row">
<input type="checkbox" id="autoRepeatSessionsToggle" ${autoRepeat ? 'checked' : ''}>
Automatically repeat sessions when the last session is completed
</label>
` : `
<p class="data-toolbar-hint" style="margin:0">
Auto-repeat is ${autoRepeat ? 'enabled' : 'disabled'}.
</p>
`}
</div>
<div class="scoring-profiles-section">
<div class="card scoring-profiles-card">
<div class="scoring-profile-header">
<div>
<h3 class="scoring-section-title">Program sessions</h3>
<p class="scoring-section-desc">
Counselors see these sessions in the app. Assign one or more session measures to each visit slot.
</p>
</div>
</div>
<div id="sessionsList" class="scoring-profiles-list">
${sorted.length
? sorted.map(s => sessionCardHTML(s, questionnaires)).join('')
: '<p class="scoring-empty-hint">No sessions defined yet. Create one to group session measures by visit.</p>'}
</div>
</div>
</div>`;
if (!canEdit()) return;
document.getElementById('autoRepeatSessionsToggle')?.addEventListener('change', async (e) => {
const enabled = e.target.checked;
try {
await apiPatch('program_sessions', { autoRepeatSessions: enabled });
showToast(enabled ? 'Auto-repeat enabled' : 'Auto-repeat disabled', 'success');
} catch (err) {
e.target.checked = !enabled;
showToast(err.message, 'error');
}
});
document.getElementById('newSessionBtn')?.addEventListener('click', () => {
openSessionModal(null, questionnaires, sorted);
});
el.querySelectorAll('.edit-session-btn').forEach(btn => {
btn.addEventListener('click', () => {
const session = sorted.find(x => x.sessionID === btn.dataset.id);
openSessionModal(session, questionnaires, sorted);
});
});
el.querySelectorAll('.delete-session-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!(await confirmAction({
title: 'Delete session',
message: `Delete session "${btn.dataset.name}"?`,
confirmLabel: 'Delete',
variant: 'danger',
}))) return;
try {
await apiDelete('program_sessions', { sessionID: btn.dataset.id });
showToast('Session deleted', 'success');
await sessionsPage();
} catch (err) {
showToast(err.message, 'error');
}
});
});
}
function sessionCardHTML(session, questionnaires) {
const qnById = Object.fromEntries((questionnaires || []).map(q => [q.questionnaireID, q]));
const members = session.questionnaires || [];
const activeBadge = session.isActive
? '<span class="badge badge-active">Active</span>'
: '<span class="badge badge-draft">Inactive</span>';
const memberRows = members.map(m => {
const q = qnById[m.questionnaireID];
const name = q?.name || m.name || m.questionnaireID;
return `<div class="scoring-profile-q-row"><span class="scoring-profile-q-name">${esc(name)}</span></div>`;
}).join('');
return `
<div class="scoring-profile-card">
<div class="scoring-profile-card-top">
<div class="scoring-profile-card-main">
<div class="scoring-profile-name-row">
<strong>${esc(session.name)}</strong>
${activeBadge}
</div>
<p class="scoring-profile-bands">Session ${effectiveSessionNumber(session)}</p>
${session.description
? `<p class="scoring-profile-desc">${esc(session.description)}</p>`
: ''}
</div>
${canEdit() ? `
<div class="scoring-profile-card-actions">
<button type="button" class="btn btn-sm edit-session-btn"
data-id="${esc(session.sessionID)}">Edit</button>
<button type="button" class="btn btn-sm btn-danger delete-session-btn"
data-id="${esc(session.sessionID)}"
data-name="${esc(session.name)}">Delete</button>
</div>` : ''}
</div>
<p class="scoring-profile-member-count">${members.length} session measure(s)</p>
<div class="scoring-profile-members">${memberRows
|| '<p class="scoring-empty-hint">No session measures assigned</p>'}</div>
</div>`;
}
function openSessionModal(session, questionnaires, allSessions) {
const isEdit = !!session;
const members = session?.questionnaires?.length
? session.questionnaires.map(m => ({
questionnaireID: m.questionnaireID,
orderIndex: m.orderIndex ?? 0,
}))
: [];
const selectedIds = new Set(members.map(m => m.questionnaireID));
const defaultSessionNumber = (allSessions?.length ?? 0) + 1;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal-dialog modal-dialog-wide">
<h2>${isEdit ? 'Edit session' : 'New session'}</h2>
<p class="modal-subtitle">Name the visit slot and choose which session measures counselors complete during it.</p>
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="form-group">
<label for="sessName">Name</label>
<input type="text" id="sessName" value="${esc(session?.name || '')}" placeholder="e.g. Baseline visit">
</div>
<div class="form-group">
<label for="sessNumber">Session number</label>
<input type="number" id="sessNumber" min="1" value="${session ? effectiveSessionNumber(session) : defaultSessionNumber}">
</div>
</div>
<div class="form-group scoring-profile-active-row">
<label class="checkbox-inline">
<input type="checkbox" id="sessActive" ${session?.isActive !== 0 ? 'checked' : ''}>
Active (visible in app)
</label>
</div>
<div class="form-group">
<label for="sessDesc">Description</label>
<textarea id="sessDesc" rows="2" placeholder="Optional note for counselors">${esc(session?.description || '')}</textarea>
</div>
<h4 class="scoring-modal-section-title">Session measures</h4>
<p class="data-toolbar-hint" style="margin:0 0 10px">Add questionnaires in the order they should appear for this session.</p>
<div id="sessQnPicker" class="scoring-profile-picker"></div>
<div class="modal-actions">
<button type="button" class="btn btn-sm" id="sessCancel">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="sessSave">${isEdit ? 'Save' : 'Create'}</button>
</div>
</div>`;
document.body.appendChild(overlay);
document.body.classList.add('modal-open');
const picker = overlay.querySelector('#sessQnPicker');
const sortedQn = [...(questionnaires || [])].sort((a, b) =>
(a.orderIndex ?? 0) - (b.orderIndex ?? 0),
);
function renderPicker() {
const ordered = members.map(m => ({
...m,
q: sortedQn.find(q => q.questionnaireID === m.questionnaireID),
}));
const available = sortedQn.filter(q => !selectedIds.has(q.questionnaireID));
picker.innerHTML = `
${ordered.map((m, idx) => `
<div class="scoring-profile-q-row" data-qn="${esc(m.questionnaireID)}">
<span class="scoring-profile-q-name">${esc(m.q?.name || m.questionnaireID)}</span>
<span class="scoring-profile-q-actions">
<button type="button" class="btn btn-sm sess-move-up" ${idx === 0 ? 'disabled' : ''} title="Move up">↑</button>
<button type="button" class="btn btn-sm sess-move-down" ${idx === ordered.length - 1 ? 'disabled' : ''} title="Move down">↓</button>
<button type="button" class="btn btn-sm btn-danger sess-remove" title="Remove">Remove</button>
</span>
</div>
`).join('')}
${available.length ? `
<div class="scoring-profile-add-row">
<select id="sessAddSelect">
<option value="">Add session measure…</option>
${available.map(q => `<option value="${esc(q.questionnaireID)}">${esc(q.name)}</option>`).join('')}
</select>
<button type="button" class="btn btn-sm" id="sessAddBtn">Add</button>
</div>` : ''}`;
picker.querySelectorAll('.sess-remove').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('.scoring-profile-q-row');
const id = row?.dataset.qn;
if (!id) return;
selectedIds.delete(id);
const i = members.findIndex(m => m.questionnaireID === id);
if (i >= 0) members.splice(i, 1);
renderPicker();
});
});
picker.querySelectorAll('.sess-move-up').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('.scoring-profile-q-row');
const id = row?.dataset.qn;
const i = members.findIndex(m => m.questionnaireID === id);
if (i > 0) {
[members[i - 1], members[i]] = [members[i], members[i - 1]];
renderPicker();
}
});
});
picker.querySelectorAll('.sess-move-down').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('.scoring-profile-q-row');
const id = row?.dataset.qn;
const i = members.findIndex(m => m.questionnaireID === id);
if (i >= 0 && i < members.length - 1) {
[members[i], members[i + 1]] = [members[i + 1], members[i]];
renderPicker();
}
});
});
const addBtn = overlay.querySelector('#sessAddBtn');
if (addBtn) {
const freshAddBtn = addBtn.cloneNode(true);
addBtn.replaceWith(freshAddBtn);
freshAddBtn.addEventListener('click', () => {
const sel = overlay.querySelector('#sessAddSelect');
const id = sel?.value;
if (!id || selectedIds.has(id)) return;
selectedIds.add(id);
members.push({ questionnaireID: id, orderIndex: members.length });
renderPicker();
});
}
}
renderPicker();
const close = () => {
overlay.remove();
document.body.classList.remove('modal-open');
};
overlay.querySelector('#sessCancel')?.addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelector('#sessSave')?.addEventListener('click', async () => {
const name = overlay.querySelector('#sessName')?.value.trim();
const description = overlay.querySelector('#sessDesc')?.value.trim() || '';
const sessionNumber = parseInt(overlay.querySelector('#sessNumber')?.value || '1', 10);
const isActive = overlay.querySelector('#sessActive')?.checked;
if (!name) {
showToast('Session name is required', 'error');
return;
}
const payload = {
name,
description,
sessionNumber: Number.isFinite(sessionNumber) ? Math.max(1, sessionNumber) : 1,
isActive,
questionnaires: members.map((m, idx) => ({
questionnaireID: m.questionnaireID,
orderIndex: idx,
})),
};
try {
if (isEdit) {
await apiPut('program_sessions', { sessionID: session.sessionID, ...payload });
showToast('Session saved', 'success');
} else {
await apiPost('program_sessions', payload);
showToast('Session created', 'success');
}
close();
await sessionsPage();
} catch (err) {
showToast(err.message, 'error');
}
});
}
function effectiveSessionNumber(session) {
if (!session) return 1;
return Math.max(session.sessionNumber ?? 1, (session.orderIndex ?? 0) + 1);
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}