added submission history, data insights, download all functionality
This commit is contained in:
@ -41,6 +41,8 @@ $routes = [
|
|||||||
'clients' => __DIR__ . '/../handlers/clients.php',
|
'clients' => __DIR__ . '/../handlers/clients.php',
|
||||||
'results' => __DIR__ . '/../handlers/results.php',
|
'results' => __DIR__ . '/../handlers/results.php',
|
||||||
'export' => __DIR__ . '/../handlers/export.php',
|
'export' => __DIR__ . '/../handlers/export.php',
|
||||||
|
'analytics' => __DIR__ . '/../handlers/analytics.php',
|
||||||
|
'coaches' => __DIR__ . '/../handlers/coaches.php',
|
||||||
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
|
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
|
||||||
'logout' => __DIR__ . '/../handlers/logout.php',
|
'logout' => __DIR__ . '/../handlers/logout.php',
|
||||||
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
||||||
|
|||||||
@ -182,6 +182,7 @@
|
|||||||
"upload_success_message": "Hochladen: {count} erledigt.",
|
"upload_success_message": "Hochladen: {count} erledigt.",
|
||||||
"username_hint": "Benutzername",
|
"username_hint": "Benutzername",
|
||||||
"year": "Jahr",
|
"year": "Jahr",
|
||||||
"questionnaire_group_demographics": "Demografie"
|
"questionnaire_group_demographics": "Demografie",
|
||||||
|
"questionnaire_group_rhs": "Gesundheitsinterview"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
db_init.php
74
db_init.php
@ -6,7 +6,15 @@ require_once __DIR__ . '/common.php';
|
|||||||
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
|
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
|
||||||
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
|
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
|
||||||
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
||||||
define('QDB_VERSION', 4);
|
define('QDB_VERSION', 5);
|
||||||
|
|
||||||
|
function qdb_table_exists(PDO $pdo, string $table): bool {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :t LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute([':t' => $table]);
|
||||||
|
return (bool)$stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
function qdb_column_exists(PDO $pdo, string $table, string $column): bool {
|
function qdb_column_exists(PDO $pdo, string $table, string $column): bool {
|
||||||
$stmt = $pdo->query('PRAGMA table_info(' . $table . ')');
|
$stmt = $pdo->query('PRAGMA table_info(' . $table . ')');
|
||||||
@ -29,6 +37,70 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
|
|||||||
$changed = true;
|
$changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE questionnaire_submission (
|
||||||
|
submissionID TEXT NOT NULL PRIMARY KEY,
|
||||||
|
clientCode TEXT NOT NULL,
|
||||||
|
questionnaireID TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
submittedAt INTEGER NOT NULL,
|
||||||
|
submittedByUserID TEXT NOT NULL DEFAULT '',
|
||||||
|
submittedByRole TEXT NOT NULL DEFAULT '',
|
||||||
|
assignedByCoach TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
startedAt INTEGER,
|
||||||
|
completedAt INTEGER,
|
||||||
|
sumPoints INTEGER,
|
||||||
|
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
|
||||||
|
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
|
||||||
|
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID),
|
||||||
|
UNIQUE(clientCode, questionnaireID, version)
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_submission_client_qn ON questionnaire_submission(clientCode, questionnaireID)');
|
||||||
|
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_submission_client_time ON questionnaire_submission(clientCode, submittedAt)');
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!qdb_table_exists($pdo, 'client_answer_submission')) {
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE client_answer_submission (
|
||||||
|
submissionID TEXT NOT NULL,
|
||||||
|
questionID TEXT NOT NULL,
|
||||||
|
answerOptionID TEXT,
|
||||||
|
freeTextValue TEXT,
|
||||||
|
numericValue REAL,
|
||||||
|
answeredAt INTEGER,
|
||||||
|
PRIMARY KEY (submissionID, questionID),
|
||||||
|
FOREIGN KEY(submissionID) REFERENCES questionnaire_submission(submissionID) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(questionID) REFERENCES question(questionID),
|
||||||
|
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!qdb_table_exists($pdo, 'client_followup_note')) {
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE client_followup_note (
|
||||||
|
clientCode TEXT NOT NULL PRIMARY KEY,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
updatedByUserID TEXT NOT NULL DEFAULT '',
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(clientCode) REFERENCES client(clientCode)
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
|
require_once __DIR__ . '/lib/submissions.php';
|
||||||
|
if (qdb_backfill_submissions_from_live($pdo)) {
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
||||||
if ($currentVersion < QDB_VERSION) {
|
if ($currentVersion < QDB_VERSION) {
|
||||||
$pdo->exec('PRAGMA foreign_keys = OFF;');
|
$pdo->exec('PRAGMA foreign_keys = OFF;');
|
||||||
|
|||||||
@ -468,8 +468,9 @@ Current upload behavior:
|
|||||||
|
|
||||||
- Unknown or empty `questionID` values are skipped.
|
- Unknown or empty `questionID` values are skipped.
|
||||||
- Unknown `answerOptionKey` values are stored as no selected option for that question.
|
- Unknown `answerOptionKey` values are stored as no selected option for that question.
|
||||||
- Re-uploading the same `clientCode` and `questionID` overwrites the previous answer.
|
- Re-uploading the same `clientCode` and `questionID` overwrites the previous answer in the **current** snapshot.
|
||||||
- Re-uploading the same `clientCode` and `questionnaireID` overwrites the completed questionnaire status and point sum (supports correction / re-edit workflows).
|
- Re-uploading the same `clientCode` and `questionnaireID` overwrites the completed questionnaire status and point sum (supports correction / re-edit workflows).
|
||||||
|
- Each successful POST also appends a **versioned submission** (`questionnaire_submission` + `client_answer_submission`) so the web dashboard can export all upload history. The app still reads only the current snapshot via `GET ?clientCode=…&answers=1`.
|
||||||
- `sumPoints` is calculated from recognized `answerOptionKey` values.
|
- `sumPoints` is calculated from recognized `answerOptionKey` values.
|
||||||
- The current database stores one selected answer option per question. If the Android UI allows multiple selections for a layout, coordinate a server change before uploading multiple option keys for one question.
|
- The current database stores one selected answer option per question. If the Android UI allows multiple selections for a layout, coordinate a server change before uploading multiple option keys for one question.
|
||||||
|
|
||||||
|
|||||||
53
handlers/analytics.php
Normal file
53
handlers/analytics.php
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$tokenRec = require_valid_token_web();
|
||||||
|
require_role(['admin', 'supervisor'], $tokenRec);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/analytics.php';
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
|
||||||
|
case 'GET':
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
try {
|
||||||
|
if (!empty($_GET['overview'])) {
|
||||||
|
$data = qdb_analytics_overview($pdo, $tokenRec);
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_success($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($_GET['staleClients'])) {
|
||||||
|
$clients = qdb_analytics_stale_clients($pdo, $tokenRec);
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_success(['clients' => $clients]);
|
||||||
|
}
|
||||||
|
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('BAD_REQUEST', 'Unknown query', 400);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
error_log('analytics GET: ' . $e->getMessage());
|
||||||
|
json_error('SERVER_ERROR', 'Server error', 500);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
$body = read_json_body();
|
||||||
|
$clientCode = trim((string)($body['clientCode'] ?? ''));
|
||||||
|
$note = (string)($body['note'] ?? '');
|
||||||
|
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||||
|
try {
|
||||||
|
qdb_analytics_set_followup_note($pdo, $tokenRec, $clientCode, $note);
|
||||||
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
json_success(['clientCode' => $clientCode, 'note' => $note]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
error_log('analytics PUT: ' . $e->getMessage());
|
||||||
|
json_error('SERVER_ERROR', 'Server error', 500);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||||
|
}
|
||||||
@ -211,6 +211,18 @@ if ($method === 'POST') {
|
|||||||
':sp' => $sumPoints,
|
':sp' => $sumPoints,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/submissions.php';
|
||||||
|
qdb_record_submission_after_submit(
|
||||||
|
$pdo,
|
||||||
|
$clientCode,
|
||||||
|
$qnID,
|
||||||
|
$tokenRec,
|
||||||
|
$startedAt,
|
||||||
|
$completedAt,
|
||||||
|
$sumPoints,
|
||||||
|
$assignedByCoach
|
||||||
|
);
|
||||||
|
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
|
||||||
|
|||||||
28
handlers/coaches.php
Normal file
28
handlers/coaches.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$tokenRec = require_valid_token_web();
|
||||||
|
require_role(['admin', 'supervisor'], $tokenRec);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/analytics.php';
|
||||||
|
|
||||||
|
if ($method !== 'GET') {
|
||||||
|
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
try {
|
||||||
|
$coachID = trim((string)($_GET['coachID'] ?? ''));
|
||||||
|
if ($coachID !== '' && !empty($_GET['recent'])) {
|
||||||
|
$recent = qdb_coach_recent_submissions($pdo, $tokenRec, $coachID);
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_success(['submissions' => $recent]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$coaches = qdb_coach_activity_list($pdo, $tokenRec);
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_success(['coaches' => $coaches]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
error_log('coaches GET: ' . $e->getMessage());
|
||||||
|
json_error('SERVER_ERROR', 'Server error', 500);
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/submissions.php';
|
||||||
|
|
||||||
$tokenRec = require_valid_token_web();
|
$tokenRec = require_valid_token_web();
|
||||||
|
|
||||||
if ($method !== 'GET') {
|
if ($method !== 'GET') {
|
||||||
@ -19,14 +21,34 @@ if (!empty($_GET['bundle'])) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($_GET['exportAll'])) {
|
||||||
|
require_role(['admin'], $tokenRec);
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
$allVersions = !empty($_GET['allVersions']);
|
||||||
|
$zipPath = qdb_build_server_export_zip($pdo, $tokenRec, $allVersions);
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
|
||||||
|
$label = $allVersions ? 'all_versions' : 'current';
|
||||||
|
$filename = 'responses_export_' . $label . '_' . date('Y-m-d_His') . '.zip';
|
||||||
|
|
||||||
|
header('Content-Type: application/zip');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
header('Content-Length: ' . (string)filesize($zipPath));
|
||||||
|
readfile($zipPath);
|
||||||
|
@unlink($zipPath);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$qnID = $_GET['questionnaireID'] ?? '';
|
$qnID = $_GET['questionnaireID'] ?? '';
|
||||||
if (!$qnID) {
|
if (!$qnID) {
|
||||||
json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
|
json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$allVersions = !empty($_GET['allVersions']);
|
||||||
|
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
|
||||||
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
|
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
|
||||||
$qn->execute([':id' => $qnID]);
|
$qn->execute([':id' => $qnID]);
|
||||||
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
|
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
|
||||||
if (!$questionnaire) {
|
if (!$questionnaire) {
|
||||||
@ -34,95 +56,30 @@ if (!$questionnaire) {
|
|||||||
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex");
|
$ctx = qdb_export_questionnaire_context($pdo, $qnID);
|
||||||
$qStmt->execute([':id' => $qnID]);
|
$questions = $ctx['questions'];
|
||||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
$resultColumns = $ctx['resultColumns'];
|
||||||
$questionIDs = array_column($questions, 'questionID');
|
$optionTextMap = $ctx['optionTextMap'];
|
||||||
$resultColumns = qdb_results_export_columns($questions, $qnID);
|
|
||||||
|
|
||||||
$optionTextMap = [];
|
if ($allVersions) {
|
||||||
foreach ($questionIDs as $qid) {
|
$rows = qdb_export_all_versions_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
|
||||||
$aoStmt = $pdo->prepare("SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid");
|
qdb_discard($tmpDb, $lockFp);
|
||||||
$aoStmt->execute([':qid' => $qid]);
|
|
||||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
$safeName = qdb_export_safe_basename($questionnaire['name']);
|
||||||
$optionTextMap[$ao['answerOptionID']] = $ao['defaultText'];
|
$filename = $safeName . '_all_versions_' . date('Y-m-d') . '.csv';
|
||||||
}
|
|
||||||
}
|
header('Content-Type: text/csv; charset=UTF-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
echo qdb_export_rows_to_csv_string($rows, qdb_export_all_versions_csv_fallback_header($resultColumns));
|
||||||
|
exit;
|
||||||
$sql = "
|
|
||||||
SELECT cl.clientCode, cl.coachID,
|
|
||||||
co.username AS coachUsername,
|
|
||||||
sv.username AS supervisorUsername,
|
|
||||||
cq.status, cq.sumPoints, cq.startedAt, cq.completedAt
|
|
||||||
FROM client cl
|
|
||||||
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
|
|
||||||
LEFT JOIN coach co ON co.coachID = cl.coachID
|
|
||||||
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
|
|
||||||
WHERE $rbacClause
|
|
||||||
ORDER BY cl.clientCode
|
|
||||||
";
|
|
||||||
$params = array_merge([':qnid' => $qnID], $rbacParams);
|
|
||||||
$cStmt = $pdo->prepare($sql);
|
|
||||||
$cStmt->execute($params);
|
|
||||||
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
$qPlaceholders = !empty($questionIDs) ? implode(',', array_fill(0, count($questionIDs), '?')) : "'__none__'";
|
|
||||||
$answerStmt = $pdo->prepare("
|
|
||||||
SELECT questionID, answerOptionID, freeTextValue, numericValue
|
|
||||||
FROM client_answer
|
|
||||||
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
|
|
||||||
");
|
|
||||||
|
|
||||||
$rows = [];
|
|
||||||
foreach ($clients as $c) {
|
|
||||||
$row = [
|
|
||||||
'clientCode' => $c['clientCode'],
|
|
||||||
'coach' => $c['coachUsername'] ?? $c['coachID'],
|
|
||||||
'supervisor' => $c['supervisorUsername'] ?? '',
|
|
||||||
'status' => $c['status'],
|
|
||||||
'sumPoints' => $c['sumPoints'],
|
|
||||||
'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '',
|
|
||||||
'completedAt' => $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '',
|
|
||||||
];
|
|
||||||
|
|
||||||
$bindParams = array_merge([$c['clientCode']], $questionIDs);
|
|
||||||
$answerStmt->execute($bindParams);
|
|
||||||
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
$answerMap = [];
|
|
||||||
foreach ($answers as $a) $answerMap[$a['questionID']] = $a;
|
|
||||||
|
|
||||||
foreach ($resultColumns as $col) {
|
|
||||||
$qid = $col['questionID'];
|
|
||||||
$a = $answerMap[$qid] ?? null;
|
|
||||||
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap);
|
|
||||||
}
|
|
||||||
$rows[] = $row;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
|
||||||
// CSV output (not JSON -- overrides the router's Content-Type)
|
$safeName = qdb_export_safe_basename($questionnaire['name']);
|
||||||
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $questionnaire['name']);
|
|
||||||
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
|
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
|
||||||
|
|
||||||
header('Content-Type: text/csv; charset=UTF-8');
|
header('Content-Type: text/csv; charset=UTF-8');
|
||||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
echo qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($resultColumns));
|
||||||
$out = fopen('php://output', 'w');
|
|
||||||
fwrite($out, "\xEF\xBB\xBF");
|
|
||||||
|
|
||||||
if (!empty($rows)) {
|
|
||||||
fputcsv($out, array_keys($rows[0]));
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
fputcsv($out, array_values($row));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$header = ['clientCode', 'coachID', 'status', 'sumPoints', 'startedAt', 'completedAt'];
|
|
||||||
foreach ($resultColumns as $col) {
|
|
||||||
$header[] = $col['header'];
|
|
||||||
}
|
|
||||||
fputcsv($out, $header);
|
|
||||||
}
|
|
||||||
fclose($out);
|
|
||||||
|
|||||||
304
lib/analytics.php
Normal file
304
lib/analytics.php
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Analytics queries for Insights dashboard (RBAC-scoped).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily upload counts for the last N calendar days (zeros for quiet days).
|
||||||
|
*
|
||||||
|
* @return list<array{date: string, count: int}>
|
||||||
|
*/
|
||||||
|
function qdb_analytics_submissions_by_day(PDO $pdo, array $tokenRec, int $days = 14): array {
|
||||||
|
$days = max(1, min(90, $days));
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
|
||||||
|
$today = strtotime('today');
|
||||||
|
$startTs = $today - ($days - 1) * 86400;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT strftime('%Y-%m-%d', qs.submittedAt, 'unixepoch') AS d, COUNT(*) AS cnt
|
||||||
|
FROM questionnaire_submission qs
|
||||||
|
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
||||||
|
WHERE $rbacClause AND qs.submittedAt >= :start
|
||||||
|
GROUP BY d
|
||||||
|
ORDER BY d"
|
||||||
|
);
|
||||||
|
$stmt->execute(array_merge($rbacParams, [':start' => $startTs]));
|
||||||
|
$byDate = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$byDate[$row['d']] = (int)$row['cnt'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
for ($i = 0; $i < $days; $i++) {
|
||||||
|
$ts = $startTs + $i * 86400;
|
||||||
|
$key = date('Y-m-d', $ts);
|
||||||
|
$out[] = ['date' => $key, 'count' => $byDate[$key] ?? 0];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_analytics_overview(PDO $pdo, array $tokenRec): array {
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT COUNT(*) FROM client cl WHERE $rbacClause");
|
||||||
|
$stmt->execute($rbacParams);
|
||||||
|
$clientCount = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT COUNT(DISTINCT cq.clientCode) FROM completed_questionnaire cq
|
||||||
|
INNER JOIN client cl ON cl.clientCode = cq.clientCode
|
||||||
|
WHERE $rbacClause"
|
||||||
|
);
|
||||||
|
$stmt->execute($rbacParams);
|
||||||
|
$clientsWithCompletions = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$d7 = $now - 7 * 86400;
|
||||||
|
$d30 = $now - 30 * 86400;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT COUNT(*) FROM questionnaire_submission qs
|
||||||
|
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
||||||
|
WHERE $rbacClause AND qs.submittedAt >= :t"
|
||||||
|
);
|
||||||
|
$stmt->execute(array_merge($rbacParams, [':t' => $d7]));
|
||||||
|
$submissions7d = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT COUNT(*) FROM questionnaire_submission qs
|
||||||
|
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
||||||
|
WHERE $rbacClause AND qs.submittedAt >= :t"
|
||||||
|
);
|
||||||
|
$stmt->execute(array_merge($rbacParams, [':t' => $d30]));
|
||||||
|
$submissions30d = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
$qnRows = $pdo->query(
|
||||||
|
"SELECT questionnaireID, name, orderIndex FROM questionnaire WHERE state = 'active' ORDER BY orderIndex, name"
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$perQuestionnaire = [];
|
||||||
|
foreach ($qnRows as $qn) {
|
||||||
|
$qnID = $qn['questionnaireID'];
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT COUNT(DISTINCT cq.clientCode) FROM completed_questionnaire cq
|
||||||
|
INNER JOIN client cl ON cl.clientCode = cq.clientCode
|
||||||
|
WHERE cq.questionnaireID = :qn AND $rbacClause"
|
||||||
|
);
|
||||||
|
$stmt->execute(array_merge([':qn' => $qnID], $rbacParams));
|
||||||
|
$completed = (int)$stmt->fetchColumn();
|
||||||
|
$ratio = $clientCount > 0 ? round(100 * $completed / $clientCount, 1) : 0;
|
||||||
|
$perQuestionnaire[] = [
|
||||||
|
'questionnaireID' => $qnID,
|
||||||
|
'name' => $qn['name'],
|
||||||
|
'completedCount' => $completed,
|
||||||
|
'eligibleCount' => $clientCount,
|
||||||
|
'completionRatio' => $ratio,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'clientCount' => $clientCount,
|
||||||
|
'clientsWithCompletions' => $clientsWithCompletions,
|
||||||
|
'submissionsLast7d' => $submissions7d,
|
||||||
|
'submissionsLast30d' => $submissions30d,
|
||||||
|
'questionnaires' => $perQuestionnaire,
|
||||||
|
'submissionsByDay' => qdb_analytics_submissions_by_day($pdo, $tokenRec, 14),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT cl.clientCode,
|
||||||
|
co.username AS coachUsername,
|
||||||
|
co.coachID,
|
||||||
|
fc.firstCompletedAt,
|
||||||
|
fc.firstQuestionnaireID,
|
||||||
|
qn.name AS firstQuestionnaireName,
|
||||||
|
la.lastActivityAt,
|
||||||
|
n.note AS followupNote
|
||||||
|
FROM client cl
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT cq.clientCode, cq.questionnaireID AS firstQuestionnaireID, cq.completedAt AS firstCompletedAt
|
||||||
|
FROM completed_questionnaire cq
|
||||||
|
WHERE cq.completedAt IS NOT NULL
|
||||||
|
AND cq.rowid = (
|
||||||
|
SELECT c2.rowid FROM completed_questionnaire c2
|
||||||
|
WHERE c2.clientCode = cq.clientCode AND c2.completedAt IS NOT NULL
|
||||||
|
ORDER BY c2.completedAt ASC, c2.questionnaireID ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
) fc ON fc.clientCode = cl.clientCode
|
||||||
|
LEFT JOIN questionnaire qn ON qn.questionnaireID = fc.firstQuestionnaireID
|
||||||
|
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT qs.clientCode, MAX(qs.submittedAt) AS lastActivityAt
|
||||||
|
FROM questionnaire_submission qs
|
||||||
|
GROUP BY qs.clientCode
|
||||||
|
) la ON la.clientCode = cl.clientCode
|
||||||
|
LEFT JOIN client_followup_note n ON n.clientCode = cl.clientCode
|
||||||
|
WHERE $rbacClause
|
||||||
|
ORDER BY COALESCE(la.lastActivityAt, 0) ASC, cl.clientCode ASC
|
||||||
|
";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($rbacParams);
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$lastAt = $r['lastActivityAt'] !== null ? (int)$r['lastActivityAt'] : null;
|
||||||
|
$daysSince = $lastAt !== null
|
||||||
|
? (int)floor(($now - $lastAt) / 86400)
|
||||||
|
: null;
|
||||||
|
$out[] = [
|
||||||
|
'clientCode' => $r['clientCode'],
|
||||||
|
'coachUsername' => $r['coachUsername'] ?? $r['coachID'],
|
||||||
|
'firstQuestionnaireID' => $r['firstQuestionnaireID'],
|
||||||
|
'firstQuestionnaireName'=> $r['firstQuestionnaireName'] ?? $r['firstQuestionnaireID'],
|
||||||
|
'firstCompletedAt' => $r['firstCompletedAt']
|
||||||
|
? date('Y-m-d H:i', (int)$r['firstCompletedAt']) : '',
|
||||||
|
'lastActivityAt' => $lastAt ? date('Y-m-d H:i', $lastAt) : '',
|
||||||
|
'daysSinceLastActivity' => $daysSince,
|
||||||
|
'note' => $r['followupNote'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_analytics_set_followup_note(PDO $pdo, array $tokenRec, string $clientCode, string $note): void {
|
||||||
|
$clientCode = trim($clientCode);
|
||||||
|
if ($clientCode === '') {
|
||||||
|
json_error('INVALID_FIELD', 'clientCode is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
$stmt = $pdo->prepare("SELECT 1 FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)");
|
||||||
|
$stmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
|
||||||
|
if (!$stmt->fetchColumn()) {
|
||||||
|
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO client_followup_note (clientCode, note, updatedByUserID, updatedAt)
|
||||||
|
VALUES (:cc, :n, :uid, :ts)
|
||||||
|
ON CONFLICT(clientCode) DO UPDATE SET
|
||||||
|
note = excluded.note,
|
||||||
|
updatedByUserID = excluded.updatedByUserID,
|
||||||
|
updatedAt = excluded.updatedAt'
|
||||||
|
)->execute([
|
||||||
|
':cc' => $clientCode,
|
||||||
|
':n' => $note,
|
||||||
|
':uid' => $tokenRec['userID'] ?? '',
|
||||||
|
':ts' => time(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_coach_activity_list(PDO $pdo, array $tokenRec): array {
|
||||||
|
$role = $tokenRec['role'] ?? '';
|
||||||
|
$entityID = $tokenRec['entityID'] ?? '';
|
||||||
|
|
||||||
|
if ($role === 'supervisor') {
|
||||||
|
$coachWhere = 'co.supervisorID = :eid';
|
||||||
|
$coachParams = [':eid' => $entityID];
|
||||||
|
} else {
|
||||||
|
$coachWhere = '1=1';
|
||||||
|
$coachParams = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$d7 = $now - 7 * 86400;
|
||||||
|
$d30 = $now - 30 * 86400;
|
||||||
|
|
||||||
|
$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 questionnaire_submission qs
|
||||||
|
INNER JOIN client c ON c.clientCode = qs.clientCode
|
||||||
|
WHERE c.coachID = co.coachID) AS totalSubmissions,
|
||||||
|
(SELECT COUNT(*) FROM questionnaire_submission qs
|
||||||
|
INNER JOIN client c ON c.clientCode = qs.clientCode
|
||||||
|
WHERE c.coachID = co.coachID AND qs.submittedAt >= :d7) AS submissions7d,
|
||||||
|
(SELECT COUNT(*) FROM questionnaire_submission qs
|
||||||
|
INNER JOIN client c ON c.clientCode = qs.clientCode
|
||||||
|
WHERE c.coachID = co.coachID AND qs.submittedAt >= :d30) AS submissions30d,
|
||||||
|
(SELECT MAX(qs.submittedAt) FROM questionnaire_submission qs
|
||||||
|
INNER JOIN client c ON c.clientCode = qs.clientCode
|
||||||
|
WHERE c.coachID = co.coachID) AS lastSubmissionAt
|
||||||
|
FROM coach co
|
||||||
|
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
|
||||||
|
WHERE $coachWhere
|
||||||
|
ORDER BY co.username
|
||||||
|
";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute(array_merge([':d7' => $d7, ':d30' => $d30], $coachParams));
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$last = $r['lastSubmissionAt'] !== null ? (int)$r['lastSubmissionAt'] : null;
|
||||||
|
$out[] = [
|
||||||
|
'coachID' => $r['coachID'],
|
||||||
|
'username' => $r['username'],
|
||||||
|
'supervisorUsername'=> $r['supervisorUsername'] ?? '',
|
||||||
|
'clientCount' => (int)$r['clientCount'],
|
||||||
|
'totalSubmissions' => (int)$r['totalSubmissions'],
|
||||||
|
'submissionsLast7d' => (int)$r['submissions7d'],
|
||||||
|
'submissionsLast30d'=> (int)$r['submissions30d'],
|
||||||
|
'lastSubmissionAt' => $last ? date('Y-m-d H:i', $last) : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_coach_recent_submissions(PDO $pdo, array $tokenRec, string $coachID, int $limit = 25): array {
|
||||||
|
$coachID = trim($coachID);
|
||||||
|
if ($coachID === '') {
|
||||||
|
json_error('INVALID_FIELD', 'coachID is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$role = $tokenRec['role'] ?? '';
|
||||||
|
$entityID = $tokenRec['entityID'] ?? '';
|
||||||
|
|
||||||
|
$chk = $pdo->prepare('SELECT coachID, supervisorID FROM coach WHERE coachID = :id');
|
||||||
|
$chk->execute([':id' => $coachID]);
|
||||||
|
$coach = $chk->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$coach) {
|
||||||
|
json_error('NOT_FOUND', 'Coach not found', 404);
|
||||||
|
}
|
||||||
|
if ($role === 'supervisor' && ($coach['supervisorID'] ?? '') !== $entityID) {
|
||||||
|
json_error('FORBIDDEN', 'Not authorized for this coach', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
$limit = max(1, min($limit, 100));
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByRole,
|
||||||
|
qs.clientCode, qs.questionnaireID, qn.name AS questionnaireName
|
||||||
|
FROM questionnaire_submission qs
|
||||||
|
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
||||||
|
LEFT JOIN questionnaire qn ON qn.questionnaireID = qs.questionnaireID
|
||||||
|
WHERE cl.coachID = :cid AND ($rbacClause)
|
||||||
|
ORDER BY qs.submittedAt DESC
|
||||||
|
LIMIT $limit
|
||||||
|
";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute(array_merge([':cid' => $coachID], $rbacParams));
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return array_map(static function ($r) {
|
||||||
|
return [
|
||||||
|
'submissionID' => $r['submissionID'],
|
||||||
|
'version' => (int)$r['version'],
|
||||||
|
'submittedAt' => $r['submittedAt'] ? date('Y-m-d H:i', (int)$r['submittedAt']) : '',
|
||||||
|
'submittedByRole' => $r['submittedByRole'] ?? '',
|
||||||
|
'clientCode' => $r['clientCode'],
|
||||||
|
'questionnaireID' => $r['questionnaireID'],
|
||||||
|
'questionnaireName'=> $r['questionnaireName'] ?? $r['questionnaireID'],
|
||||||
|
];
|
||||||
|
}, $rows);
|
||||||
|
}
|
||||||
@ -768,6 +768,15 @@ function qdb_wipe_all_data_except_admins(PDO $pdo): array
|
|||||||
$pdo->exec('PRAGMA foreign_keys = OFF');
|
$pdo->exec('PRAGMA foreign_keys = OFF');
|
||||||
|
|
||||||
$deleted['client_answers'] = (int)$pdo->exec('DELETE FROM client_answer');
|
$deleted['client_answers'] = (int)$pdo->exec('DELETE FROM client_answer');
|
||||||
|
if (qdb_table_exists($pdo, 'client_answer_submission')) {
|
||||||
|
$pdo->exec('DELETE FROM client_answer_submission');
|
||||||
|
}
|
||||||
|
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
|
$pdo->exec('DELETE FROM questionnaire_submission');
|
||||||
|
}
|
||||||
|
if (qdb_table_exists($pdo, 'client_followup_note')) {
|
||||||
|
$pdo->exec('DELETE FROM client_followup_note');
|
||||||
|
}
|
||||||
$deleted['completed_questionnaires'] = (int)$pdo->exec('DELETE FROM completed_questionnaire');
|
$deleted['completed_questionnaires'] = (int)$pdo->exec('DELETE FROM completed_questionnaire');
|
||||||
$deleted['clients'] = (int)$pdo->exec('DELETE FROM client');
|
$deleted['clients'] = (int)$pdo->exec('DELETE FROM client');
|
||||||
|
|
||||||
|
|||||||
453
lib/submissions.php
Normal file
453
lib/submissions.php
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Questionnaire submission versioning (archive each coach upload).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function qdb_next_submission_version(PDO $pdo, string $clientCode, string $questionnaireID): int {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT COALESCE(MAX(version), 0) + 1 FROM questionnaire_submission
|
||||||
|
WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||||
|
);
|
||||||
|
$stmt->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
|
||||||
|
return (int)$stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot live answers + completion metadata after a successful submit.
|
||||||
|
*/
|
||||||
|
function qdb_record_submission_after_submit(
|
||||||
|
PDO $pdo,
|
||||||
|
string $clientCode,
|
||||||
|
string $questionnaireID,
|
||||||
|
array $tokenRec,
|
||||||
|
?int $startedAt,
|
||||||
|
?int $completedAt,
|
||||||
|
int $sumPoints,
|
||||||
|
string $assignedByCoach
|
||||||
|
): string {
|
||||||
|
$version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID);
|
||||||
|
$submissionID = bin2hex(random_bytes(16));
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$cq = $pdo->prepare(
|
||||||
|
'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||||
|
);
|
||||||
|
$cq->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
|
||||||
|
$status = $cq->fetchColumn() ?: 'completed';
|
||||||
|
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO questionnaire_submission (
|
||||||
|
submissionID, clientCode, questionnaireID, version, submittedAt,
|
||||||
|
submittedByUserID, submittedByRole, assignedByCoach,
|
||||||
|
status, startedAt, completedAt, sumPoints
|
||||||
|
) VALUES (
|
||||||
|
:sid, :cc, :qn, :ver, :sat,
|
||||||
|
:uid, :role, :abc,
|
||||||
|
:st, :sa, :ca, :sp
|
||||||
|
)'
|
||||||
|
)->execute([
|
||||||
|
':sid' => $submissionID,
|
||||||
|
':cc' => $clientCode,
|
||||||
|
':qn' => $questionnaireID,
|
||||||
|
':ver' => $version,
|
||||||
|
':sat' => $now,
|
||||||
|
':uid' => $tokenRec['userID'] ?? '',
|
||||||
|
':role' => $tokenRec['role'] ?? '',
|
||||||
|
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
|
||||||
|
':st' => $status,
|
||||||
|
':sa' => $startedAt,
|
||||||
|
':ca' => $completedAt,
|
||||||
|
':sp' => $sumPoints,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$copy = $pdo->prepare(
|
||||||
|
'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
|
||||||
|
SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
|
||||||
|
FROM client_answer
|
||||||
|
WHERE clientCode = :cc AND questionID IN (
|
||||||
|
SELECT questionID FROM question WHERE questionnaireID = :qn
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$copy->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $questionnaireID]);
|
||||||
|
|
||||||
|
return $submissionID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time: create version 1 submissions for existing completions without archive rows.
|
||||||
|
*/
|
||||||
|
function qdb_backfill_submissions_from_live(PDO $pdo): bool {
|
||||||
|
$count = (int)$pdo->query('SELECT COUNT(*) FROM questionnaire_submission')->fetchColumn();
|
||||||
|
if ($count > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $pdo->query(
|
||||||
|
'SELECT clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints
|
||||||
|
FROM completed_questionnaire'
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$rows) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
foreach ($rows as $cq) {
|
||||||
|
$clientCode = $cq['clientCode'];
|
||||||
|
$qnID = $cq['questionnaireID'];
|
||||||
|
$chk = $pdo->prepare(
|
||||||
|
'SELECT 1 FROM client_answer ca
|
||||||
|
JOIN question q ON q.questionID = ca.questionID
|
||||||
|
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn LIMIT 1'
|
||||||
|
);
|
||||||
|
$chk->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||||
|
if (!$chk->fetchColumn()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$submissionID = bin2hex(random_bytes(16));
|
||||||
|
$completedAt = $cq['completedAt'] ?? time();
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO questionnaire_submission (
|
||||||
|
submissionID, clientCode, questionnaireID, version, submittedAt,
|
||||||
|
submittedByUserID, submittedByRole, assignedByCoach,
|
||||||
|
status, startedAt, completedAt, sumPoints
|
||||||
|
) VALUES (
|
||||||
|
:sid, :cc, :qn, 1, :sat,
|
||||||
|
\'\', \'\', :abc,
|
||||||
|
:st, :sa, :ca, :sp
|
||||||
|
)'
|
||||||
|
)->execute([
|
||||||
|
':sid' => $submissionID,
|
||||||
|
':cc' => $clientCode,
|
||||||
|
':qn' => $qnID,
|
||||||
|
':sat' => $completedAt,
|
||||||
|
':abc' => $cq['assignedByCoach'],
|
||||||
|
':st' => $cq['status'] ?: 'completed',
|
||||||
|
':sa' => $cq['startedAt'],
|
||||||
|
':ca' => $cq['completedAt'],
|
||||||
|
':sp' => $cq['sumPoints'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
|
||||||
|
SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
|
||||||
|
FROM client_answer
|
||||||
|
WHERE clientCode = :cc AND questionID IN (
|
||||||
|
SELECT questionID FROM question WHERE questionnaireID = :qn
|
||||||
|
)'
|
||||||
|
)->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $qnID]);
|
||||||
|
}
|
||||||
|
$pdo->commit();
|
||||||
|
return true;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build CSV rows for all submission versions of one questionnaire (RBAC-scoped).
|
||||||
|
*
|
||||||
|
* @return list<array<string, string>>
|
||||||
|
*/
|
||||||
|
function qdb_export_all_versions_rows(
|
||||||
|
PDO $pdo,
|
||||||
|
string $questionnaireID,
|
||||||
|
array $questions,
|
||||||
|
array $resultColumns,
|
||||||
|
array $optionTextMap,
|
||||||
|
array $tokenRec
|
||||||
|
): array {
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole,
|
||||||
|
qs.completedAt AS submissionCompletedAt, qs.sumPoints AS submissionSumPoints,
|
||||||
|
qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus,
|
||||||
|
cl.clientCode, cl.coachID,
|
||||||
|
co.username AS coachUsername,
|
||||||
|
sv.username AS supervisorUsername
|
||||||
|
FROM questionnaire_submission qs
|
||||||
|
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
||||||
|
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||||
|
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
|
||||||
|
WHERE qs.questionnaireID = :qnid AND ($rbacClause)
|
||||||
|
ORDER BY cl.clientCode ASC, qs.version ASC
|
||||||
|
";
|
||||||
|
$params = array_merge([':qnid' => $questionnaireID], $rbacParams);
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$submissions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$questionIDs = array_column($questions, 'questionID');
|
||||||
|
$qPlaceholders = !empty($questionIDs)
|
||||||
|
? implode(',', array_fill(0, count($questionIDs), '?'))
|
||||||
|
: "'__none__'";
|
||||||
|
|
||||||
|
$answerStmt = $pdo->prepare("
|
||||||
|
SELECT questionID, answerOptionID, freeTextValue, numericValue
|
||||||
|
FROM client_answer_submission
|
||||||
|
WHERE submissionID = ? AND questionID IN ($qPlaceholders)
|
||||||
|
");
|
||||||
|
|
||||||
|
$userMap = [];
|
||||||
|
$userStmt = $pdo->query('SELECT userID, username FROM users');
|
||||||
|
foreach ($userStmt->fetchAll(PDO::FETCH_ASSOC) as $u) {
|
||||||
|
$userMap[$u['userID']] = $u['username'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($submissions as $s) {
|
||||||
|
$row = [
|
||||||
|
'submissionID' => $s['submissionID'],
|
||||||
|
'version' => (string)(int)$s['version'],
|
||||||
|
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
|
||||||
|
'submittedByRole'=> $s['submittedByRole'] ?? '',
|
||||||
|
'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''),
|
||||||
|
'clientCode' => $s['clientCode'],
|
||||||
|
'coach' => $s['coachUsername'] ?? $s['coachID'],
|
||||||
|
'supervisor' => $s['supervisorUsername'] ?? '',
|
||||||
|
'status' => $s['submissionStatus'] ?? '',
|
||||||
|
'sumPoints' => $s['submissionSumPoints'] ?? '',
|
||||||
|
'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '',
|
||||||
|
'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '',
|
||||||
|
];
|
||||||
|
|
||||||
|
$bindParams = array_merge([$s['submissionID']], $questionIDs);
|
||||||
|
$answerStmt->execute($bindParams);
|
||||||
|
$answerMap = [];
|
||||||
|
foreach ($answerStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||||
|
$answerMap[$a['questionID']] = $a;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($resultColumns as $col) {
|
||||||
|
$qid = $col['questionID'];
|
||||||
|
$a = $answerMap[$qid] ?? null;
|
||||||
|
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap);
|
||||||
|
}
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{questions: array, resultColumns: array, optionTextMap: array}
|
||||||
|
*/
|
||||||
|
function qdb_export_questionnaire_context(PDO $pdo, string $questionnaireID): array {
|
||||||
|
$qStmt = $pdo->prepare(
|
||||||
|
'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex'
|
||||||
|
);
|
||||||
|
$qStmt->execute([':id' => $questionnaireID]);
|
||||||
|
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$questionIDs = array_column($questions, 'questionID');
|
||||||
|
$resultColumns = qdb_results_export_columns($questions, $questionnaireID);
|
||||||
|
|
||||||
|
$optionTextMap = [];
|
||||||
|
foreach ($questionIDs as $qid) {
|
||||||
|
$aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
|
||||||
|
$aoStmt->execute([':qid' => $qid]);
|
||||||
|
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||||
|
$optionTextMap[$ao['answerOptionID']] = $ao['defaultText'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'questions' => $questions,
|
||||||
|
'resultColumns' => $resultColumns,
|
||||||
|
'optionTextMap' => $optionTextMap,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current (live) response rows for one questionnaire, RBAC-scoped.
|
||||||
|
*
|
||||||
|
* @return list<array<string, string>>
|
||||||
|
*/
|
||||||
|
function qdb_export_current_rows(
|
||||||
|
PDO $pdo,
|
||||||
|
string $questionnaireID,
|
||||||
|
array $questions,
|
||||||
|
array $resultColumns,
|
||||||
|
array $optionTextMap,
|
||||||
|
array $tokenRec
|
||||||
|
): array {
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
$questionIDs = array_column($questions, 'questionID');
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT cl.clientCode, cl.coachID,
|
||||||
|
co.username AS coachUsername,
|
||||||
|
sv.username AS supervisorUsername,
|
||||||
|
cq.status, cq.sumPoints, cq.startedAt, cq.completedAt
|
||||||
|
FROM client cl
|
||||||
|
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
|
||||||
|
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||||
|
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
|
||||||
|
WHERE $rbacClause
|
||||||
|
ORDER BY cl.clientCode
|
||||||
|
";
|
||||||
|
$params = array_merge([':qnid' => $questionnaireID], $rbacParams);
|
||||||
|
$cStmt = $pdo->prepare($sql);
|
||||||
|
$cStmt->execute($params);
|
||||||
|
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$qPlaceholders = !empty($questionIDs) ? implode(',', array_fill(0, count($questionIDs), '?')) : "'__none__'";
|
||||||
|
$answerStmt = $pdo->prepare("
|
||||||
|
SELECT questionID, answerOptionID, freeTextValue, numericValue
|
||||||
|
FROM client_answer
|
||||||
|
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
|
||||||
|
");
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($clients as $c) {
|
||||||
|
$row = [
|
||||||
|
'clientCode' => $c['clientCode'],
|
||||||
|
'coach' => $c['coachUsername'] ?? $c['coachID'],
|
||||||
|
'supervisor' => $c['supervisorUsername'] ?? '',
|
||||||
|
'status' => $c['status'],
|
||||||
|
'sumPoints' => $c['sumPoints'],
|
||||||
|
'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '',
|
||||||
|
'completedAt' => $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '',
|
||||||
|
];
|
||||||
|
|
||||||
|
$bindParams = array_merge([$c['clientCode']], $questionIDs);
|
||||||
|
$answerStmt->execute($bindParams);
|
||||||
|
$answerMap = [];
|
||||||
|
foreach ($answerStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||||
|
$answerMap[$a['questionID']] = $a;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($resultColumns as $col) {
|
||||||
|
$qid = $col['questionID'];
|
||||||
|
$a = $answerMap[$qid] ?? null;
|
||||||
|
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap);
|
||||||
|
}
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_export_safe_basename(string $name): string {
|
||||||
|
$safe = preg_replace('/[^a-zA-Z0-9_-]+/', '_', $name);
|
||||||
|
return $safe !== '' ? $safe : 'questionnaire';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, true> $used */
|
||||||
|
function qdb_export_unique_zip_name(array &$used, string $base): string {
|
||||||
|
$name = $base;
|
||||||
|
$n = 2;
|
||||||
|
while (isset($used[$name])) {
|
||||||
|
$name = preg_replace('/(\.csv)$/i', "_{$n}$1", $base, 1, $count);
|
||||||
|
if (!$count) {
|
||||||
|
$name = $base . '_' . $n;
|
||||||
|
}
|
||||||
|
$n++;
|
||||||
|
}
|
||||||
|
$used[$name] = true;
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, string>> $rows
|
||||||
|
* @param list<string> $fallbackHeader
|
||||||
|
*/
|
||||||
|
function qdb_export_rows_to_csv_string(array $rows, array $fallbackHeader = []): string {
|
||||||
|
$fp = fopen('php://temp', 'r+');
|
||||||
|
if ($fp === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
fwrite($fp, "\xEF\xBB\xBF");
|
||||||
|
if (!empty($rows)) {
|
||||||
|
fputcsv($fp, array_keys($rows[0]));
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
fputcsv($fp, array_values($row));
|
||||||
|
}
|
||||||
|
} elseif ($fallbackHeader !== []) {
|
||||||
|
fputcsv($fp, $fallbackHeader);
|
||||||
|
}
|
||||||
|
rewind($fp);
|
||||||
|
$csv = stream_get_contents($fp);
|
||||||
|
fclose($fp);
|
||||||
|
return $csv !== false ? $csv : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_export_current_csv_fallback_header(array $resultColumns): array {
|
||||||
|
$header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt'];
|
||||||
|
foreach ($resultColumns as $col) {
|
||||||
|
$header[] = $col['header'];
|
||||||
|
}
|
||||||
|
return $header;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_export_all_versions_csv_fallback_header(array $resultColumns): array {
|
||||||
|
$header = [
|
||||||
|
'submissionID', 'version', 'submittedAt', 'submittedByRole', 'submittedBy',
|
||||||
|
'clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt',
|
||||||
|
];
|
||||||
|
foreach ($resultColumns as $col) {
|
||||||
|
$header[] = $col['header'];
|
||||||
|
}
|
||||||
|
return $header;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a ZIP of CSV exports for every questionnaire. Returns path to a temp file (caller must unlink).
|
||||||
|
*/
|
||||||
|
function qdb_build_server_export_zip(PDO $pdo, array $tokenRec, bool $allVersions): string {
|
||||||
|
if (!class_exists('ZipArchive')) {
|
||||||
|
json_error('SERVER_ERROR', 'ZIP export is not available on this server', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$qnRows = $pdo->query(
|
||||||
|
'SELECT questionnaireID, name, version FROM questionnaire ORDER BY orderIndex, name'
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$tmpZip = tempnam(sys_get_temp_dir(), 'qdb_export_');
|
||||||
|
if ($tmpZip === false) {
|
||||||
|
json_error('SERVER_ERROR', 'Could not create export file', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($tmpZip, ZipArchive::OVERWRITE) !== true) {
|
||||||
|
@unlink($tmpZip);
|
||||||
|
json_error('SERVER_ERROR', 'Could not create ZIP archive', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$usedNames = [];
|
||||||
|
foreach ($qnRows as $qn) {
|
||||||
|
$qnID = $qn['questionnaireID'];
|
||||||
|
$ctx = qdb_export_questionnaire_context($pdo, $qnID);
|
||||||
|
$questions = $ctx['questions'];
|
||||||
|
$resultColumns = $ctx['resultColumns'];
|
||||||
|
$optionTextMap = $ctx['optionTextMap'];
|
||||||
|
|
||||||
|
if ($allVersions) {
|
||||||
|
$rows = qdb_export_all_versions_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
|
||||||
|
$fallback = qdb_export_all_versions_csv_fallback_header($resultColumns);
|
||||||
|
$base = qdb_export_safe_basename($qn['name']) . '_all_versions.csv';
|
||||||
|
} else {
|
||||||
|
$rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
|
||||||
|
$fallback = qdb_export_current_csv_fallback_header($resultColumns);
|
||||||
|
$ver = preg_replace('/[^a-zA-Z0-9_.-]+/', '_', (string)($qn['version'] ?? ''));
|
||||||
|
$base = qdb_export_safe_basename($qn['name']) . ($ver !== '' ? "_v{$ver}" : '') . '.csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
$entryName = qdb_export_unique_zip_name($usedNames, $base);
|
||||||
|
$csv = qdb_export_rows_to_csv_string($rows, $fallback);
|
||||||
|
$zip->addFromString($entryName, $csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
$readme = $allVersions
|
||||||
|
? "BW Schützt — all questionnaire response exports (every upload version).\nOne CSV per questionnaire.\n"
|
||||||
|
: "BW Schützt — all questionnaire response exports (current data).\nOne CSV per questionnaire.\n";
|
||||||
|
$zip->addFromString('README.txt', $readme);
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return $tmpZip;
|
||||||
|
}
|
||||||
45
schema.sql
45
schema.sql
@ -138,3 +138,48 @@ CREATE TABLE IF NOT EXISTS session (
|
|||||||
expiresAt INTEGER NOT NULL,
|
expiresAt INTEGER NOT NULL,
|
||||||
temp INTEGER NOT NULL DEFAULT 0
|
temp INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS questionnaire_submission (
|
||||||
|
submissionID TEXT NOT NULL PRIMARY KEY,
|
||||||
|
clientCode TEXT NOT NULL,
|
||||||
|
questionnaireID TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
submittedAt INTEGER NOT NULL,
|
||||||
|
submittedByUserID TEXT NOT NULL DEFAULT '',
|
||||||
|
submittedByRole TEXT NOT NULL DEFAULT '',
|
||||||
|
assignedByCoach TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
startedAt INTEGER,
|
||||||
|
completedAt INTEGER,
|
||||||
|
sumPoints INTEGER,
|
||||||
|
FOREIGN KEY(clientCode) REFERENCES client(clientCode),
|
||||||
|
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID),
|
||||||
|
FOREIGN KEY(assignedByCoach) REFERENCES coach(coachID),
|
||||||
|
UNIQUE(clientCode, questionnaireID, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_submission_client_qn
|
||||||
|
ON questionnaire_submission(clientCode, questionnaireID);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_submission_client_time
|
||||||
|
ON questionnaire_submission(clientCode, submittedAt);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS client_answer_submission (
|
||||||
|
submissionID TEXT NOT NULL,
|
||||||
|
questionID TEXT NOT NULL,
|
||||||
|
answerOptionID TEXT,
|
||||||
|
freeTextValue TEXT,
|
||||||
|
numericValue REAL,
|
||||||
|
answeredAt INTEGER,
|
||||||
|
PRIMARY KEY (submissionID, questionID),
|
||||||
|
FOREIGN KEY(submissionID) REFERENCES questionnaire_submission(submissionID) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(questionID) REFERENCES question(questionID),
|
||||||
|
FOREIGN KEY(answerOptionID) REFERENCES answer_option(answerOptionID)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS client_followup_note (
|
||||||
|
clientCode TEXT NOT NULL PRIMARY KEY,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
updatedByUserID TEXT NOT NULL DEFAULT '',
|
||||||
|
updatedAt INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(clientCode) REFERENCES client(clientCode)
|
||||||
|
);
|
||||||
|
|||||||
@ -1720,6 +1720,176 @@ table.data-table tbody tr.row-orphan-category td {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--toast-error-fg);
|
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,
|
input:disabled,
|
||||||
select:disabled {
|
select:disabled {
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
|
|||||||
@ -29,6 +29,12 @@
|
|||||||
Users
|
Users
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li data-nav-roles="admin supervisor">
|
||||||
<a href="#/clients">
|
<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>
|
<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
|
Assign Clients
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li data-nav-roles="admin supervisor">
|
||||||
<a href="#/translations">
|
<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>
|
<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">
|
<li data-nav-roles="admin">
|
||||||
<a href="#/dev">
|
<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>
|
<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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import { assignmentsPage } from './pages/assignments.js';
|
|||||||
import { clientsPage } from './pages/clients.js';
|
import { clientsPage } from './pages/clients.js';
|
||||||
import { translationsPage } from './pages/translations.js';
|
import { translationsPage } from './pages/translations.js';
|
||||||
import { devPage } from './pages/dev.js';
|
import { devPage } from './pages/dev.js';
|
||||||
|
import { insightsPage } from './pages/insights.js';
|
||||||
|
import { coachesPage } from './pages/coaches.js';
|
||||||
|
|
||||||
// Auth state
|
// Auth state
|
||||||
export function isLoggedIn() {
|
export function isLoggedIn() {
|
||||||
@ -109,6 +111,8 @@ addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
|
|||||||
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
|
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
|
||||||
addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage));
|
addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage));
|
||||||
addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage));
|
addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage));
|
||||||
|
addRoute('/insights', roleGuard(['admin', 'supervisor'], insightsPage));
|
||||||
|
addRoute('/coaches', roleGuard(['admin', 'supervisor'], coachesPage));
|
||||||
addRoute('/dev', roleGuard(['admin'], devPage));
|
addRoute('/dev', roleGuard(['admin'], devPage));
|
||||||
|
|
||||||
// Nav link handling & logout
|
// 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');
|
const app = document.getElementById('app');
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="page-header">
|
<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>
|
||||||
<div class="card" style="max-width:720px">
|
<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">
|
<p class="field-hint callout-warn">
|
||||||
<strong>Local / test only.</strong> Imports prefixed dev users (<code>dev_*</code>),
|
<strong>Local / test only.</strong> Imports prefixed dev users (<code>dev_*</code>),
|
||||||
clients (<code>DEV-CL-*</code>), and completed questionnaire runs.
|
clients (<code>DEV-CL-*</code>), and completed questionnaire runs.
|
||||||
@ -150,6 +163,8 @@ export function devPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
wireAdminZipExport();
|
||||||
|
|
||||||
document.getElementById('devImportBtn').addEventListener('click', async () => {
|
document.getElementById('devImportBtn').addEventListener('click', async () => {
|
||||||
if (!parsedFixture) return;
|
if (!parsedFixture) return;
|
||||||
const btn = document.getElementById('devImportBtn');
|
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 => {
|
body.querySelectorAll('.export-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', onExportCsvClick);
|
btn.addEventListener('click', onExportCsvClick);
|
||||||
});
|
});
|
||||||
|
body.querySelectorAll('.export-btn-all').forEach(btn => {
|
||||||
|
btn.addEventListener('click', onExportAllVersionsClick);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportRowHTML(q) {
|
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><span class="badge badge-${(q.state || 'draft').toLowerCase()}">${esc(q.state || 'draft')}</span></td>
|
||||||
<td>${q.questionCount || 0}</td>
|
<td>${q.questionCount || 0}</td>
|
||||||
<td>${q.completedCount ?? 0}</td>
|
<td>${q.completedCount ?? 0}</td>
|
||||||
<td>
|
<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)}">
|
<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 CSV
|
Export current
|
||||||
</button>
|
</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>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
@ -176,32 +182,56 @@ function wireExportActions() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onExportCsvClick(ev) {
|
async function downloadExportCsv(btn, url, filename) {
|
||||||
const btn = ev.currentTarget;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Downloading...';
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('qdb_token');
|
const token = localStorage.getItem('qdb_token');
|
||||||
const res = await fetch(`../api/export.php?questionnaireID=${encodeURIComponent(btn.dataset.id)}&format=csv`, {
|
const res = await fetch(url, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
||||||
throw new Error(err.error);
|
throw new Error(err.error?.message || err.error || 'Download failed');
|
||||||
}
|
}
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = blobUrl;
|
||||||
a.download = `${btn.dataset.name}_v${btn.dataset.version}.csv`;
|
a.download = filename;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onExportCsvClick(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)}`;
|
||||||
|
await downloadExportCsv(btn, url, `${btn.dataset.name}_v${btn.dataset.version}.csv`);
|
||||||
showToast('Download started', 'success');
|
showToast('Download started', 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
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