diff --git a/api/index.php b/api/index.php index 98d409e..281a49b 100644 --- a/api/index.php +++ b/api/index.php @@ -53,6 +53,8 @@ $routes = [ 'program_sessions' => __DIR__ . '/../handlers/program_sessions.php', 'coaches' => __DIR__ . '/../handlers/coaches.php', 'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php', + 'app_session_schedule' => __DIR__ . '/../handlers/app_session_schedule.php', + 'schedule' => __DIR__ . '/../handlers/schedule.php', 'logout' => __DIR__ . '/../handlers/logout.php', 'session' => __DIR__ . '/../handlers/session.php', 'auth/login' => __DIR__ . '/../handlers/auth.php', diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json index d0043f6..7cf8970 100644 --- a/data/app_ui_strings.json +++ b/data/app_ui_strings.json @@ -126,6 +126,18 @@ "review_scores_total", "save", "save_password_btn", + "schedule_confirm", + "schedule_date_must_be_future", + "schedule_hub_date_heading", + "schedule_later", + "schedule_next_session_body", + "schedule_next_session_eyebrow", + "schedule_next_session_title", + "schedule_pick_date_label", + "schedule_reminder_today", + "schedule_reminder_today_title", + "schedule_reminder_tomorrow", + "schedule_reminder_tomorrow_title", "select_one_answer", "select_one_answer_per_row", "september", @@ -273,6 +285,18 @@ "review_scores_total": "Gewichtete Summe", "save": "Speichern", "save_password_btn": "Passwort speichern", + "schedule_confirm": "Termin speichern", + "schedule_date_must_be_future": "Datum muss in der Zukunft liegen", + "schedule_hub_date_heading": "Geplant · %s", + "schedule_later": "Später planen", + "schedule_next_session_body": "Nächster Termin für diese Sitzung", + "schedule_next_session_eyebrow": "Nächste Sitzung", + "schedule_next_session_title": "Termin planen", + "schedule_pick_date_label": "Datum wählen", + "schedule_reminder_today": "%d Klient(en) haben heute einen Termin", + "schedule_reminder_today_title": "Termine heute", + "schedule_reminder_tomorrow": "%d Klient(en) haben morgen einen Termin", + "schedule_reminder_tomorrow_title": "Termine morgen", "select_one_answer": "Bitte wählen Sie eine Antwort aus!", "select_one_answer_per_row": "Bitte wählen Sie eine Antwort pro Reihe aus!", "september": "September", diff --git a/db_init.php b/db_init.php index c1287ac..1099bd0 100644 --- a/db_init.php +++ b/db_init.php @@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) { define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); } define('QDB_SCHEMA', __DIR__ . '/schema.sql'); -define('QDB_VERSION', 15); +define('QDB_VERSION', 16); function qdb_table_exists(PDO $pdo, string $table): bool { $stmt = $pdo->prepare( @@ -136,6 +136,22 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { } } + if (!qdb_table_exists($pdo, 'client_session_schedule')) { + $pdo->exec(" + CREATE TABLE client_session_schedule ( + clientCode TEXT NOT NULL PRIMARY KEY, + scheduledDate TEXT NOT NULL, + programCycleNumber INTEGER NOT NULL, + programSessionNumber INTEGER NOT NULL, + programSessionID TEXT, + updatedAt INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE + ) + "); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_client_session_schedule_date ON client_session_schedule(scheduledDate)'); + $changed = true; + } + if (qdb_table_exists($pdo, 'questionnaire_structure_snapshot') && qdb_table_exists($pdo, 'questionnaire')) { require_once __DIR__ . '/lib/questionnaire_structure.php'; diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index aac0061..d6274e4 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -514,11 +514,13 @@ if ($fetchClients) { } require_once __DIR__ . '/../lib/program_sessions.php'; + require_once __DIR__ . '/../lib/session_schedule.php'; $clients = array_map(function ($code) use ($completions, $pdo) { return [ 'clientCode' => $code, 'completedQuestionnaires' => $completions[$code] ?? [], 'programProgress' => qdb_client_program_progress($pdo, $code), + 'nextSessionSchedule' => qdb_get_client_session_schedule($pdo, $code), ]; }, $clientCodes); diff --git a/handlers/app_session_schedule.php b/handlers/app_session_schedule.php new file mode 100644 index 0000000..39f370d --- /dev/null +++ b/handlers/app_session_schedule.php @@ -0,0 +1,56 @@ +prepare( + "SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)" + ); + $clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams)); + if (!$clStmt->fetch()) { + qdb_discard($tmpDb, $lockFp); + json_error('NOT_FOUND', 'Client not found or not authorized', 404); + } + + require_once __DIR__ . '/../lib/session_schedule.php'; + qdb_upsert_client_session_schedule( + $pdo, + $clientCode, + $scheduledDate, + $programCycleNumber, + $programSessionNumber, + $programSessionID + ); + + qdb_save($tmpDb, $lockFp); + + json_success([ + 'scheduled' => true, + 'schedule' => qdb_get_client_session_schedule($pdo, $clientCode), + ]); +} catch (Throwable $e) { + qdb_handler_fail($e, 'Save session schedule', null, $tmpDb ?? null, $lockFp ?? null); +} diff --git a/handlers/questionnaires.php b/handlers/questionnaires.php index 3a172a9..5de33a5 100644 --- a/handlers/questionnaires.php +++ b/handlers/questionnaires.php @@ -19,7 +19,14 @@ case 'GET': INNER JOIN client cl ON cl.clientCode = cq.clientCode WHERE cq.questionnaireID = q.questionnaireID AND $rbacClause - ) AS completedCount + ) AS completedCount, + ( + SELECT COUNT(*) + FROM questionnaire_submission qs + INNER JOIN client cl ON cl.clientCode = qs.clientCode + WHERE qs.questionnaireID = q.questionnaireID + AND $rbacClause + ) AS submissionCount FROM questionnaire q LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID GROUP BY q.questionnaireID @@ -33,6 +40,7 @@ case 'GET': $r['showPoints'] = (int)$r['showPoints']; $r['questionCount'] = (int)$r['questionCount']; $r['completedCount'] = (int)$r['completedCount']; + $r['submissionCount'] = (int)$r['submissionCount']; $r['conditionJson'] = $r['conditionJson'] ?: '{}'; } unset($r); diff --git a/handlers/schedule.php b/handlers/schedule.php new file mode 100644 index 0000000..7c5ba3c --- /dev/null +++ b/handlers/schedule.php @@ -0,0 +1,31 @@ + $entries, + 'byDate' => $byDate, + ]); +} catch (Throwable $e) { + qdb_handler_fail($e, 'Load schedule', null, $tmpDb ?? null, $lockFp ?? null); +} diff --git a/lib/session_schedule.php b/lib/session_schedule.php new file mode 100644 index 0000000..97673ec --- /dev/null +++ b/lib/session_schedule.php @@ -0,0 +1,140 @@ + $todayStart; +} + +/** + * @return array{scheduledDate: string, programCycleNumber: int, programSessionNumber: int, programSessionID: ?string, updatedAt: int}|null + */ +function qdb_get_client_session_schedule(PDO $pdo, string $clientCode): ?array +{ + $stmt = $pdo->prepare( + 'SELECT scheduledDate, programCycleNumber, programSessionNumber, programSessionID, updatedAt + FROM client_session_schedule WHERE clientCode = :cc' + ); + $stmt->execute([':cc' => $clientCode]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + return [ + 'scheduledDate' => (string)$row['scheduledDate'], + 'programCycleNumber' => (int)$row['programCycleNumber'], + 'programSessionNumber' => (int)$row['programSessionNumber'], + 'programSessionID' => $row['programSessionID'] !== null && $row['programSessionID'] !== '' + ? (string)$row['programSessionID'] + : null, + 'updatedAt' => (int)$row['updatedAt'], + ]; +} + +function qdb_upsert_client_session_schedule( + PDO $pdo, + string $clientCode, + string $scheduledDate, + int $programCycleNumber, + int $programSessionNumber, + ?string $programSessionID = null +): void { + if (!qdb_scheduled_date_format_valid($scheduledDate)) { + json_error('INVALID_FIELD', 'scheduledDate must be DD-MM-YYYY', 400); + } + if (!qdb_scheduled_date_is_after_today($scheduledDate)) { + json_error('INVALID_FIELD', 'scheduledDate must be in the future', 400); + } + if ($programCycleNumber < 1 || $programSessionNumber < 1) { + json_error('INVALID_FIELD', 'program cycle and session must be positive', 400); + } + + $pdo->prepare( + 'INSERT INTO client_session_schedule ( + clientCode, scheduledDate, programCycleNumber, programSessionNumber, programSessionID, updatedAt + ) VALUES ( + :cc, :sd, :pcn, :psn, :psid, :uat + ) + ON CONFLICT(clientCode) DO UPDATE SET + scheduledDate = excluded.scheduledDate, + programCycleNumber = excluded.programCycleNumber, + programSessionNumber = excluded.programSessionNumber, + programSessionID = excluded.programSessionID, + updatedAt = excluded.updatedAt' + )->execute([ + ':cc' => $clientCode, + ':sd' => $scheduledDate, + ':pcn' => $programCycleNumber, + ':psn' => $programSessionNumber, + ':psid' => $programSessionID, + ':uat' => time(), + ]); +} + +/** + * RBAC-scoped schedule rows for the website Schedule view. + * + * @return list> + */ +function qdb_list_session_schedules_scoped(PDO $pdo, array $tokenRec): array +{ + [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); + $sql = " + SELECT css.clientCode, css.scheduledDate, css.programCycleNumber, css.programSessionNumber, + css.programSessionID, css.updatedAt, + cl.coachID, co.username AS coachUsername, + sv.username AS supervisorUsername + FROM client_session_schedule css + INNER JOIN client cl ON cl.clientCode = css.clientCode + LEFT JOIN coach co ON co.coachID = cl.coachID + LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID + WHERE ($rbacClause) + ORDER BY css.scheduledDate ASC, co.username ASC, css.clientCode ASC + "; + $stmt = $pdo->prepare($sql); + $stmt->execute($rbacParams); + $rows = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $rows[] = [ + 'clientCode' => $row['clientCode'], + 'scheduledDate' => $row['scheduledDate'], + 'programCycleNumber' => (int)$row['programCycleNumber'], + 'programSessionNumber' => (int)$row['programSessionNumber'], + 'programSessionID' => $row['programSessionID'], + 'coachID' => $row['coachID'], + 'coachUsername' => $row['coachUsername'] ?? $row['coachID'], + 'supervisorUsername' => $row['supervisorUsername'] ?? '', + 'updatedAt' => (int)$row['updatedAt'], + ]; + } + return $rows; +} diff --git a/schema.sql b/schema.sql index a2f934d..0567f31 100644 --- a/schema.sql +++ b/schema.sql @@ -294,3 +294,15 @@ CREATE TABLE IF NOT EXISTS client_program_cycle ( FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS client_session_schedule ( + clientCode TEXT NOT NULL PRIMARY KEY, + scheduledDate TEXT NOT NULL, + programCycleNumber INTEGER NOT NULL, + programSessionNumber INTEGER NOT NULL, + programSessionID TEXT, + updatedAt INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_client_session_schedule_date + ON client_session_schedule(scheduledDate); diff --git a/website/index.html b/website/index.html index 336e596..44f7424 100644 --- a/website/index.html +++ b/website/index.html @@ -29,6 +29,12 @@ Sessions +
  • + + + Schedule + +
  • diff --git a/website/js/app.js b/website/js/app.js index d051401..e7fe5c4 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -14,6 +14,7 @@ import { devPage } from './pages/dev.js'; import { insightsPage } from './pages/insights.js'; import { coachesPage } from './pages/coaches.js'; import { sessionsPage } from './pages/sessions.js'; +import { schedulePage } from './pages/schedule.js'; import { openChangeOwnPasswordModal } from './password-modal.js'; // Auth state @@ -136,6 +137,9 @@ function updateNav() { if (!active && href === '/sessions' && hash.startsWith('/sessions')) { active = true; } + if (!active && href === '/schedule' && hash.startsWith('/schedule')) { + active = true; + } if (!active && href !== '/' && hash.startsWith(href)) { active = true; } @@ -160,6 +164,7 @@ addRoute('/login', loginPage); addRoute('/', roleGuard(['admin', 'supervisor'], homeDashboardPage)); addRoute('/questionnaires', authGuard(questionnairesPage)); addRoute('/sessions', roleGuard(['admin', 'supervisor'], sessionsPage)); +addRoute('/schedule', roleGuard(['admin', 'supervisor'], schedulePage)); addRoute('/questionnaire/new', authGuard(editorPage)); addRoute('/questionnaire/:id/results', authGuard(resultsPage)); addRoute('/questionnaire/:id', authGuard(editorPage)); diff --git a/website/js/pages/export.js b/website/js/pages/export.js index aef0ee1..15e9ad3 100644 --- a/website/js/pages/export.js +++ b/website/js/pages/export.js @@ -62,7 +62,7 @@ function renderExport() { Version State Questions - Completed + Uploaded responses Actions @@ -133,7 +133,7 @@ function exportRowHTML(q) { ${esc(q.version || '—')} ${esc(q.state || 'draft')} ${q.questionCount || 0} - ${q.completedCount ?? 0} + ${q.submissionCount ?? 0}