diff --git a/api/index.php b/api/index.php index e767421..052403a 100644 --- a/api/index.php +++ b/api/index.php @@ -41,6 +41,8 @@ $routes = [ 'clients' => __DIR__ . '/../handlers/clients.php', 'results' => __DIR__ . '/../handlers/results.php', 'export' => __DIR__ . '/../handlers/export.php', + 'analytics' => __DIR__ . '/../handlers/analytics.php', + 'coaches' => __DIR__ . '/../handlers/coaches.php', 'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php', 'logout' => __DIR__ . '/../handlers/logout.php', 'auth/login' => __DIR__ . '/../handlers/auth.php', diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json index 475ef0b..8be0d8f 100644 --- a/data/app_ui_strings.json +++ b/data/app_ui_strings.json @@ -182,6 +182,7 @@ "upload_success_message": "Hochladen: {count} erledigt.", "username_hint": "Benutzername", "year": "Jahr", - "questionnaire_group_demographics": "Demografie" + "questionnaire_group_demographics": "Demografie", + "questionnaire_group_rhs": "Gesundheitsinterview" } } diff --git a/db_init.php b/db_init.php index 0ffab33..3b8696c 100644 --- a/db_init.php +++ b/db_init.php @@ -6,7 +6,15 @@ require_once __DIR__ . '/common.php'; define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database'); define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock'); 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 { $stmt = $pdo->query('PRAGMA table_info(' . $table . ')'); @@ -29,6 +37,70 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { $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(); if ($currentVersion < QDB_VERSION) { $pdo->exec('PRAGMA foreign_keys = OFF;'); diff --git a/docs/android-questionnaire-api.md b/docs/android-questionnaire-api.md index 9d7751d..2b5ef31 100644 --- a/docs/android-questionnaire-api.md +++ b/docs/android-questionnaire-api.md @@ -468,8 +468,9 @@ Current upload behavior: - Unknown or empty `questionID` values are skipped. - 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). +- 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. - 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. diff --git a/handlers/analytics.php b/handlers/analytics.php new file mode 100644 index 0000000..2d10b23 --- /dev/null +++ b/handlers/analytics.php @@ -0,0 +1,53 @@ + $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); +} diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index b845d74..b1f1c2a 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -211,6 +211,18 @@ if ($method === 'POST') { ':sp' => $sumPoints, ]); + require_once __DIR__ . '/../lib/submissions.php'; + qdb_record_submission_after_submit( + $pdo, + $clientCode, + $qnID, + $tokenRec, + $startedAt, + $completedAt, + $sumPoints, + $assignedByCoach + ); + $pdo->commit(); qdb_save($tmpDb, $lockFp); diff --git a/handlers/coaches.php b/handlers/coaches.php new file mode 100644 index 0000000..7898e30 --- /dev/null +++ b/handlers/coaches.php @@ -0,0 +1,28 @@ + $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); +} diff --git a/handlers/export.php b/handlers/export.php index 92329f7..1512a37 100644 --- a/handlers/export.php +++ b/handlers/export.php @@ -1,5 +1,7 @@ prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id"); +$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id'); $qn->execute([':id' => $qnID]); $questionnaire = $qn->fetch(PDO::FETCH_ASSOC); if (!$questionnaire) { @@ -34,95 +56,30 @@ if (!$questionnaire) { 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"); -$qStmt->execute([':id' => $qnID]); -$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC); -$questionIDs = array_column($questions, 'questionID'); -$resultColumns = qdb_results_export_columns($questions, $qnID); +$ctx = qdb_export_questionnaire_context($pdo, $qnID); +$questions = $ctx['questions']; +$resultColumns = $ctx['resultColumns']; +$optionTextMap = $ctx['optionTextMap']; -$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']; - } -} - -[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); - -$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; +if ($allVersions) { + $rows = qdb_export_all_versions_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec); + qdb_discard($tmpDb, $lockFp); + + $safeName = qdb_export_safe_basename($questionnaire['name']); + $filename = $safeName . '_all_versions_' . date('Y-m-d') . '.csv'; + + header('Content-Type: text/csv; charset=UTF-8'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + echo qdb_export_rows_to_csv_string($rows, qdb_export_all_versions_csv_fallback_header($resultColumns)); + exit; } +$rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec); qdb_discard($tmpDb, $lockFp); -// CSV output (not JSON -- overrides the router's Content-Type) -$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $questionnaire['name']); +$safeName = qdb_export_safe_basename($questionnaire['name']); $filename = $safeName . '_v' . $questionnaire['version'] . '.csv'; header('Content-Type: text/csv; charset=UTF-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); - -$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); +echo qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($resultColumns)); diff --git a/lib/analytics.php b/lib/analytics.php new file mode 100644 index 0000000..68556c4 --- /dev/null +++ b/lib/analytics.php @@ -0,0 +1,304 @@ + + */ +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); +} diff --git a/lib/dev_fixture.php b/lib/dev_fixture.php index 2f01987..89a9dc5 100644 --- a/lib/dev_fixture.php +++ b/lib/dev_fixture.php @@ -768,6 +768,15 @@ function qdb_wipe_all_data_except_admins(PDO $pdo): array $pdo->exec('PRAGMA foreign_keys = OFF'); $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['clients'] = (int)$pdo->exec('DELETE FROM client'); diff --git a/lib/submissions.php b/lib/submissions.php new file mode 100644 index 0000000..0cc8c9c --- /dev/null +++ b/lib/submissions.php @@ -0,0 +1,453 @@ +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> + */ +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> + */ +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 $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> $rows + * @param list $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; +} diff --git a/schema.sql b/schema.sql index d5f2a94..15ba22a 100644 --- a/schema.sql +++ b/schema.sql @@ -138,3 +138,48 @@ CREATE TABLE IF NOT EXISTS session ( expiresAt INTEGER NOT NULL, 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) +); diff --git a/website/css/style.css b/website/css/style.css index 1ad861e..3d9d891 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -1720,6 +1720,176 @@ table.data-table tbody tr.row-orphan-category td { border-radius: 8px; color: var(--toast-error-fg); } +.insights-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 16px; +} +.insights-kpi { + padding: 20px; + text-align: center; +} +.insights-kpi-value { + font-size: 2rem; + font-weight: 700; + color: var(--text); + line-height: 1.2; +} +.insights-kpi-label { + font-size: 0.85rem; + color: var(--text-secondary); + margin-top: 6px; +} +.insights-charts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + margin-top: 20px; +} +.insights-chart-card h3 { + margin: 0 0 4px; + font-size: 1rem; +} +.insights-chart-hint { + margin: 0 0 14px; + font-size: 0.82rem; + color: var(--text-secondary); +} +.insights-vchart { + display: flex; + align-items: flex-end; + gap: 6px; + height: 160px; + padding: 8px 4px 0; + border-bottom: 1px solid var(--border); +} +.insights-vchart-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; +} +.insights-vchart-bar-wrap { + flex: 1; + width: 100%; + max-width: 36px; + display: flex; + align-items: flex-end; + justify-content: center; +} +.insights-vchart-bar { + width: 100%; + min-height: 2px; + border-radius: 4px 4px 0 0; + background: linear-gradient(180deg, var(--primary-hover), var(--primary)); + transition: height 0.2s ease; +} +.insights-vchart-value { + font-size: 0.7rem; + color: var(--text-secondary); + margin-top: 4px; + line-height: 1; +} +.insights-vchart-label { + font-size: 0.65rem; + color: var(--text-secondary); + margin-top: 2px; + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.insights-hchart { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; +} +.insights-hchart-row { + display: grid; + grid-template-columns: minmax(100px, 28%) 1fr minmax(40px, auto); + gap: 10px; + align-items: center; + font-size: 0.85rem; +} +.insights-hchart-label { + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.insights-hchart-track { + height: 22px; + background: var(--surface-muted); + border-radius: 4px; + overflow: hidden; +} +.insights-hchart-fill { + height: 100%; + min-width: 2px; + border-radius: 4px; + background: linear-gradient(90deg, var(--primary), var(--primary-hover)); +} +.insights-hchart-fill--warn { + background: linear-gradient(90deg, #d97706, var(--warning)); +} +.insights-hchart-fill--muted { + background: var(--border); +} +.insights-hchart-num { + text-align: right; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} +.followup-note-input { + width: 100%; + min-width: 180px; + max-width: 320px; + font-family: inherit; + font-size: 0.85rem; + padding: 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + resize: vertical; +} +.export-actions-cell { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.export-actions-cell .btn { + margin: 0; +} +.coach-recent-panel { + padding: 12px 8px 8px 32px; + background: var(--surface-muted); + border-radius: 8px; + margin: 4px 0 8px; +} +.data-table-compact td, +.data-table-compact th { + padding: 8px 10px; + font-size: 0.85rem; +} +.btn-link { + background: none; + border: none; + padding: 0 6px 0 0; + color: var(--accent); + cursor: pointer; + min-width: auto; +} +.coach-detail-row td { + border-top: none; + padding-top: 0; +} + input:disabled, select:disabled { background: var(--surface-muted); diff --git a/website/index.html b/website/index.html index 3406215..5e0c83d 100644 --- a/website/index.html +++ b/website/index.html @@ -29,6 +29,12 @@ Users +
  • + + + Coach activity + +
  • @@ -41,6 +47,12 @@ Assign Clients
  • +
  • + + + Insights + +
  • @@ -56,7 +68,7 @@
  • - Dev import + Admin Settings
  • diff --git a/website/js/app.js b/website/js/app.js index 71e4f35..65769e7 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -9,6 +9,8 @@ import { assignmentsPage } from './pages/assignments.js'; import { clientsPage } from './pages/clients.js'; import { translationsPage } from './pages/translations.js'; import { devPage } from './pages/dev.js'; +import { insightsPage } from './pages/insights.js'; +import { coachesPage } from './pages/coaches.js'; // Auth state export function isLoggedIn() { @@ -109,6 +111,8 @@ addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage)); addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage)); addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage)); addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage)); +addRoute('/insights', roleGuard(['admin', 'supervisor'], insightsPage)); +addRoute('/coaches', roleGuard(['admin', 'supervisor'], coachesPage)); addRoute('/dev', roleGuard(['admin'], devPage)); // Nav link handling & logout diff --git a/website/js/pages/coaches.js b/website/js/pages/coaches.js new file mode 100644 index 0000000..98af45b --- /dev/null +++ b/website/js/pages/coaches.js @@ -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 = ` + +
    + `; + + try { + const data = await apiGet('coaches.php'); + coachesList = data.coaches || []; + renderCoaches(); + } catch (e) { + showToast(e.message, 'error'); + document.getElementById('coachesContent').innerHTML = + `

    ${esc(e.message)}

    `; + } +} + +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 = `

    No coaches

    `; + return; + } + + el.innerHTML = ` +
    +

    Track uploads and client load per coach. Expand a row for recent submissions.

    +
    + + +
    +
    + + + + + + + + +
    CoachSupervisorClientsTotal uploads7d30dLast upload
    +
    +
    + `; + + 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 + ? ` ` + : ''; + return ` + + ${expandControl}${esc(c.username)} + ${esc(c.supervisorUsername || '—')} + ${c.clientCount ?? 0} + ${c.totalSubmissions ?? 0} + ${c.submissionsLast7d ?? 0} + ${c.submissionsLast30d ?? 0} + ${esc(c.lastSubmissionAt || '—')} + + ${expanded ? ` + + +
    + ${recent.length ? ` + + + + + + ${recent.map(s => ` + + + + + + + `).join('')} + +
    WhenClientQuestionnaireVer.
    ${esc(s.submittedAt)}${esc(s.clientCode)}${esc(s.questionnaireName)}${s.version}
    + ` : '

    Loading…

    '} +
    + + ` : ''}`; +} + +function renderCoachTableBody() { + const body = document.getElementById('coachTableBody'); + const countEl = document.getElementById('coachListCount'); + if (!body) return; + + const filtered = filteredCoaches(); + + if (!filtered.length) { + body.innerHTML = ` + + + No coaches match + + `; + 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; +} diff --git a/website/js/pages/dev.js b/website/js/pages/dev.js index 616b41b..a68ba06 100644 --- a/website/js/pages/dev.js +++ b/website/js/pages/dev.js @@ -11,9 +11,22 @@ export function devPage() { const app = document.getElementById('app'); app.innerHTML = ` +
    +

    Export all response data (ZIP)

    +

    + Download one CSV per questionnaire in a single ZIP (full server data). + Current data uses live answers; + All versions includes every upload (one row per submission). +

    +
    + + +
    +

    Dev import

    Local / test only. Imports prefixed dev users (dev_*), clients (DEV-CL-*), and completed questionnaire runs. @@ -150,6 +163,8 @@ export function devPage() { } }); + wireAdminZipExport(); + document.getElementById('devImportBtn').addEventListener('click', async () => { if (!parsedFixture) return; const btn = document.getElementById('devImportBtn'); @@ -184,3 +199,62 @@ export function devPage() { } }); } + +function filenameFromContentDisposition(res, fallback) { + const disp = res.headers.get('Content-Disposition'); + if (!disp) return fallback; + const m = /filename="([^"]+)"/i.exec(disp); + return m ? m[1] : fallback; +} + +async function downloadExportZip(url, defaultFilename) { + const token = localStorage.getItem('qdb_token'); + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Download failed' })); + throw new Error(err.error?.message || err.error || 'Download failed'); + } + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = filenameFromContentDisposition(res, defaultFilename); + a.click(); + URL.revokeObjectURL(blobUrl); +} + +function wireAdminZipExport() { + document.getElementById('exportAllCurrentZipBtn')?.addEventListener('click', async () => { + const btn = document.getElementById('exportAllCurrentZipBtn'); + btn.disabled = true; + const prev = btn.textContent; + btn.textContent = 'Preparing ZIP…'; + try { + await downloadExportZip('../api/export?exportAll=1', 'responses_export_current.zip'); + showToast('ZIP download started (current data)', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = prev; + } + }); + + document.getElementById('exportAllVersionsZipBtn')?.addEventListener('click', async () => { + const btn = document.getElementById('exportAllVersionsZipBtn'); + btn.disabled = true; + const prev = btn.textContent; + btn.textContent = 'Preparing ZIP…'; + try { + await downloadExportZip('../api/export?exportAll=1&allVersions=1', 'responses_export_all_versions.zip'); + showToast('ZIP download started (all versions)', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = prev; + } + }); +} diff --git a/website/js/pages/export.js b/website/js/pages/export.js index 14b8663..7b1999b 100644 --- a/website/js/pages/export.js +++ b/website/js/pages/export.js @@ -124,6 +124,9 @@ function renderExportTableBody() { body.querySelectorAll('.export-btn').forEach(btn => { btn.addEventListener('click', onExportCsvClick); }); + body.querySelectorAll('.export-btn-all').forEach(btn => { + btn.addEventListener('click', onExportAllVersionsClick); + }); } function exportRowHTML(q) { @@ -134,11 +137,14 @@ function exportRowHTML(q) { ${esc(q.state || 'draft')} ${q.questionCount || 0} ${q.completedCount ?? 0} - - - View Results + + Results `; } @@ -176,32 +182,56 @@ function wireExportActions() { }); } +async function downloadExportCsv(btn, url, filename) { + const token = localStorage.getItem('qdb_token'); + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Download failed' })); + throw new Error(err.error?.message || err.error || 'Download failed'); + } + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = filename; + a.click(); + URL.revokeObjectURL(blobUrl); +} + async function onExportCsvClick(ev) { const btn = ev.currentTarget; btn.disabled = true; - btn.textContent = 'Downloading...'; + const prev = btn.textContent; + btn.textContent = 'Downloading…'; try { - const token = localStorage.getItem('qdb_token'); - const res = await fetch(`../api/export.php?questionnaireID=${encodeURIComponent(btn.dataset.id)}&format=csv`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: 'Download failed' })); - throw new Error(err.error); - } - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${btn.dataset.name}_v${btn.dataset.version}.csv`; - a.click(); - URL.revokeObjectURL(url); + const url = `../api/export?questionnaireID=${encodeURIComponent(btn.dataset.id)}`; + await downloadExportCsv(btn, url, `${btn.dataset.name}_v${btn.dataset.version}.csv`); showToast('Download started', 'success'); } catch (e) { showToast(e.message, 'error'); } finally { btn.disabled = false; - btn.textContent = 'Export CSV'; + btn.textContent = prev; + } +} + +async function onExportAllVersionsClick(ev) { + const btn = ev.currentTarget; + btn.disabled = true; + const prev = btn.textContent; + btn.textContent = 'Downloading…'; + try { + const url = `../api/export?questionnaireID=${encodeURIComponent(btn.dataset.id)}&allVersions=1`; + const stamp = new Date().toISOString().slice(0, 10); + await downloadExportCsv(btn, url, `${btn.dataset.name}_all_versions_${stamp}.csv`); + showToast('All versions download started', 'success'); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = prev; } } diff --git a/website/js/pages/insights.js b/website/js/pages/insights.js new file mode 100644 index 0000000..e3f2b07 --- /dev/null +++ b/website/js/pages/insights.js @@ -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 = ` +

    +
    + `; + + 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 = + `

    ${esc(e.message)}

    `; + } +} + +function renderInsights() { + const el = document.getElementById('insightsContent'); + const ov = overview || {}; + + const kpiCards = ` +
    +
    +
    ${ov.clientCount ?? 0}
    +
    Clients
    +
    +
    +
    ${ov.clientsWithCompletions ?? 0}
    +
    With completions
    +
    +
    +
    ${ov.submissionsLast7d ?? 0}
    +
    Uploads (7 days)
    +
    +
    +
    ${ov.submissionsLast30d ?? 0}
    +
    Uploads (30 days)
    +
    +
    + `; + + 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 => ` + + ${esc(q.name)} + ${q.completedCount ?? 0} / ${q.eligibleCount ?? 0} + ${q.completionRatio ?? 0}% + + `).join(''); + + el.innerHTML = ` + ${kpiCards} +
    +
    +

    Uploads per day

    +

    Last 14 days

    + ${uploadChart} +
    +
    +

    Client engagement

    +

    Among clients in your scope

    + ${engagementChart} +
    +
    +

    Days since last activity

    +

    Clients with at least one completion

    + ${idleChart} +
    +
    +
    +

    Completion by questionnaire

    +

    Share of clients who completed each active questionnaire

    + ${completionChart} +
    + + + + + ${qRows || ''} +
    QuestionnaireCompletedRate
    No active questionnaires
    +
    +
    +
    +

    Needs follow-up

    +

    + Clients who completed at least one questionnaire, sorted by longest time since any upload activity. +

    +
    + + +
    +
    + + + + + + + + +
    ClientCoachFirst completedFirst atLast activityDays idleNote
    +
    +
    + `; + + 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 ` + + ${esc(c.clientCode)} + ${esc(c.coachUsername)} + ${esc(c.firstQuestionnaireName || '—')} + ${esc(c.firstCompletedAt || '—')} + ${esc(c.lastActivityAt || '—')} + ${c.daysSinceLastActivity != null ? c.daysSinceLastActivity : '—'} + + + + + `; +} + +function renderFollowupTableBody() { + const body = document.getElementById('followupTableBody'); + const countEl = document.getElementById('followupListCount'); + if (!body) return; + + const filtered = filteredFollowupClients(); + + if (!filtered.length) { + body.innerHTML = ` + + + ${staleClients.length ? 'No clients match' : 'No clients'} + + `; + 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 `

    ${esc(emptyLabel)}

    `; + } + const max = chartMax(items.map(i => i.value)); + return ` + `; +} + +function horizontalBarChart(items, { suffix = '%', max: fixedMax, variant = '' } = {}) { + if (!items.length) { + return '

    No data

    '; + } + const max = fixedMax ?? chartMax(items.map(i => i.value)); + const fillClass = variant === 'warn' ? 'insights-hchart-fill--warn' + : variant === 'muted' ? 'insights-hchart-fill--muted' + : ''; + return ` + `; +} + +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); +}