added submission history, data insights, download all functionality
This commit is contained in:
@ -1720,6 +1720,176 @@ table.data-table tbody tr.row-orphan-category td {
|
||||
border-radius: 8px;
|
||||
color: var(--toast-error-fg);
|
||||
}
|
||||
.insights-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.insights-kpi {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.insights-kpi-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.insights-kpi-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.insights-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.insights-chart-card h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.insights-chart-hint {
|
||||
margin: 0 0 14px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.insights-vchart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
height: 160px;
|
||||
padding: 8px 4px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.insights-vchart-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
.insights-vchart-bar-wrap {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 36px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
.insights-vchart-bar {
|
||||
width: 100%;
|
||||
min-height: 2px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background: linear-gradient(180deg, var(--primary-hover), var(--primary));
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
.insights-vchart-value {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.insights-vchart-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.insights-hchart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.insights-hchart-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 28%) 1fr minmax(40px, auto);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.insights-hchart-label {
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.insights-hchart-track {
|
||||
height: 22px;
|
||||
background: var(--surface-muted);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.insights-hchart-fill {
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-hover));
|
||||
}
|
||||
.insights-hchart-fill--warn {
|
||||
background: linear-gradient(90deg, #d97706, var(--warning));
|
||||
}
|
||||
.insights-hchart-fill--muted {
|
||||
background: var(--border);
|
||||
}
|
||||
.insights-hchart-num {
|
||||
text-align: right;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.followup-note-input {
|
||||
width: 100%;
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
resize: vertical;
|
||||
}
|
||||
.export-actions-cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.export-actions-cell .btn {
|
||||
margin: 0;
|
||||
}
|
||||
.coach-recent-panel {
|
||||
padding: 12px 8px 8px 32px;
|
||||
background: var(--surface-muted);
|
||||
border-radius: 8px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.data-table-compact td,
|
||||
.data-table-compact th {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 6px 0 0;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
min-width: auto;
|
||||
}
|
||||
.coach-detail-row td {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
input:disabled,
|
||||
select:disabled {
|
||||
background: var(--surface-muted);
|
||||
|
||||
@ -29,6 +29,12 @@
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
<li data-nav-roles="admin supervisor">
|
||||
<a href="#/coaches">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="8.5" cy="7" r="4"/><polyline points="17 11 19 13 23 9"/></svg>
|
||||
Coach activity
|
||||
</a>
|
||||
</li>
|
||||
<li data-nav-roles="admin supervisor">
|
||||
<a href="#/clients">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
@ -41,6 +47,12 @@
|
||||
Assign Clients
|
||||
</a>
|
||||
</li>
|
||||
<li data-nav-roles="admin supervisor">
|
||||
<a href="#/insights">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||
Insights
|
||||
</a>
|
||||
</li>
|
||||
<li data-nav-roles="admin supervisor">
|
||||
<a href="#/translations">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||
@ -56,7 +68,7 @@
|
||||
<li data-nav-roles="admin">
|
||||
<a href="#/dev">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>
|
||||
Dev import
|
||||
Admin Settings
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@ -9,6 +9,8 @@ import { assignmentsPage } from './pages/assignments.js';
|
||||
import { clientsPage } from './pages/clients.js';
|
||||
import { translationsPage } from './pages/translations.js';
|
||||
import { devPage } from './pages/dev.js';
|
||||
import { insightsPage } from './pages/insights.js';
|
||||
import { coachesPage } from './pages/coaches.js';
|
||||
|
||||
// Auth state
|
||||
export function isLoggedIn() {
|
||||
@ -109,6 +111,8 @@ addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
|
||||
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
|
||||
addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage));
|
||||
addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage));
|
||||
addRoute('/insights', roleGuard(['admin', 'supervisor'], insightsPage));
|
||||
addRoute('/coaches', roleGuard(['admin', 'supervisor'], coachesPage));
|
||||
addRoute('/dev', roleGuard(['admin'], devPage));
|
||||
|
||||
// Nav link handling & logout
|
||||
|
||||
198
website/js/pages/coaches.js
Normal file
198
website/js/pages/coaches.js
Normal file
@ -0,0 +1,198 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import { showToast } from '../app.js';
|
||||
|
||||
let coachesList = [];
|
||||
let expandedCoachId = null;
|
||||
let recentByCoach = {};
|
||||
let filterSearch = '';
|
||||
|
||||
export async function coachesPage() {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Coach activity</h1>
|
||||
<div class="actions"><a href="#/" class="btn">← Dashboard</a></div>
|
||||
</div>
|
||||
<div id="coachesContent"><div class="spinner"></div></div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const data = await apiGet('coaches.php');
|
||||
coachesList = data.coaches || [];
|
||||
renderCoaches();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
document.getElementById('coachesContent').innerHTML =
|
||||
`<p class="error-text">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function coachSearchText(c) {
|
||||
return [c.username, c.coachID, c.supervisorUsername].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function coachHasUploads(c) {
|
||||
return (c.totalSubmissions ?? 0) > 0;
|
||||
}
|
||||
|
||||
function filteredCoaches() {
|
||||
const q = filterSearch.trim().toLowerCase();
|
||||
if (!q) return coachesList;
|
||||
return coachesList.filter(c => coachSearchText(c).toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function renderCoaches() {
|
||||
const el = document.getElementById('coachesContent');
|
||||
|
||||
if (!coachesList.length) {
|
||||
el.innerHTML = `<div class="card empty-state"><h3>No coaches</h3></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<p class="data-toolbar-hint" style="margin:0 0 12px">Track uploads and client load per coach. Expand a row for recent submissions.</p>
|
||||
<div class="filter-bar">
|
||||
<input type="search" id="coachListSearch" class="filter-search"
|
||||
placeholder="Search coach or supervisor…" value="${esc(filterSearch)}">
|
||||
<span class="data-toolbar-hint" id="coachListCount"></span>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Coach</th><th>Supervisor</th><th>Clients</th>
|
||||
<th>Total uploads</th><th>7d</th><th>30d</th><th>Last upload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="coachTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('coachListSearch').addEventListener('input', (e) => {
|
||||
filterSearch = e.target.value;
|
||||
const filtered = filteredCoaches();
|
||||
if (
|
||||
expandedCoachId &&
|
||||
!filtered.some(c => c.coachID === expandedCoachId && coachHasUploads(c))
|
||||
) {
|
||||
expandedCoachId = null;
|
||||
}
|
||||
renderCoachTableBody();
|
||||
});
|
||||
|
||||
renderCoachTableBody();
|
||||
}
|
||||
|
||||
function coachRowHTML(c) {
|
||||
const canExpand = coachHasUploads(c);
|
||||
const expanded = canExpand && expandedCoachId === c.coachID;
|
||||
const recent = recentByCoach[c.coachID] || [];
|
||||
const expandControl = canExpand
|
||||
? `<button type="button" class="btn btn-sm btn-link coach-expand-btn" data-coach="${esc(c.coachID)}">${expanded ? '▼' : '▶'}</button> `
|
||||
: '';
|
||||
return `
|
||||
<tr class="coach-row" data-coach="${esc(c.coachID)}">
|
||||
<td>${expandControl}<strong>${esc(c.username)}</strong></td>
|
||||
<td>${esc(c.supervisorUsername || '—')}</td>
|
||||
<td>${c.clientCount ?? 0}</td>
|
||||
<td>${c.totalSubmissions ?? 0}</td>
|
||||
<td>${c.submissionsLast7d ?? 0}</td>
|
||||
<td>${c.submissionsLast30d ?? 0}</td>
|
||||
<td>${esc(c.lastSubmissionAt || '—')}</td>
|
||||
</tr>
|
||||
${expanded ? `
|
||||
<tr class="coach-detail-row">
|
||||
<td colspan="7">
|
||||
<div class="coach-recent-panel">
|
||||
${recent.length ? `
|
||||
<table class="data-table data-table-compact">
|
||||
<thead>
|
||||
<tr><th>When</th><th>Client</th><th>Questionnaire</th><th>Ver.</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${recent.map(s => `
|
||||
<tr>
|
||||
<td>${esc(s.submittedAt)}</td>
|
||||
<td><code>${esc(s.clientCode)}</code></td>
|
||||
<td>${esc(s.questionnaireName)}</td>
|
||||
<td>${s.version}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
` : '<p class="data-toolbar-hint">Loading…</p>'}
|
||||
</div>
|
||||
</td>
|
||||
</tr>` : ''}`;
|
||||
}
|
||||
|
||||
function renderCoachTableBody() {
|
||||
const body = document.getElementById('coachTableBody');
|
||||
const countEl = document.getElementById('coachListCount');
|
||||
if (!body) return;
|
||||
|
||||
const filtered = filteredCoaches();
|
||||
|
||||
if (!filtered.length) {
|
||||
body.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" style="text-align:center;color:var(--text-secondary);padding:24px">
|
||||
No coaches match
|
||||
</td>
|
||||
</tr>`;
|
||||
if (countEl) {
|
||||
countEl.textContent = filterSearch.trim()
|
||||
? `0 of ${coachesList.length} coaches`
|
||||
: '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = filtered.map(c => coachRowHTML(c)).join('');
|
||||
|
||||
if (countEl) {
|
||||
const q = filterSearch.trim();
|
||||
countEl.textContent = q
|
||||
? `${filtered.length} of ${coachesList.length} coaches`
|
||||
: `${coachesList.length} coach${coachesList.length === 1 ? '' : 'es'}`;
|
||||
}
|
||||
|
||||
body.querySelectorAll('.coach-expand-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => toggleCoach(btn.dataset.coach));
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleCoach(coachID) {
|
||||
const coach = coachesList.find(c => c.coachID === coachID);
|
||||
if (!coach || !coachHasUploads(coach)) {
|
||||
return;
|
||||
}
|
||||
if (expandedCoachId === coachID) {
|
||||
expandedCoachId = null;
|
||||
renderCoachTableBody();
|
||||
return;
|
||||
}
|
||||
expandedCoachId = coachID;
|
||||
renderCoachTableBody();
|
||||
if (!recentByCoach[coachID]) {
|
||||
try {
|
||||
const data = await apiGet(
|
||||
`coaches.php?coachID=${encodeURIComponent(coachID)}&recent=1`
|
||||
);
|
||||
recentByCoach[coachID] = data.submissions || [];
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
recentByCoach[coachID] = [];
|
||||
}
|
||||
renderCoachTableBody();
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s ?? '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
@ -11,9 +11,22 @@ export function devPage() {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Dev import</h1>
|
||||
<h1>Admin Settings</h1>
|
||||
</div>
|
||||
<div class="card" style="max-width:720px;margin-bottom:16px">
|
||||
<h2 style="margin:0 0 8px;font-size:1.1rem">Export all response data (ZIP)</h2>
|
||||
<p class="field-hint" style="margin:0 0 14px">
|
||||
Download one CSV per questionnaire in a single ZIP (full server data).
|
||||
<strong>Current data</strong> uses live answers;
|
||||
<strong>All versions</strong> includes every upload (one row per submission).
|
||||
</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:10px">
|
||||
<button type="button" class="btn btn-primary" id="exportAllCurrentZipBtn">Export all — current data (ZIP)</button>
|
||||
<button type="button" class="btn" id="exportAllVersionsZipBtn">Export all — all versions (ZIP)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="max-width:720px">
|
||||
<h2 style="margin:0 0 8px;font-size:1.1rem">Dev import</h2>
|
||||
<p class="field-hint callout-warn">
|
||||
<strong>Local / test only.</strong> Imports prefixed dev users (<code>dev_*</code>),
|
||||
clients (<code>DEV-CL-*</code>), and completed questionnaire runs.
|
||||
@ -150,6 +163,8 @@ export function devPage() {
|
||||
}
|
||||
});
|
||||
|
||||
wireAdminZipExport();
|
||||
|
||||
document.getElementById('devImportBtn').addEventListener('click', async () => {
|
||||
if (!parsedFixture) return;
|
||||
const btn = document.getElementById('devImportBtn');
|
||||
@ -184,3 +199,62 @@ export function devPage() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function filenameFromContentDisposition(res, fallback) {
|
||||
const disp = res.headers.get('Content-Disposition');
|
||||
if (!disp) return fallback;
|
||||
const m = /filename="([^"]+)"/i.exec(disp);
|
||||
return m ? m[1] : fallback;
|
||||
}
|
||||
|
||||
async function downloadExportZip(url, defaultFilename) {
|
||||
const token = localStorage.getItem('qdb_token');
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
||||
throw new Error(err.error?.message || err.error || 'Download failed');
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filenameFromContentDisposition(res, defaultFilename);
|
||||
a.click();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
function wireAdminZipExport() {
|
||||
document.getElementById('exportAllCurrentZipBtn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('exportAllCurrentZipBtn');
|
||||
btn.disabled = true;
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = 'Preparing ZIP…';
|
||||
try {
|
||||
await downloadExportZip('../api/export?exportAll=1', 'responses_export_current.zip');
|
||||
showToast('ZIP download started (current data)', 'success');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = prev;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('exportAllVersionsZipBtn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('exportAllVersionsZipBtn');
|
||||
btn.disabled = true;
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = 'Preparing ZIP…';
|
||||
try {
|
||||
await downloadExportZip('../api/export?exportAll=1&allVersions=1', 'responses_export_all_versions.zip');
|
||||
showToast('ZIP download started (all versions)', 'success');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = prev;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -124,6 +124,9 @@ function renderExportTableBody() {
|
||||
body.querySelectorAll('.export-btn').forEach(btn => {
|
||||
btn.addEventListener('click', onExportCsvClick);
|
||||
});
|
||||
body.querySelectorAll('.export-btn-all').forEach(btn => {
|
||||
btn.addEventListener('click', onExportAllVersionsClick);
|
||||
});
|
||||
}
|
||||
|
||||
function exportRowHTML(q) {
|
||||
@ -134,11 +137,14 @@ function exportRowHTML(q) {
|
||||
<td><span class="badge badge-${(q.state || 'draft').toLowerCase()}">${esc(q.state || 'draft')}</span></td>
|
||||
<td>${q.questionCount || 0}</td>
|
||||
<td>${q.completedCount ?? 0}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary export-btn" data-id="${esc(q.questionnaireID)}" data-name="${esc(q.name)}" data-version="${esc(q.version)}">
|
||||
Export CSV
|
||||
<td class="export-actions-cell">
|
||||
<button class="btn btn-sm btn-primary export-btn" data-id="${esc(q.questionnaireID)}" data-name="${esc(q.name)}" data-version="${esc(q.version)}" data-all="0">
|
||||
Export current
|
||||
</button>
|
||||
<a href="#/questionnaire/${esc(q.questionnaireID)}/results" class="btn btn-sm">View Results</a>
|
||||
<button class="btn btn-sm export-btn-all" data-id="${esc(q.questionnaireID)}" data-name="${esc(q.name)}">
|
||||
All versions
|
||||
</button>
|
||||
<a href="#/questionnaire/${esc(q.questionnaireID)}/results" class="btn btn-sm">Results</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
@ -176,32 +182,56 @@ function wireExportActions() {
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadExportCsv(btn, url, filename) {
|
||||
const token = localStorage.getItem('qdb_token');
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
||||
throw new Error(err.error?.message || err.error || 'Download failed');
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
async function onExportCsvClick(ev) {
|
||||
const btn = ev.currentTarget;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Downloading...';
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = 'Downloading…';
|
||||
try {
|
||||
const token = localStorage.getItem('qdb_token');
|
||||
const res = await fetch(`../api/export.php?questionnaireID=${encodeURIComponent(btn.dataset.id)}&format=csv`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
||||
throw new Error(err.error);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${btn.dataset.name}_v${btn.dataset.version}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
const url = `../api/export?questionnaireID=${encodeURIComponent(btn.dataset.id)}`;
|
||||
await downloadExportCsv(btn, url, `${btn.dataset.name}_v${btn.dataset.version}.csv`);
|
||||
showToast('Download started', 'success');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Export CSV';
|
||||
btn.textContent = prev;
|
||||
}
|
||||
}
|
||||
|
||||
async function onExportAllVersionsClick(ev) {
|
||||
const btn = ev.currentTarget;
|
||||
btn.disabled = true;
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = 'Downloading…';
|
||||
try {
|
||||
const url = `../api/export?questionnaireID=${encodeURIComponent(btn.dataset.id)}&allVersions=1`;
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
await downloadExportCsv(btn, url, `${btn.dataset.name}_all_versions_${stamp}.csv`);
|
||||
showToast('All versions download started', 'success');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = prev;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
328
website/js/pages/insights.js
Normal file
328
website/js/pages/insights.js
Normal file
@ -0,0 +1,328 @@
|
||||
import { apiGet, apiPut } from '../api.js';
|
||||
import { showToast } from '../app.js';
|
||||
|
||||
let overview = null;
|
||||
let staleClients = [];
|
||||
let followupSearch = '';
|
||||
|
||||
export async function insightsPage() {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Insights</h1>
|
||||
<div class="actions"><a href="#/" class="btn">← Dashboard</a></div>
|
||||
</div>
|
||||
<div id="insightsContent"><div class="spinner"></div></div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const [ov, stale] = await Promise.all([
|
||||
apiGet('analytics.php?overview=1'),
|
||||
apiGet('analytics.php?staleClients=1'),
|
||||
]);
|
||||
overview = ov;
|
||||
staleClients = stale.clients || [];
|
||||
renderInsights();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
document.getElementById('insightsContent').innerHTML =
|
||||
`<p class="error-text">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderInsights() {
|
||||
const el = document.getElementById('insightsContent');
|
||||
const ov = overview || {};
|
||||
|
||||
const kpiCards = `
|
||||
<div class="insights-kpi-grid">
|
||||
<div class="insights-kpi card">
|
||||
<div class="insights-kpi-value">${ov.clientCount ?? 0}</div>
|
||||
<div class="insights-kpi-label">Clients</div>
|
||||
</div>
|
||||
<div class="insights-kpi card">
|
||||
<div class="insights-kpi-value">${ov.clientsWithCompletions ?? 0}</div>
|
||||
<div class="insights-kpi-label">With completions</div>
|
||||
</div>
|
||||
<div class="insights-kpi card">
|
||||
<div class="insights-kpi-value">${ov.submissionsLast7d ?? 0}</div>
|
||||
<div class="insights-kpi-label">Uploads (7 days)</div>
|
||||
</div>
|
||||
<div class="insights-kpi card">
|
||||
<div class="insights-kpi-value">${ov.submissionsLast30d ?? 0}</div>
|
||||
<div class="insights-kpi-label">Uploads (30 days)</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const uploadChart = verticalBarChart(
|
||||
(ov.submissionsByDay || []).map(d => ({
|
||||
label: formatChartDay(d.date),
|
||||
value: d.count,
|
||||
title: `${d.date}: ${d.count} upload(s)`,
|
||||
})),
|
||||
{ emptyLabel: 'No uploads in this period' }
|
||||
);
|
||||
|
||||
const withoutCompletions = Math.max(0, (ov.clientCount ?? 0) - (ov.clientsWithCompletions ?? 0));
|
||||
const engagementChart = horizontalBarChart([
|
||||
{ label: 'With completions', value: ov.clientsWithCompletions ?? 0 },
|
||||
{ label: 'No completions yet', value: withoutCompletions },
|
||||
], { suffix: '' });
|
||||
|
||||
const idleChart = horizontalBarChart(idleBucketItems(staleClients), {
|
||||
suffix: '',
|
||||
variant: 'warn',
|
||||
});
|
||||
|
||||
const completionChart = horizontalBarChart(
|
||||
(ov.questionnaires || []).map(q => ({
|
||||
label: q.name,
|
||||
value: q.completionRatio ?? 0,
|
||||
title: `${q.name}: ${q.completionRatio ?? 0}%`,
|
||||
})),
|
||||
{ suffix: '%', max: 100 }
|
||||
);
|
||||
|
||||
const qRows = (ov.questionnaires || []).map(q => `
|
||||
<tr>
|
||||
<td><strong>${esc(q.name)}</strong></td>
|
||||
<td>${q.completedCount ?? 0} / ${q.eligibleCount ?? 0}</td>
|
||||
<td>${q.completionRatio ?? 0}%</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
${kpiCards}
|
||||
<div class="insights-charts-grid">
|
||||
<div class="card insights-chart-card">
|
||||
<h3>Uploads per day</h3>
|
||||
<p class="insights-chart-hint">Last 14 days</p>
|
||||
${uploadChart}
|
||||
</div>
|
||||
<div class="card insights-chart-card">
|
||||
<h3>Client engagement</h3>
|
||||
<p class="insights-chart-hint">Among clients in your scope</p>
|
||||
${engagementChart}
|
||||
</div>
|
||||
<div class="card insights-chart-card">
|
||||
<h3>Days since last activity</h3>
|
||||
<p class="insights-chart-hint">Clients with at least one completion</p>
|
||||
${idleChart}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h3 style="margin:0 0 8px">Completion by questionnaire</h3>
|
||||
<p class="insights-chart-hint" style="margin:0 0 12px">Share of clients who completed each active questionnaire</p>
|
||||
${completionChart}
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Questionnaire</th><th>Completed</th><th>Rate</th></tr>
|
||||
</thead>
|
||||
<tbody>${qRows || '<tr><td colspan="3">No active questionnaires</td></tr>'}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
<div class="filter-bar">
|
||||
<input type="search" id="followupListSearch" class="filter-search"
|
||||
placeholder="Search client, coach, questionnaire, note…" value="${esc(followupSearch)}">
|
||||
<span class="data-toolbar-hint" id="followupListCount"></span>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th><th>Coach</th><th>First completed</th><th>First at</th>
|
||||
<th>Last activity</th><th>Days idle</th><th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="followupTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('followupListSearch').addEventListener('input', (e) => {
|
||||
followupSearch = e.target.value;
|
||||
renderFollowupTableBody();
|
||||
});
|
||||
|
||||
renderFollowupTableBody();
|
||||
}
|
||||
|
||||
function followupSearchText(c) {
|
||||
return [
|
||||
c.clientCode,
|
||||
c.coachUsername,
|
||||
c.firstQuestionnaireName,
|
||||
c.firstQuestionnaireID,
|
||||
c.note,
|
||||
c.firstCompletedAt,
|
||||
c.lastActivityAt,
|
||||
c.daysSinceLastActivity != null ? String(c.daysSinceLastActivity) : '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function filteredFollowupClients() {
|
||||
const q = followupSearch.trim().toLowerCase();
|
||||
if (!q) return staleClients;
|
||||
return staleClients.filter(c => followupSearchText(c).toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function followupRowHTML(c) {
|
||||
return `
|
||||
<tr data-client="${esc(c.clientCode)}">
|
||||
<td><code>${esc(c.clientCode)}</code></td>
|
||||
<td>${esc(c.coachUsername)}</td>
|
||||
<td>${esc(c.firstQuestionnaireName || '—')}</td>
|
||||
<td>${esc(c.firstCompletedAt || '—')}</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)}"
|
||||
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>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderFollowupTableBody() {
|
||||
const body = document.getElementById('followupTableBody');
|
||||
const countEl = document.getElementById('followupListCount');
|
||||
if (!body) return;
|
||||
|
||||
const filtered = filteredFollowupClients();
|
||||
|
||||
if (!filtered.length) {
|
||||
body.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" style="text-align:center;color:var(--text-secondary);padding:24px">
|
||||
${staleClients.length ? 'No clients match' : 'No clients'}
|
||||
</td>
|
||||
</tr>`;
|
||||
if (countEl) {
|
||||
countEl.textContent = followupSearch.trim() && staleClients.length
|
||||
? `0 of ${staleClients.length} clients`
|
||||
: '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = filtered.map(c => followupRowHTML(c)).join('');
|
||||
|
||||
if (countEl) {
|
||||
const q = followupSearch.trim();
|
||||
countEl.textContent = q
|
||||
? `${filtered.length} of ${staleClients.length} clients`
|
||||
: `${staleClients.length} client${staleClients.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
body.querySelectorAll('.save-note-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tr = btn.closest('tr');
|
||||
const ta = tr?.querySelector('.followup-note-input');
|
||||
saveNote(btn.dataset.client, ta);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveNote(clientCode, ta) {
|
||||
if (!ta) return;
|
||||
try {
|
||||
await apiPut('analytics.php', { clientCode, note: ta.value.trim() });
|
||||
const row = staleClients.find(c => c.clientCode === clientCode);
|
||||
if (row) row.note = ta.value.trim();
|
||||
showToast('Note saved', 'success');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s ?? '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function formatChartDay(isoDate) {
|
||||
if (!isoDate) return '';
|
||||
const d = new Date(isoDate + 'T12:00:00');
|
||||
if (Number.isNaN(d.getTime())) return isoDate.slice(5);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function chartMax(values, fallbackMax = 1) {
|
||||
const m = Math.max(0, ...values);
|
||||
return m > 0 ? m : fallbackMax;
|
||||
}
|
||||
|
||||
function verticalBarChart(items, { emptyLabel = 'No data' } = {}) {
|
||||
if (!items.length) {
|
||||
return `<p class="insights-chart-hint">${esc(emptyLabel)}</p>`;
|
||||
}
|
||||
const max = chartMax(items.map(i => i.value));
|
||||
return `
|
||||
<div class="insights-vchart" role="img" aria-label="Bar chart">
|
||||
${items.map(item => {
|
||||
const pct = max ? Math.round((100 * item.value) / max) : 0;
|
||||
return `
|
||||
<div class="insights-vchart-col" title="${esc(item.title || `${item.label}: ${item.value}`)}">
|
||||
<div class="insights-vchart-bar-wrap">
|
||||
<div class="insights-vchart-bar" style="height:${pct}%"></div>
|
||||
</div>
|
||||
<span class="insights-vchart-value">${item.value}</span>
|
||||
<span class="insights-vchart-label">${esc(item.label)}</span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function horizontalBarChart(items, { suffix = '%', max: fixedMax, variant = '' } = {}) {
|
||||
if (!items.length) {
|
||||
return '<p class="insights-chart-hint">No data</p>';
|
||||
}
|
||||
const max = fixedMax ?? chartMax(items.map(i => i.value));
|
||||
const fillClass = variant === 'warn' ? 'insights-hchart-fill--warn'
|
||||
: variant === 'muted' ? 'insights-hchart-fill--muted'
|
||||
: '';
|
||||
return `
|
||||
<div class="insights-hchart" role="img" aria-label="Bar chart">
|
||||
${items.map(item => {
|
||||
const pct = max ? Math.round((100 * item.value) / max) : 0;
|
||||
const display = suffix === '%' ? `${item.value}${suffix}` : String(item.value);
|
||||
return `
|
||||
<div class="insights-hchart-row" title="${esc(item.title || `${item.label}: ${display}`)}">
|
||||
<span class="insights-hchart-label">${esc(item.label)}</span>
|
||||
<div class="insights-hchart-track">
|
||||
<div class="insights-hchart-fill ${fillClass}" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="insights-hchart-num">${esc(display)}</span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function idleBucketItems(clients) {
|
||||
const buckets = [
|
||||
{ label: '0–7 days', value: 0 },
|
||||
{ label: '8–30 days', value: 0 },
|
||||
{ label: '31–90 days', value: 0 },
|
||||
{ label: '90+ days', value: 0 },
|
||||
{ label: 'No uploads yet', value: 0 },
|
||||
];
|
||||
for (const c of clients) {
|
||||
const d = c.daysSinceLastActivity;
|
||||
if (d == null) buckets[4].value += 1;
|
||||
else if (d <= 7) buckets[0].value += 1;
|
||||
else if (d <= 30) buckets[1].value += 1;
|
||||
else if (d <= 90) buckets[2].value += 1;
|
||||
else buckets[3].value += 1;
|
||||
}
|
||||
return buckets.filter(b => b.value > 0);
|
||||
}
|
||||
Reference in New Issue
Block a user