This commit is contained in:
@ -53,6 +53,8 @@ $routes = [
|
|||||||
'program_sessions' => __DIR__ . '/../handlers/program_sessions.php',
|
'program_sessions' => __DIR__ . '/../handlers/program_sessions.php',
|
||||||
'coaches' => __DIR__ . '/../handlers/coaches.php',
|
'coaches' => __DIR__ . '/../handlers/coaches.php',
|
||||||
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.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',
|
'logout' => __DIR__ . '/../handlers/logout.php',
|
||||||
'session' => __DIR__ . '/../handlers/session.php',
|
'session' => __DIR__ . '/../handlers/session.php',
|
||||||
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
||||||
|
|||||||
@ -126,6 +126,18 @@
|
|||||||
"review_scores_total",
|
"review_scores_total",
|
||||||
"save",
|
"save",
|
||||||
"save_password_btn",
|
"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",
|
||||||
"select_one_answer_per_row",
|
"select_one_answer_per_row",
|
||||||
"september",
|
"september",
|
||||||
@ -273,6 +285,18 @@
|
|||||||
"review_scores_total": "Gewichtete Summe",
|
"review_scores_total": "Gewichtete Summe",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"save_password_btn": "Passwort 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": "Bitte wählen Sie eine Antwort aus!",
|
||||||
"select_one_answer_per_row": "Bitte wählen Sie eine Antwort pro Reihe aus!",
|
"select_one_answer_per_row": "Bitte wählen Sie eine Antwort pro Reihe aus!",
|
||||||
"september": "September",
|
"september": "September",
|
||||||
|
|||||||
18
db_init.php
18
db_init.php
@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) {
|
|||||||
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
|
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
|
||||||
}
|
}
|
||||||
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
||||||
define('QDB_VERSION', 15);
|
define('QDB_VERSION', 16);
|
||||||
|
|
||||||
function qdb_table_exists(PDO $pdo, string $table): bool {
|
function qdb_table_exists(PDO $pdo, string $table): bool {
|
||||||
$stmt = $pdo->prepare(
|
$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')
|
if (qdb_table_exists($pdo, 'questionnaire_structure_snapshot')
|
||||||
&& qdb_table_exists($pdo, 'questionnaire')) {
|
&& qdb_table_exists($pdo, 'questionnaire')) {
|
||||||
require_once __DIR__ . '/lib/questionnaire_structure.php';
|
require_once __DIR__ . '/lib/questionnaire_structure.php';
|
||||||
|
|||||||
@ -514,11 +514,13 @@ if ($fetchClients) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_once __DIR__ . '/../lib/program_sessions.php';
|
require_once __DIR__ . '/../lib/program_sessions.php';
|
||||||
|
require_once __DIR__ . '/../lib/session_schedule.php';
|
||||||
$clients = array_map(function ($code) use ($completions, $pdo) {
|
$clients = array_map(function ($code) use ($completions, $pdo) {
|
||||||
return [
|
return [
|
||||||
'clientCode' => $code,
|
'clientCode' => $code,
|
||||||
'completedQuestionnaires' => $completions[$code] ?? [],
|
'completedQuestionnaires' => $completions[$code] ?? [],
|
||||||
'programProgress' => qdb_client_program_progress($pdo, $code),
|
'programProgress' => qdb_client_program_progress($pdo, $code),
|
||||||
|
'nextSessionSchedule' => qdb_get_client_session_schedule($pdo, $code),
|
||||||
];
|
];
|
||||||
}, $clientCodes);
|
}, $clientCodes);
|
||||||
|
|
||||||
|
|||||||
56
handlers/app_session_schedule.php
Normal file
56
handlers/app_session_schedule.php
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$tokenRec = require_valid_token();
|
||||||
|
|
||||||
|
if ($method !== 'POST') {
|
||||||
|
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = read_json_body();
|
||||||
|
require_fields($body, ['clientCode', 'scheduledDate', 'programCycleNumber', 'programSessionNumber']);
|
||||||
|
|
||||||
|
$clientCode = trim((string)$body['clientCode']);
|
||||||
|
$scheduledDate = trim((string)$body['scheduledDate']);
|
||||||
|
$programCycleNumber = max(1, (int)$body['programCycleNumber']);
|
||||||
|
$programSessionNumber = max(1, (int)$body['programSessionNumber']);
|
||||||
|
$programSessionID = isset($body['programSessionID'])
|
||||||
|
? trim((string)$body['programSessionID'])
|
||||||
|
: null;
|
||||||
|
if ($programSessionID === '') {
|
||||||
|
$programSessionID = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$opened = qdb_open_write_or_fail();
|
||||||
|
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||||
|
|
||||||
|
require_role(['coach'], $tokenRec);
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
$clStmt = $pdo->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);
|
||||||
|
}
|
||||||
@ -19,7 +19,14 @@ case 'GET':
|
|||||||
INNER JOIN client cl ON cl.clientCode = cq.clientCode
|
INNER JOIN client cl ON cl.clientCode = cq.clientCode
|
||||||
WHERE cq.questionnaireID = q.questionnaireID
|
WHERE cq.questionnaireID = q.questionnaireID
|
||||||
AND $rbacClause
|
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
|
FROM questionnaire q
|
||||||
LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID
|
LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID
|
||||||
GROUP BY q.questionnaireID
|
GROUP BY q.questionnaireID
|
||||||
@ -33,6 +40,7 @@ case 'GET':
|
|||||||
$r['showPoints'] = (int)$r['showPoints'];
|
$r['showPoints'] = (int)$r['showPoints'];
|
||||||
$r['questionCount'] = (int)$r['questionCount'];
|
$r['questionCount'] = (int)$r['questionCount'];
|
||||||
$r['completedCount'] = (int)$r['completedCount'];
|
$r['completedCount'] = (int)$r['completedCount'];
|
||||||
|
$r['submissionCount'] = (int)$r['submissionCount'];
|
||||||
$r['conditionJson'] = $r['conditionJson'] ?: '{}';
|
$r['conditionJson'] = $r['conditionJson'] ?: '{}';
|
||||||
}
|
}
|
||||||
unset($r);
|
unset($r);
|
||||||
|
|||||||
31
handlers/schedule.php
Normal file
31
handlers/schedule.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
$tokenRec = require_valid_token_web();
|
||||||
|
require_role(['admin', 'supervisor'], $tokenRec);
|
||||||
|
|
||||||
|
if ($method !== 'GET') {
|
||||||
|
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$opened = qdb_open_read_or_fail();
|
||||||
|
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/session_schedule.php';
|
||||||
|
$entries = qdb_list_session_schedules_scoped($pdo, $tokenRec);
|
||||||
|
|
||||||
|
$byDate = [];
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
$date = $entry['scheduledDate'];
|
||||||
|
$byDate[$date][] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
|
||||||
|
json_success([
|
||||||
|
'entries' => $entries,
|
||||||
|
'byDate' => $byDate,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
qdb_handler_fail($e, 'Load schedule', null, $tmpDb ?? null, $lockFp ?? null);
|
||||||
|
}
|
||||||
140
lib/session_schedule.php
Normal file
140
lib/session_schedule.php
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Next program session appointments (date-only, DD-MM-YYYY).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @return bool */
|
||||||
|
function qdb_scheduled_date_format_valid(string $date): bool
|
||||||
|
{
|
||||||
|
if (!preg_match('/^\d{2}-\d{2}-\d{4}$/', $date)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
[$d, $m, $y] = array_map('intval', explode('-', $date));
|
||||||
|
return checkdate($m, $d, $y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start of calendar day in server default timezone. */
|
||||||
|
function qdb_scheduled_date_to_start_of_day_unix(string $date): ?int
|
||||||
|
{
|
||||||
|
if (!qdb_scheduled_date_format_valid($date)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
[$d, $m, $y] = explode('-', $date);
|
||||||
|
$ts = mktime(0, 0, 0, (int)$m, (int)$d, (int)$y);
|
||||||
|
return $ts !== false ? $ts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_scheduled_date_is_after_today(string $date, ?int $nowTs = null): bool
|
||||||
|
{
|
||||||
|
$dayStart = qdb_scheduled_date_to_start_of_day_unix($date);
|
||||||
|
if ($dayStart === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$nowTs ??= time();
|
||||||
|
$todayStart = strtotime('today', $nowTs);
|
||||||
|
return $dayStart > $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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
12
schema.sql
12
schema.sql
@ -294,3 +294,15 @@ CREATE TABLE IF NOT EXISTS client_program_cycle (
|
|||||||
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE
|
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);
|
||||||
|
|||||||
@ -29,6 +29,12 @@
|
|||||||
Sessions
|
Sessions
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li data-nav-roles="admin supervisor">
|
||||||
|
<a href="#/schedule">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01"/></svg>
|
||||||
|
Schedule
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/questionnaires">
|
<a href="#/questionnaires">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/></svg>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { devPage } from './pages/dev.js';
|
|||||||
import { insightsPage } from './pages/insights.js';
|
import { insightsPage } from './pages/insights.js';
|
||||||
import { coachesPage } from './pages/coaches.js';
|
import { coachesPage } from './pages/coaches.js';
|
||||||
import { sessionsPage } from './pages/sessions.js';
|
import { sessionsPage } from './pages/sessions.js';
|
||||||
|
import { schedulePage } from './pages/schedule.js';
|
||||||
import { openChangeOwnPasswordModal } from './password-modal.js';
|
import { openChangeOwnPasswordModal } from './password-modal.js';
|
||||||
|
|
||||||
// Auth state
|
// Auth state
|
||||||
@ -136,6 +137,9 @@ function updateNav() {
|
|||||||
if (!active && href === '/sessions' && hash.startsWith('/sessions')) {
|
if (!active && href === '/sessions' && hash.startsWith('/sessions')) {
|
||||||
active = true;
|
active = true;
|
||||||
}
|
}
|
||||||
|
if (!active && href === '/schedule' && hash.startsWith('/schedule')) {
|
||||||
|
active = true;
|
||||||
|
}
|
||||||
if (!active && href !== '/' && hash.startsWith(href)) {
|
if (!active && href !== '/' && hash.startsWith(href)) {
|
||||||
active = true;
|
active = true;
|
||||||
}
|
}
|
||||||
@ -160,6 +164,7 @@ addRoute('/login', loginPage);
|
|||||||
addRoute('/', roleGuard(['admin', 'supervisor'], homeDashboardPage));
|
addRoute('/', roleGuard(['admin', 'supervisor'], homeDashboardPage));
|
||||||
addRoute('/questionnaires', authGuard(questionnairesPage));
|
addRoute('/questionnaires', authGuard(questionnairesPage));
|
||||||
addRoute('/sessions', roleGuard(['admin', 'supervisor'], sessionsPage));
|
addRoute('/sessions', roleGuard(['admin', 'supervisor'], sessionsPage));
|
||||||
|
addRoute('/schedule', roleGuard(['admin', 'supervisor'], schedulePage));
|
||||||
addRoute('/questionnaire/new', authGuard(editorPage));
|
addRoute('/questionnaire/new', authGuard(editorPage));
|
||||||
addRoute('/questionnaire/:id/results', authGuard(resultsPage));
|
addRoute('/questionnaire/:id/results', authGuard(resultsPage));
|
||||||
addRoute('/questionnaire/:id', authGuard(editorPage));
|
addRoute('/questionnaire/:id', authGuard(editorPage));
|
||||||
|
|||||||
@ -62,7 +62,7 @@ function renderExport() {
|
|||||||
<th>Version</th>
|
<th>Version</th>
|
||||||
<th>State</th>
|
<th>State</th>
|
||||||
<th>Questions</th>
|
<th>Questions</th>
|
||||||
<th>Completed</th>
|
<th>Uploaded responses</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -133,7 +133,7 @@ function exportRowHTML(q) {
|
|||||||
<td>${esc(q.version || '—')}</td>
|
<td>${esc(q.version || '—')}</td>
|
||||||
<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.submissionCount ?? 0}</td>
|
||||||
<td class="export-actions-cell">
|
<td class="export-actions-cell">
|
||||||
<button class="btn btn-sm btn-primary export-btn" data-id="${esc(q.questionnaireID)}" data-name="${esc(q.name)}" data-version="${esc(q.version)}" data-all="0">
|
<button class="btn btn-sm btn-primary export-btn" data-id="${esc(q.questionnaireID)}" data-name="${esc(q.name)}" data-version="${esc(q.version)}" data-all="0">
|
||||||
Export current
|
Export current
|
||||||
|
|||||||
108
website/js/pages/schedule.js
Normal file
108
website/js/pages/schedule.js
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { apiGet } from '../api.js';
|
||||||
|
import { pageHeaderHTML, showToast } from '../app.js';
|
||||||
|
|
||||||
|
export async function schedulePage() {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
app.innerHTML = `
|
||||||
|
${pageHeaderHTML('Schedule', '', {
|
||||||
|
subtitle: 'Upcoming session appointments set by counselors in the app',
|
||||||
|
})}
|
||||||
|
<div id="scheduleContent"><div class="spinner"></div></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiGet('schedule');
|
||||||
|
renderSchedule(data.entries || [], data.byDate || {});
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
document.getElementById('scheduleContent').innerHTML =
|
||||||
|
`<p class="error-text">${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSchedule(entries, byDate) {
|
||||||
|
const el = document.getElementById('scheduleContent');
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
const futureEntries = entries.filter(e => parseDdMmYyyy(e.scheduledDate) > todayStart.getTime());
|
||||||
|
const futureByDate = {};
|
||||||
|
futureEntries.forEach(e => {
|
||||||
|
(futureByDate[e.scheduledDate] ||= []).push(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!futureEntries.length) {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<p class="scoring-empty-hint" style="margin:0">No upcoming session appointments.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates = Object.keys(futureByDate).sort((a, b) => parseDdMmYyyy(a) - parseDdMmYyyy(b));
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card" style="margin-bottom:16px">
|
||||||
|
<p class="data-toolbar-hint" style="margin:0">
|
||||||
|
${futureEntries.length} scheduled client${futureEntries.length === 1 ? '' : 's'} across ${dates.length} date${dates.length === 1 ? '' : 's'}.
|
||||||
|
Counselors set dates in the mobile app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
${dates.map(date => dateGroupHTML(date, futureByDate[date] || [])).join('')}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateGroupHTML(scheduledDate, rows) {
|
||||||
|
const sorted = [...rows].sort((a, b) =>
|
||||||
|
String(a.coachUsername || '').localeCompare(String(b.coachUsername || ''))
|
||||||
|
|| String(a.clientCode).localeCompare(String(b.clientCode)),
|
||||||
|
);
|
||||||
|
return `
|
||||||
|
<div class="card scoring-profiles-card" style="margin-bottom:16px">
|
||||||
|
<div class="scoring-profile-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="scoring-section-title">${esc(scheduledDate)}</h3>
|
||||||
|
<p class="scoring-section-desc">
|
||||||
|
${sorted.length} client${sorted.length === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Cycle</th>
|
||||||
|
<th>Session</th>
|
||||||
|
<th>Counselor</th>
|
||||||
|
<th>Supervisor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${sorted.map(row => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(row.clientCode)}</td>
|
||||||
|
<td>${esc(String(row.programCycleNumber))}</td>
|
||||||
|
<td>${esc(String(row.programSessionNumber))}</td>
|
||||||
|
<td>${esc(row.coachUsername || row.coachID || '—')}</td>
|
||||||
|
<td>${esc(row.supervisorUsername || '—')}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDdMmYyyy(date) {
|
||||||
|
const m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(String(date));
|
||||||
|
if (!m) return 0;
|
||||||
|
return new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user