349 lines
15 KiB
JavaScript
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;
|
|
}
|