made client data browsable with dropdown and version diffs
This commit is contained in:
143
common.php
143
common.php
@ -1105,6 +1105,149 @@ function qdb_option_german_label(PDO $pdo, string $answerOptionID, string $store
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** German label for a string catalog key (symptoms, glass levels, etc.). */
|
||||||
|
function qdb_string_german_label(PDO $pdo, string $stringKey, array &$cache = []): string {
|
||||||
|
$stringKey = trim($stringKey);
|
||||||
|
if ($stringKey === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (isset($cache[$stringKey])) {
|
||||||
|
return $cache[$stringKey];
|
||||||
|
}
|
||||||
|
$tr = qdb_fetch_translation_map($pdo, 'string', $stringKey);
|
||||||
|
$de = trim($tr[QDB_SOURCE_LANGUAGE] ?? '');
|
||||||
|
$label = $de !== '' ? $de : $stringKey;
|
||||||
|
$cache[$stringKey] = $label;
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** German question text for UI display. */
|
||||||
|
function qdb_question_german_label(PDO $pdo, array $questionRow): string {
|
||||||
|
$tr = qdb_fetch_translation_map($pdo, 'question', $questionRow['questionID']);
|
||||||
|
$de = trim($tr[QDB_SOURCE_LANGUAGE] ?? '');
|
||||||
|
if ($de !== '') {
|
||||||
|
return $de;
|
||||||
|
}
|
||||||
|
$defaultText = trim($questionRow['defaultText'] ?? '');
|
||||||
|
if ($defaultText !== '' && !qdb_is_stable_key($defaultText)) {
|
||||||
|
return $defaultText;
|
||||||
|
}
|
||||||
|
return qdb_question_local_id($questionRow['questionID']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result columns + option map using German labels (client detail, not CSV export).
|
||||||
|
*
|
||||||
|
* @return array{questions: array, resultColumns: array, optionTextMap: array, stringLabelCache: array}
|
||||||
|
*/
|
||||||
|
function qdb_display_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);
|
||||||
|
$stringLabelCache = [];
|
||||||
|
$resultColumns = [];
|
||||||
|
|
||||||
|
foreach ($questions as $q) {
|
||||||
|
$cfg = json_decode($q['configJson'] ?? '{}', true) ?: [];
|
||||||
|
$type = $q['type'] ?? '';
|
||||||
|
if ($type === 'glass_scale_question') {
|
||||||
|
$symptoms = $cfg['symptoms'] ?? [];
|
||||||
|
if (is_array($symptoms) && $symptoms !== []) {
|
||||||
|
foreach ($symptoms as $symptomKey) {
|
||||||
|
$sk = trim((string)$symptomKey);
|
||||||
|
if ($sk === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$resultColumns[] = [
|
||||||
|
'header' => qdb_string_german_label($pdo, $sk, $stringLabelCache),
|
||||||
|
'questionID' => $q['questionID'],
|
||||||
|
'symptomKey' => $sk,
|
||||||
|
'kind' => 'glass_symptom',
|
||||||
|
'question' => $q,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$resultColumns[] = [
|
||||||
|
'header' => qdb_question_german_label($pdo, $q),
|
||||||
|
'questionID' => $q['questionID'],
|
||||||
|
'symptomKey' => null,
|
||||||
|
'kind' => 'question',
|
||||||
|
'question' => $q,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$optionTextMap = [];
|
||||||
|
foreach ($questions as $q) {
|
||||||
|
$aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
|
||||||
|
$aoStmt->execute([':qid' => $q['questionID']]);
|
||||||
|
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||||
|
$optionTextMap[$ao['answerOptionID']] = qdb_option_german_label(
|
||||||
|
$pdo,
|
||||||
|
$ao['answerOptionID'],
|
||||||
|
$ao['defaultText']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'questions' => $questions,
|
||||||
|
'resultColumns' => $resultColumns,
|
||||||
|
'optionTextMap' => $optionTextMap,
|
||||||
|
'stringLabelCache' => $stringLabelCache,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format one answer cell for UI (German labels). */
|
||||||
|
function qdb_display_column_cell_value(
|
||||||
|
PDO $pdo,
|
||||||
|
array $column,
|
||||||
|
?array $answerRow,
|
||||||
|
array $optionTextMap,
|
||||||
|
array &$stringLabelCache
|
||||||
|
): string {
|
||||||
|
if (($column['kind'] ?? '') === 'glass_symptom') {
|
||||||
|
$raw = qdb_glass_symptom_answer_value(
|
||||||
|
$answerRow['freeTextValue'] ?? null,
|
||||||
|
(string)($column['symptomKey'] ?? '')
|
||||||
|
);
|
||||||
|
if ($raw === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return qdb_string_german_label($pdo, $raw, $stringLabelCache);
|
||||||
|
}
|
||||||
|
if (($column['question']['type'] ?? '') === 'glass_scale_question') {
|
||||||
|
return qdb_format_glass_answer_json_display($pdo, $answerRow['freeTextValue'] ?? null, $stringLabelCache);
|
||||||
|
}
|
||||||
|
return qdb_format_client_answer_display($column['question'], $answerRow, $optionTextMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Glass-scale JSON with German symptom keys and level labels. */
|
||||||
|
function qdb_format_glass_answer_json_display(PDO $pdo, ?string $raw, array &$stringLabelCache): string
|
||||||
|
{
|
||||||
|
if ($raw === null || trim($raw) === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
$parts = [];
|
||||||
|
foreach ($decoded as $key => $val) {
|
||||||
|
$val = trim((string)$val);
|
||||||
|
if ($val === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$symLabel = qdb_string_german_label($pdo, (string)$key, $stringLabelCache);
|
||||||
|
$levelLabel = qdb_string_german_label($pdo, $val, $stringLabelCache);
|
||||||
|
$parts[] = $symLabel . ': ' . $levelLabel;
|
||||||
|
}
|
||||||
|
return implode('; ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build translation entry lists for one questionnaire.
|
* Build translation entry lists for one questionnaire.
|
||||||
* Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved).
|
* Returns ['stringEntries' => [...], 'contentEntries' => [...]] (questions + options interleaved).
|
||||||
|
|||||||
11
db_init.php
11
db_init.php
@ -6,7 +6,7 @@ require_once __DIR__ . '/common.php';
|
|||||||
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
|
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
|
||||||
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
|
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
|
||||||
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
||||||
define('QDB_VERSION', 5);
|
define('QDB_VERSION', 6);
|
||||||
|
|
||||||
function qdb_table_exists(PDO $pdo, string $table): bool {
|
function qdb_table_exists(PDO $pdo, string $table): bool {
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
@ -101,6 +101,15 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
||||||
|
if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
|
$pdo->exec(
|
||||||
|
'UPDATE questionnaire_submission SET submittedAt = completedAt
|
||||||
|
WHERE completedAt IS NOT NULL AND submittedAt != completedAt'
|
||||||
|
);
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
||||||
if ($currentVersion < QDB_VERSION) {
|
if ($currentVersion < QDB_VERSION) {
|
||||||
$pdo->exec('PRAGMA foreign_keys = OFF;');
|
$pdo->exec('PRAGMA foreign_keys = OFF;');
|
||||||
|
|||||||
@ -11,13 +11,26 @@ case 'GET':
|
|||||||
try {
|
try {
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
|
||||||
|
$clientCode = trim((string)($_GET['clientCode'] ?? ''));
|
||||||
|
if ($clientCode !== '' && !empty($_GET['detail'])) {
|
||||||
|
require_once __DIR__ . '/../lib/submissions.php';
|
||||||
|
$detail = qdb_client_detail($pdo, $tokenRec, $clientCode);
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_success($detail);
|
||||||
|
}
|
||||||
|
|
||||||
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
|
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
|
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername,
|
||||||
|
CASE WHEN EXISTS (
|
||||||
|
SELECT 1 FROM completed_questionnaire cq WHERE cq.clientCode = cl.clientCode
|
||||||
|
) OR EXISTS (
|
||||||
|
SELECT 1 FROM questionnaire_submission qs WHERE qs.clientCode = cl.clientCode
|
||||||
|
) THEN 1 ELSE 0 END AS hasResponseData
|
||||||
FROM client cl
|
FROM client cl
|
||||||
LEFT JOIN coach co ON co.coachID = cl.coachID
|
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||||
WHERE $clause
|
WHERE $clause
|
||||||
ORDER BY cl.clientCode"
|
ORDER BY hasResponseData DESC, cl.clientCode ASC"
|
||||||
);
|
);
|
||||||
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
|
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
@ -98,12 +111,11 @@ case 'DELETE':
|
|||||||
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/submissions.php';
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
$pdo->prepare("DELETE FROM client_answer WHERE clientCode = :cc")
|
qdb_delete_client_response_data($pdo, [$clientCode]);
|
||||||
->execute([':cc' => $clientCode]);
|
$pdo->prepare('DELETE FROM client WHERE clientCode = :cc')
|
||||||
$pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode = :cc")
|
|
||||||
->execute([':cc' => $clientCode]);
|
|
||||||
$pdo->prepare("DELETE FROM client WHERE clientCode = :cc")
|
|
||||||
->execute([':cc' => $clientCode]);
|
->execute([':cc' => $clientCode]);
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
|
|
||||||
|
|||||||
@ -12,27 +12,24 @@ function qdb_analytics_submissions_by_day(PDO $pdo, array $tokenRec, int $days =
|
|||||||
$days = max(1, min(90, $days));
|
$days = max(1, min(90, $days));
|
||||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
|
||||||
$today = strtotime('today');
|
$startTs = strtotime('today', time()) - ($days - 1) * 86400;
|
||||||
$startTs = $today - ($days - 1) * 86400;
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
"SELECT strftime('%Y-%m-%d', qs.submittedAt, 'unixepoch') AS d, COUNT(*) AS cnt
|
"SELECT qs.submittedAt
|
||||||
FROM questionnaire_submission qs
|
FROM questionnaire_submission qs
|
||||||
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
||||||
WHERE $rbacClause AND qs.submittedAt >= :start
|
WHERE $rbacClause AND qs.submittedAt >= :start"
|
||||||
GROUP BY d
|
|
||||||
ORDER BY d"
|
|
||||||
);
|
);
|
||||||
$stmt->execute(array_merge($rbacParams, [':start' => $startTs]));
|
$stmt->execute(array_merge($rbacParams, [':start' => $startTs]));
|
||||||
$byDate = [];
|
$byDate = [];
|
||||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $submittedAt) {
|
||||||
$byDate[$row['d']] = (int)$row['cnt'];
|
$key = date('Y-m-d', (int)$submittedAt);
|
||||||
|
$byDate[$key] = ($byDate[$key] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$out = [];
|
$out = [];
|
||||||
for ($i = 0; $i < $days; $i++) {
|
for ($i = 0; $i < $days; $i++) {
|
||||||
$ts = $startTs + $i * 86400;
|
$key = date('Y-m-d', $startTs + $i * 86400);
|
||||||
$key = date('Y-m-d', $ts);
|
|
||||||
$out[] = ['date' => $key, 'count' => $byDate[$key] ?? 0];
|
$out[] = ['date' => $key, 'count' => $byDate[$key] ?? 0];
|
||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
|
|||||||
@ -15,6 +15,7 @@ const QDB_DEV_GLASS_POINTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
require_once __DIR__ . '/app_answers.php';
|
require_once __DIR__ . '/app_answers.php';
|
||||||
|
require_once __DIR__ . '/submissions.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{imported: array, skipped: array, errors: string[]}
|
* @return array{imported: array, skipped: array, errors: string[]}
|
||||||
@ -33,6 +34,7 @@ function qdb_import_dev_fixture(PDO $pdo, array $fixture): array
|
|||||||
'coaches' => 0,
|
'coaches' => 0,
|
||||||
'clients' => 0,
|
'clients' => 0,
|
||||||
'completions' => 0,
|
'completions' => 0,
|
||||||
|
'submissions' => 0,
|
||||||
'answers' => 0,
|
'answers' => 0,
|
||||||
];
|
];
|
||||||
$skipped = [
|
$skipped = [
|
||||||
@ -165,21 +167,57 @@ function qdb_import_dev_fixture(PDO $pdo, array $fixture): array
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$completedAt = isset($row['completedAt']) ? (int)$row['completedAt'] : ($now - ($variant % 90) * 86400);
|
$coachUserStmt = $pdo->prepare(
|
||||||
$startedAt = isset($row['startedAt']) ? (int)$row['startedAt'] : ($completedAt - 600);
|
"SELECT u.userID FROM users u
|
||||||
|
WHERE u.role = 'coach' AND u.entityID = :cid LIMIT 1"
|
||||||
$answers = qdb_dev_build_answers_for_questionnaire($pdo, $qnID, $variant);
|
|
||||||
$answerCount = qdb_dev_persist_submission(
|
|
||||||
$pdo,
|
|
||||||
$qnID,
|
|
||||||
$clientCode,
|
|
||||||
(string)$coachID,
|
|
||||||
$answers,
|
|
||||||
$startedAt,
|
|
||||||
$completedAt
|
|
||||||
);
|
);
|
||||||
|
$coachUserStmt->execute([':cid' => $coachID]);
|
||||||
|
$coachUserID = (string)($coachUserStmt->fetchColumn() ?: '');
|
||||||
|
$tokenRec = ['userID' => $coachUserID, 'role' => 'coach'];
|
||||||
|
|
||||||
|
$uploads = qdb_dev_normalize_uploads($row, $variant, $now);
|
||||||
|
$archiveSubmissions = qdb_table_exists($pdo, 'questionnaire_submission');
|
||||||
|
|
||||||
|
foreach ($uploads as $uploadIndex => $upload) {
|
||||||
|
$variantSeed = (int)($upload['variant'] ?? ($variant + $uploadIndex));
|
||||||
|
$completedAt = (int)($upload['submittedAt'] ?? $upload['completedAt'] ?? $now);
|
||||||
|
$startedAt = (int)($upload['startedAt'] ?? ($completedAt - 600));
|
||||||
|
|
||||||
|
$answers = qdb_dev_build_answers_for_questionnaire($pdo, $qnID, $variantSeed);
|
||||||
|
$answerCount = qdb_dev_persist_submission(
|
||||||
|
$pdo,
|
||||||
|
$qnID,
|
||||||
|
$clientCode,
|
||||||
|
(string)$coachID,
|
||||||
|
$answers,
|
||||||
|
$startedAt,
|
||||||
|
$completedAt
|
||||||
|
);
|
||||||
|
$imported['answers'] += $answerCount;
|
||||||
|
|
||||||
|
if ($archiveSubmissions) {
|
||||||
|
$spStmt = $pdo->prepare(
|
||||||
|
'SELECT sumPoints FROM completed_questionnaire
|
||||||
|
WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||||
|
);
|
||||||
|
$spStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||||
|
$sumPoints = (int)($spStmt->fetchColumn() ?: 0);
|
||||||
|
qdb_record_submission_after_submit(
|
||||||
|
$pdo,
|
||||||
|
$clientCode,
|
||||||
|
$qnID,
|
||||||
|
$tokenRec,
|
||||||
|
$startedAt,
|
||||||
|
$completedAt,
|
||||||
|
$sumPoints,
|
||||||
|
(string)$coachID,
|
||||||
|
$completedAt
|
||||||
|
);
|
||||||
|
$imported['submissions']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$imported['completions']++;
|
$imported['completions']++;
|
||||||
$imported['answers'] += $answerCount;
|
|
||||||
$variant++;
|
$variant++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,12 +238,56 @@ function qdb_dev_validate_fixture(array $fixture): string
|
|||||||
if ($prefix === '' || !preg_match('/^dev[a-z0-9_]*$/i', $prefix)) {
|
if ($prefix === '' || !preg_match('/^dev[a-z0-9_]*$/i', $prefix)) {
|
||||||
json_error('INVALID_FIELD', 'fixture.prefix must start with "dev" (e.g. dev_)', 400);
|
json_error('INVALID_FIELD', 'fixture.prefix must start with "dev" (e.g. dev_)', 400);
|
||||||
}
|
}
|
||||||
if ((int)($fixture['fixtureVersion'] ?? 0) !== 1) {
|
$fixtureVersion = (int)($fixture['fixtureVersion'] ?? 0);
|
||||||
json_error('INVALID_FIELD', 'fixtureVersion must be 1', 400);
|
if ($fixtureVersion !== 1 && $fixtureVersion !== 2) {
|
||||||
|
json_error('INVALID_FIELD', 'fixtureVersion must be 1 or 2', 400);
|
||||||
}
|
}
|
||||||
return $prefix;
|
return $prefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{submittedAt: int, startedAt: int, variant: int}>
|
||||||
|
*/
|
||||||
|
function qdb_dev_normalize_uploads(array $row, int $fallbackVariant, int $now): array
|
||||||
|
{
|
||||||
|
if (!empty($row['uploads']) && is_array($row['uploads'])) {
|
||||||
|
$out = [];
|
||||||
|
foreach ($row['uploads'] as $i => $upload) {
|
||||||
|
if (!is_array($upload)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$submittedAt = (int)($upload['submittedAt'] ?? $upload['completedAt'] ?? $now);
|
||||||
|
$startedAt = (int)($upload['startedAt'] ?? ($submittedAt - 600));
|
||||||
|
$out[] = [
|
||||||
|
'submittedAt' => $submittedAt,
|
||||||
|
'startedAt' => $startedAt,
|
||||||
|
'variant' => (int)($upload['variant'] ?? ($fallbackVariant + $i)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($out !== []) {
|
||||||
|
usort($out, static fn($a, $b) => $a['submittedAt'] <=> $b['submittedAt']);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionCount = max(1, (int)($row['versions'] ?? $row['uploadCount'] ?? 1));
|
||||||
|
$finalCompleted = isset($row['completedAt']) ? (int)$row['completedAt'] : ($now - ($fallbackVariant % 90) * 86400);
|
||||||
|
$uploads = [];
|
||||||
|
for ($v = 0; $v < $versionCount; $v++) {
|
||||||
|
$gapDays = ($versionCount - 1 - $v) * 14 + ($fallbackVariant % 5);
|
||||||
|
$completedAt = $finalCompleted - ($gapDays * 86400);
|
||||||
|
$uploads[] = [
|
||||||
|
'submittedAt' => $completedAt,
|
||||||
|
'startedAt' => isset($row['startedAt']) && $v === $versionCount - 1
|
||||||
|
? (int)$row['startedAt']
|
||||||
|
: ($completedAt - 600 - ($v * 120)),
|
||||||
|
'variant' => $fallbackVariant + $v,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($uploads, static fn($a, $b) => $a['submittedAt'] <=> $b['submittedAt']);
|
||||||
|
return $uploads;
|
||||||
|
}
|
||||||
|
|
||||||
function qdb_dev_has_prefix(string $value, string $prefix): bool
|
function qdb_dev_has_prefix(string $value, string $prefix): bool
|
||||||
{
|
{
|
||||||
return str_starts_with(strtolower($value), strtolower($prefix))
|
return str_starts_with(strtolower($value), strtolower($prefix))
|
||||||
@ -603,6 +685,8 @@ function qdb_remove_dev_test_data(PDO $pdo, array $options = []): array
|
|||||||
$clientLike = $clientPrefix . '%';
|
$clientLike = $clientPrefix . '%';
|
||||||
|
|
||||||
$deleted = [
|
$deleted = [
|
||||||
|
'submissions' => 0,
|
||||||
|
'followup_notes' => 0,
|
||||||
'client_answers' => 0,
|
'client_answers' => 0,
|
||||||
'completed_questionnaires' => 0,
|
'completed_questionnaires' => 0,
|
||||||
'clients' => 0,
|
'clients' => 0,
|
||||||
@ -673,17 +757,15 @@ function qdb_remove_dev_test_data(PDO $pdo, array $options = []): array
|
|||||||
|
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
try {
|
try {
|
||||||
$deleted['client_answers'] = qdb_dev_delete_by_ids($pdo, 'client_answer', 'clientCode', $clientCodeList);
|
|
||||||
|
|
||||||
if ($clientCodeList !== []) {
|
if ($clientCodeList !== []) {
|
||||||
foreach (array_chunk($clientCodeList, 200) as $chunk) {
|
$responseDeleted = qdb_delete_client_response_data($pdo, $clientCodeList);
|
||||||
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
$deleted['submissions'] += $responseDeleted['submissions'];
|
||||||
$delCq = $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode IN ($ph)");
|
$deleted['followup_notes'] += $responseDeleted['followup_notes'];
|
||||||
$delCq->execute($chunk);
|
$deleted['client_answers'] += $responseDeleted['client_answers'];
|
||||||
$deleted['completed_questionnaires'] += $delCq->rowCount();
|
$deleted['completed_questionnaires'] += $responseDeleted['completed_questionnaires'];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if ($coachIdList !== []) {
|
if ($coachIdList !== []) {
|
||||||
|
$deleted['submissions'] += qdb_delete_submissions_for_coaches($pdo, $coachIdList);
|
||||||
$deleted['completed_questionnaires'] += qdb_dev_delete_by_ids(
|
$deleted['completed_questionnaires'] += qdb_dev_delete_by_ids(
|
||||||
$pdo,
|
$pdo,
|
||||||
'completed_questionnaire',
|
'completed_questionnaire',
|
||||||
|
|||||||
@ -23,11 +23,12 @@ function qdb_record_submission_after_submit(
|
|||||||
?int $startedAt,
|
?int $startedAt,
|
||||||
?int $completedAt,
|
?int $completedAt,
|
||||||
int $sumPoints,
|
int $sumPoints,
|
||||||
string $assignedByCoach
|
string $assignedByCoach,
|
||||||
|
?int $submittedAt = null
|
||||||
): string {
|
): string {
|
||||||
$version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID);
|
$version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID);
|
||||||
$submissionID = bin2hex(random_bytes(16));
|
$submissionID = bin2hex(random_bytes(16));
|
||||||
$now = time();
|
$submittedAtValue = $submittedAt ?? $completedAt ?? time();
|
||||||
|
|
||||||
$cq = $pdo->prepare(
|
$cq = $pdo->prepare(
|
||||||
'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
|
'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||||
@ -50,7 +51,7 @@ function qdb_record_submission_after_submit(
|
|||||||
':cc' => $clientCode,
|
':cc' => $clientCode,
|
||||||
':qn' => $questionnaireID,
|
':qn' => $questionnaireID,
|
||||||
':ver' => $version,
|
':ver' => $version,
|
||||||
':sat' => $now,
|
':sat' => $submittedAtValue,
|
||||||
':uid' => $tokenRec['userID'] ?? '',
|
':uid' => $tokenRec['userID'] ?? '',
|
||||||
':role' => $tokenRec['role'] ?? '',
|
':role' => $tokenRec['role'] ?? '',
|
||||||
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
|
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
|
||||||
@ -451,3 +452,285 @@ function qdb_build_server_export_zip(PDO $pdo, array $tokenRec, bool $allVersion
|
|||||||
|
|
||||||
return $tmpZip;
|
return $tmpZip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete uploads, answers, and completions for client codes (does not delete client rows).
|
||||||
|
*
|
||||||
|
* @param list<string> $clientCodes
|
||||||
|
* @return array{submissions: int, followup_notes: int, client_answers: int, completed_questionnaires: int}
|
||||||
|
*/
|
||||||
|
function qdb_delete_client_response_data(PDO $pdo, array $clientCodes): array {
|
||||||
|
$clientCodes = array_values(array_unique(array_filter(
|
||||||
|
$clientCodes,
|
||||||
|
static fn($c) => is_string($c) && $c !== ''
|
||||||
|
)));
|
||||||
|
$deleted = [
|
||||||
|
'submissions' => 0,
|
||||||
|
'followup_notes' => 0,
|
||||||
|
'client_answers' => 0,
|
||||||
|
'completed_questionnaires' => 0,
|
||||||
|
];
|
||||||
|
if ($clientCodes === []) {
|
||||||
|
return $deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_chunk($clientCodes, 200) as $chunk) {
|
||||||
|
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||||
|
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM questionnaire_submission WHERE clientCode IN ($ph)");
|
||||||
|
$stmt->execute($chunk);
|
||||||
|
$deleted['submissions'] += $stmt->rowCount();
|
||||||
|
}
|
||||||
|
if (qdb_table_exists($pdo, 'client_followup_note')) {
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM client_followup_note WHERE clientCode IN ($ph)");
|
||||||
|
$stmt->execute($chunk);
|
||||||
|
$deleted['followup_notes'] += $stmt->rowCount();
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM client_answer WHERE clientCode IN ($ph)");
|
||||||
|
$stmt->execute($chunk);
|
||||||
|
$deleted['client_answers'] += $stmt->rowCount();
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode IN ($ph)");
|
||||||
|
$stmt->execute($chunk);
|
||||||
|
$deleted['completed_questionnaires'] += $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove submission rows assigned to coaches (FK before coach delete).
|
||||||
|
*
|
||||||
|
* @param list<string> $coachIDs
|
||||||
|
*/
|
||||||
|
function qdb_delete_submissions_for_coaches(PDO $pdo, array $coachIDs): int {
|
||||||
|
if (!qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$coachIDs = array_values(array_unique(array_filter(
|
||||||
|
$coachIDs,
|
||||||
|
static fn($id) => is_string($id) && $id !== ''
|
||||||
|
)));
|
||||||
|
if ($coachIDs === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$total = 0;
|
||||||
|
foreach (array_chunk($coachIDs, 200) as $chunk) {
|
||||||
|
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM questionnaire_submission WHERE assignedByCoach IN ($ph)");
|
||||||
|
$stmt->execute($chunk);
|
||||||
|
$total += $stmt->rowCount();
|
||||||
|
}
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $answerMap questionID => answer row
|
||||||
|
* @param array<string, array<string, mixed>>|null $compareMap live answers to diff against
|
||||||
|
* @return list<array{label: string, value: string, changedFromLive: bool}>
|
||||||
|
*/
|
||||||
|
function qdb_answers_display_rows(
|
||||||
|
PDO $pdo,
|
||||||
|
array $resultColumns,
|
||||||
|
array $answerMap,
|
||||||
|
array $optionTextMap,
|
||||||
|
array &$stringLabelCache,
|
||||||
|
?array $compareMap = null
|
||||||
|
): array {
|
||||||
|
$rows = [];
|
||||||
|
foreach ($resultColumns as $col) {
|
||||||
|
$qid = $col['questionID'];
|
||||||
|
$a = $answerMap[$qid] ?? null;
|
||||||
|
$value = qdb_display_column_cell_value($pdo, $col, $a, $optionTextMap, $stringLabelCache);
|
||||||
|
$changedFromLive = false;
|
||||||
|
if ($compareMap !== null) {
|
||||||
|
$liveValue = qdb_display_column_cell_value(
|
||||||
|
$pdo,
|
||||||
|
$col,
|
||||||
|
$compareMap[$qid] ?? null,
|
||||||
|
$optionTextMap,
|
||||||
|
$stringLabelCache
|
||||||
|
);
|
||||||
|
$changedFromLive = $value !== $liveValue;
|
||||||
|
}
|
||||||
|
$rows[] = [
|
||||||
|
'label' => $col['header'],
|
||||||
|
'value' => $value,
|
||||||
|
'changedFromLive' => $changedFromLive,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $questionIDs
|
||||||
|
* @return array<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
function qdb_load_client_answer_map(
|
||||||
|
PDO $pdo,
|
||||||
|
string $clientCode,
|
||||||
|
array $questionIDs,
|
||||||
|
string $table = 'client_answer',
|
||||||
|
?string $submissionID = null
|
||||||
|
): array {
|
||||||
|
if ($questionIDs === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$allowed = ['client_answer', 'client_answer_submission'];
|
||||||
|
if (!in_array($table, $allowed, true)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ph = implode(',', array_fill(0, count($questionIDs), '?'));
|
||||||
|
if ($table === 'client_answer_submission') {
|
||||||
|
if ($submissionID === null || $submissionID === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$sql = "SELECT questionID, answerOptionID, freeTextValue, numericValue
|
||||||
|
FROM client_answer_submission
|
||||||
|
WHERE submissionID = ? AND questionID IN ($ph)";
|
||||||
|
$params = array_merge([$submissionID], $questionIDs);
|
||||||
|
} else {
|
||||||
|
$sql = "SELECT questionID, answerOptionID, freeTextValue, numericValue
|
||||||
|
FROM client_answer
|
||||||
|
WHERE clientCode = ? AND questionID IN ($ph)";
|
||||||
|
$params = array_merge([$clientCode], $questionIDs);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$map = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$map[$row['questionID']] = $row;
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full client response detail for the web clients list (RBAC-scoped).
|
||||||
|
*/
|
||||||
|
function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array {
|
||||||
|
$clientCode = trim($clientCode);
|
||||||
|
if ($clientCode === '') {
|
||||||
|
json_error('INVALID_FIELD', 'clientCode is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT cl.clientCode, cl.coachID,
|
||||||
|
co.username AS coachUsername,
|
||||||
|
sv.username AS supervisorUsername
|
||||||
|
FROM client cl
|
||||||
|
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||||
|
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
|
||||||
|
WHERE cl.clientCode = :cc AND $rbacClause"
|
||||||
|
);
|
||||||
|
$stmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
|
||||||
|
$client = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$client) {
|
||||||
|
json_error('NOT_FOUND', 'Client not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$qnStmt = $pdo->prepare(
|
||||||
|
"SELECT q.questionnaireID, q.name, q.version AS questionnaireVersion, q.orderIndex
|
||||||
|
FROM questionnaire q
|
||||||
|
WHERE q.questionnaireID IN (
|
||||||
|
SELECT questionnaireID FROM completed_questionnaire WHERE clientCode = :cc
|
||||||
|
UNION
|
||||||
|
SELECT questionnaireID FROM questionnaire_submission WHERE clientCode = :cc2
|
||||||
|
)
|
||||||
|
ORDER BY q.orderIndex, q.name"
|
||||||
|
);
|
||||||
|
$qnStmt->execute([':cc' => $clientCode, ':cc2' => $clientCode]);
|
||||||
|
$qnRows = $qnStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$questionnaires = [];
|
||||||
|
foreach ($qnRows as $qn) {
|
||||||
|
$qnID = $qn['questionnaireID'];
|
||||||
|
$ctx = qdb_display_questionnaire_context($pdo, $qnID);
|
||||||
|
$questionIDs = array_column($ctx['questions'], 'questionID');
|
||||||
|
$resultColumns = $ctx['resultColumns'];
|
||||||
|
$optionTextMap = $ctx['optionTextMap'];
|
||||||
|
$stringLabelCache = $ctx['stringLabelCache'];
|
||||||
|
|
||||||
|
$cqStmt = $pdo->prepare(
|
||||||
|
'SELECT status, sumPoints, startedAt, completedAt
|
||||||
|
FROM completed_questionnaire
|
||||||
|
WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||||
|
);
|
||||||
|
$cqStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||||
|
$cq = $cqStmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
|
||||||
|
$liveMap = qdb_load_client_answer_map($pdo, $clientCode, $questionIDs);
|
||||||
|
$currentAnswers = qdb_answers_display_rows(
|
||||||
|
$pdo,
|
||||||
|
$resultColumns,
|
||||||
|
$liveMap,
|
||||||
|
$optionTextMap,
|
||||||
|
$stringLabelCache,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
$subStmt = $pdo->prepare(
|
||||||
|
'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt
|
||||||
|
FROM questionnaire_submission
|
||||||
|
WHERE clientCode = :cc AND questionnaireID = :qn
|
||||||
|
ORDER BY version DESC'
|
||||||
|
);
|
||||||
|
$subStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||||
|
$submissions = [];
|
||||||
|
foreach ($subStmt->fetchAll(PDO::FETCH_ASSOC) as $s) {
|
||||||
|
$subMap = qdb_load_client_answer_map(
|
||||||
|
$pdo,
|
||||||
|
$clientCode,
|
||||||
|
$questionIDs,
|
||||||
|
'client_answer_submission',
|
||||||
|
$s['submissionID']
|
||||||
|
);
|
||||||
|
$versionAnswers = qdb_answers_display_rows(
|
||||||
|
$pdo,
|
||||||
|
$resultColumns,
|
||||||
|
$subMap,
|
||||||
|
$optionTextMap,
|
||||||
|
$stringLabelCache,
|
||||||
|
$liveMap
|
||||||
|
);
|
||||||
|
$changedCount = count(array_filter(
|
||||||
|
$versionAnswers,
|
||||||
|
static fn($r) => !empty($r['changedFromLive'])
|
||||||
|
));
|
||||||
|
$submissions[] = [
|
||||||
|
'submissionID' => $s['submissionID'],
|
||||||
|
'version' => (int)$s['version'],
|
||||||
|
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
|
||||||
|
'status' => $s['status'] ?? '',
|
||||||
|
'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null,
|
||||||
|
'completedAt' => $s['completedAt'] ? date('Y-m-d H:i', (int)$s['completedAt']) : '',
|
||||||
|
'changedCount' => $changedCount,
|
||||||
|
'answers' => $versionAnswers,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$questionnaires[] = [
|
||||||
|
'questionnaireID' => $qnID,
|
||||||
|
'name' => $qn['name'],
|
||||||
|
'questionnaireVersion' => $qn['questionnaireVersion'] ?? '',
|
||||||
|
'status' => $cq['status'] ?? '',
|
||||||
|
'sumPoints' => isset($cq['sumPoints']) ? (int)$cq['sumPoints'] : null,
|
||||||
|
'startedAt' => !empty($cq['startedAt']) ? date('Y-m-d H:i', (int)$cq['startedAt']) : '',
|
||||||
|
'completedAt' => !empty($cq['completedAt']) ? date('Y-m-d H:i', (int)$cq['completedAt']) : '',
|
||||||
|
'submissionCount' => count($submissions),
|
||||||
|
'currentAnswers' => $currentAnswers,
|
||||||
|
'submissions' => $submissions,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'client' => [
|
||||||
|
'clientCode' => $client['clientCode'],
|
||||||
|
'coachID' => $client['coachID'] ?? '',
|
||||||
|
'coachUsername' => $client['coachUsername'] ?? '',
|
||||||
|
'supervisorUsername' => $client['supervisorUsername'] ?? '',
|
||||||
|
],
|
||||||
|
'questionnaires' => $questionnaires,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Generate dev-test-fixture.json for admin Dev tab import."""
|
"""Generate dev-test-fixture.json for admin Dev tab import."""
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PREFIX = "dev_"
|
PREFIX = "dev_"
|
||||||
@ -8,7 +10,10 @@ PASSWORD = "socialvrlab"
|
|||||||
CLIENT_PREFIX = "DEV-CL-"
|
CLIENT_PREFIX = "DEV-CL-"
|
||||||
SCALE = 5
|
SCALE = 5
|
||||||
|
|
||||||
# Newest questionnaire bundle (repo root); IDs are read from this file when present.
|
# Fixed RNG + anchor time so the file is reproducible but not grid-like.
|
||||||
|
RNG = random.Random(42)
|
||||||
|
ANCHOR = datetime(2026, 5, 27, 15, 30, 0)
|
||||||
|
|
||||||
BUNDLE_PATH = Path(__file__).resolve().parents[2] / "questionnaires_bundle_2026-05-28.json"
|
BUNDLE_PATH = Path(__file__).resolve().parents[2] / "questionnaires_bundle_2026-05-28.json"
|
||||||
|
|
||||||
FALLBACK_QUESTIONNAIRE_IDS = [
|
FALLBACK_QUESTIONNAIRE_IDS = [
|
||||||
@ -20,13 +25,15 @@ FALLBACK_QUESTIONNAIRE_IDS = [
|
|||||||
"questionnaire_6_follow_up_survey",
|
"questionnaire_6_follow_up_survey",
|
||||||
]
|
]
|
||||||
|
|
||||||
PER_QUESTIONNAIRE = 40 * SCALE
|
|
||||||
NUM_ADMINS = 2 * SCALE
|
NUM_ADMINS = 2 * SCALE
|
||||||
NUM_SUPERVISORS = 3 * SCALE
|
NUM_SUPERVISORS = 3 * SCALE
|
||||||
NUM_COACHES = 20 * SCALE
|
NUM_COACHES = 20 * SCALE
|
||||||
NUM_CLIENTS = 100 * SCALE
|
NUM_CLIENTS = 100 * SCALE
|
||||||
CLIENTS_WITHOUT_COMPLETIONS = 15 * SCALE
|
CLIENTS_WITHOUT_COMPLETIONS = 15 * SCALE
|
||||||
|
|
||||||
|
# Roughly how many clients (with a coach) get at least one questionnaire.
|
||||||
|
COMPLETION_PARTICIPATION = 0.82
|
||||||
|
|
||||||
|
|
||||||
def load_questionnaire_ids() -> list[str]:
|
def load_questionnaire_ids() -> list[str]:
|
||||||
if not BUNDLE_PATH.is_file():
|
if not BUNDLE_PATH.is_file():
|
||||||
@ -40,6 +47,111 @@ def load_questionnaire_ids() -> list[str]:
|
|||||||
return ids or FALLBACK_QUESTIONNAIRE_IDS
|
return ids or FALLBACK_QUESTIONNAIRE_IDS
|
||||||
|
|
||||||
|
|
||||||
|
def ts_days_ago(min_days: float, max_days: float) -> int:
|
||||||
|
days = RNG.uniform(min_days, max_days)
|
||||||
|
return int((ANCHOR - timedelta(days=days)).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def variant_seed(client_code: str, qn_id: str, version_index: int) -> int:
|
||||||
|
raw = f"{client_code}:{qn_id}:v{version_index}"
|
||||||
|
return int.from_bytes(raw.encode(), "big") % 100_000
|
||||||
|
|
||||||
|
|
||||||
|
def assign_coaches(clients: list[dict], coaches: list[dict]) -> None:
|
||||||
|
"""Uneven coach load: a few busy coaches, several light ones."""
|
||||||
|
coach_names = [c["username"] for c in coaches]
|
||||||
|
weights = []
|
||||||
|
for i, _ in enumerate(coach_names):
|
||||||
|
# Coaches 1–4 and 7, 12 get more clients; tail is lighter.
|
||||||
|
base = RNG.uniform(0.4, 1.0)
|
||||||
|
if (i % 7) in (0, 1, 2):
|
||||||
|
base *= RNG.uniform(2.0, 4.5)
|
||||||
|
if i % 11 == 0:
|
||||||
|
base *= RNG.uniform(0.15, 0.45)
|
||||||
|
weights.append(base)
|
||||||
|
|
||||||
|
for row in clients:
|
||||||
|
row["coach"] = RNG.choices(coach_names, weights=weights, k=1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def build_completions(
|
||||||
|
questionnaire_ids: list[str],
|
||||||
|
clients_with_data: list[str],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Natural-ish completion patterns:
|
||||||
|
- Sequential questionnaire funnel (not random qn per slot)
|
||||||
|
- Irregular timestamps and gaps between uploads
|
||||||
|
- ~20% of client/qn pairs have 2–4 archived versions
|
||||||
|
"""
|
||||||
|
completions: list[dict] = []
|
||||||
|
qn_count = len(questionnaire_ids)
|
||||||
|
|
||||||
|
# How many questionnaires a client completes (skewed toward mid funnel).
|
||||||
|
qn_count_weights = [8, 14, 22, 24, 18, 10, 4][:qn_count]
|
||||||
|
if len(qn_count_weights) < qn_count:
|
||||||
|
qn_count_weights += [2] * (qn_count - len(qn_count_weights))
|
||||||
|
|
||||||
|
for client_code in clients_with_data:
|
||||||
|
if RNG.random() > COMPLETION_PARTICIPATION:
|
||||||
|
continue
|
||||||
|
|
||||||
|
num_qn = RNG.choices(
|
||||||
|
list(range(1, qn_count + 1)),
|
||||||
|
weights=qn_count_weights[:qn_count],
|
||||||
|
k=1,
|
||||||
|
)[0]
|
||||||
|
selected = questionnaire_ids[:num_qn]
|
||||||
|
|
||||||
|
# First activity somewhere in the last ~6 months; some clients are very recent.
|
||||||
|
cursor = ts_days_ago(3, 185)
|
||||||
|
if RNG.random() < 0.12:
|
||||||
|
cursor = ts_days_ago(0.5, 14)
|
||||||
|
|
||||||
|
for qn_id in selected:
|
||||||
|
version_count = 1
|
||||||
|
roll = RNG.random()
|
||||||
|
if roll < 0.08 and num_qn >= 2:
|
||||||
|
version_count = 4
|
||||||
|
elif roll < 0.20:
|
||||||
|
version_count = 3
|
||||||
|
elif roll < 0.38:
|
||||||
|
version_count = 2
|
||||||
|
|
||||||
|
uploads = []
|
||||||
|
for v in range(version_count):
|
||||||
|
if v > 0:
|
||||||
|
# Re-upload after days or weeks (sometimes same day correction).
|
||||||
|
if RNG.random() < 0.18:
|
||||||
|
gap_sec = RNG.randint(2 * 3600, 36 * 3600)
|
||||||
|
else:
|
||||||
|
gap_sec = RNG.randint(2 * 86400, 55 * 86400)
|
||||||
|
cursor += gap_sec
|
||||||
|
else:
|
||||||
|
# First upload for this qn: often a few days after previous qn.
|
||||||
|
cursor += RNG.randint(0, 12 * 86400)
|
||||||
|
|
||||||
|
duration = RNG.randint(4 * 60, 55 * 60)
|
||||||
|
started = cursor - duration
|
||||||
|
completed = cursor + RNG.randint(0, 120)
|
||||||
|
|
||||||
|
uploads.append({
|
||||||
|
"submittedAt": completed,
|
||||||
|
"startedAt": started,
|
||||||
|
"variant": variant_seed(client_code, qn_id, v),
|
||||||
|
})
|
||||||
|
|
||||||
|
completions.append({
|
||||||
|
"clientCode": client_code,
|
||||||
|
"questionnaireID": qn_id,
|
||||||
|
"versions": version_count,
|
||||||
|
"uploads": uploads,
|
||||||
|
})
|
||||||
|
|
||||||
|
RNG.shuffle(completions)
|
||||||
|
return completions
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
questionnaire_ids = load_questionnaire_ids()
|
questionnaire_ids = load_questionnaire_ids()
|
||||||
|
|
||||||
@ -59,40 +171,27 @@ def main():
|
|||||||
"supervisor": f"{PREFIX}supervisor_{sv}",
|
"supervisor": f"{PREFIX}supervisor_{sv}",
|
||||||
})
|
})
|
||||||
|
|
||||||
clients = []
|
clients = [
|
||||||
for i in range(1, NUM_CLIENTS + 1):
|
{"clientCode": f"{CLIENT_PREFIX}{i:04d}"}
|
||||||
coach_idx = (i - 1) // 5 + 1
|
for i in range(1, NUM_CLIENTS + 1)
|
||||||
if coach_idx > NUM_COACHES:
|
|
||||||
coach_idx = NUM_COACHES
|
|
||||||
clients.append({
|
|
||||||
"clientCode": f"{CLIENT_PREFIX}{i:04d}",
|
|
||||||
"coach": f"{PREFIX}coach_{coach_idx:02d}",
|
|
||||||
})
|
|
||||||
|
|
||||||
clients_with_data = [
|
|
||||||
c["clientCode"]
|
|
||||||
for c in clients
|
|
||||||
if int(c["clientCode"].split("-")[-1]) <= NUM_CLIENTS - CLIENTS_WITHOUT_COMPLETIONS
|
|
||||||
]
|
]
|
||||||
|
assign_coaches(clients, coaches)
|
||||||
|
|
||||||
completions = []
|
all_codes = [c["clientCode"] for c in clients]
|
||||||
slot = 0
|
no_data_codes = set(RNG.sample(all_codes, min(CLIENTS_WITHOUT_COMPLETIONS, len(all_codes))))
|
||||||
for qn_id in questionnaire_ids:
|
clients_with_data = [c for c in all_codes if c not in no_data_codes]
|
||||||
for _ in range(PER_QUESTIONNAIRE):
|
|
||||||
client_code = clients_with_data[slot % len(clients_with_data)]
|
completions = build_completions(questionnaire_ids, clients_with_data)
|
||||||
completions.append({
|
total_uploads = sum(c["versions"] for c in completions)
|
||||||
"clientCode": client_code,
|
multi_version_pairs = sum(1 for c in completions if c["versions"] > 1)
|
||||||
"questionnaireID": qn_id,
|
|
||||||
})
|
|
||||||
slot += 1
|
|
||||||
|
|
||||||
bundle_note = BUNDLE_PATH.name if BUNDLE_PATH.is_file() else "fallback questionnaire list"
|
bundle_note = BUNDLE_PATH.name if BUNDLE_PATH.is_file() else "fallback questionnaire list"
|
||||||
|
|
||||||
fixture = {
|
fixture = {
|
||||||
"fixtureVersion": 1,
|
"fixtureVersion": 2,
|
||||||
"description": (
|
"description": (
|
||||||
f"Dev/test users, clients, and completed questionnaires ({SCALE}x scale). "
|
f"Dev/test users, clients, completions with uneven coach load and upload history "
|
||||||
f"Questionnaires aligned with {bundle_note}. Skip-on-conflict import; password socialvrlab."
|
f"({SCALE}x scale). Questionnaires from {bundle_note}. Password: socialvrlab."
|
||||||
),
|
),
|
||||||
"prefix": PREFIX,
|
"prefix": PREFIX,
|
||||||
"defaultPassword": PASSWORD,
|
"defaultPassword": PASSWORD,
|
||||||
@ -110,8 +209,9 @@ def main():
|
|||||||
"coaches": len(coaches),
|
"coaches": len(coaches),
|
||||||
"clients": len(clients),
|
"clients": len(clients),
|
||||||
"clientsWithoutCompletions": CLIENTS_WITHOUT_COMPLETIONS,
|
"clientsWithoutCompletions": CLIENTS_WITHOUT_COMPLETIONS,
|
||||||
"completions": len(completions),
|
"completionRecords": len(completions),
|
||||||
"perQuestionnaire": PER_QUESTIONNAIRE,
|
"totalUploads": total_uploads,
|
||||||
|
"multiVersionPairs": multi_version_pairs,
|
||||||
"questionnaires": len(questionnaire_ids),
|
"questionnaires": len(questionnaire_ids),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1889,6 +1889,45 @@ table.data-table tbody tr.row-orphan-category td {
|
|||||||
border-top: none;
|
border-top: none;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
.client-detail-panel {
|
||||||
|
padding: 12px 8px 8px 32px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
}
|
||||||
|
.client-detail-meta {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.client-qn-panel {
|
||||||
|
padding: 10px 8px 10px 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.client-version-panel {
|
||||||
|
padding: 8px 8px 8px 28px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-left: 3px solid var(--primary-border);
|
||||||
|
}
|
||||||
|
.client-detail-row td {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
tr.answer-changed td {
|
||||||
|
background: rgba(251, 191, 36, 0.1);
|
||||||
|
}
|
||||||
|
tr.answer-changed .answer-value-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--badge-warn-fg);
|
||||||
|
}
|
||||||
|
.client-qn-summary {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
input:disabled,
|
input:disabled,
|
||||||
select:disabled {
|
select:disabled {
|
||||||
|
|||||||
@ -9,6 +9,10 @@ let coachesList = [];
|
|||||||
let filterSearch = '';
|
let filterSearch = '';
|
||||||
let filterTestData = '';
|
let filterTestData = '';
|
||||||
let page = 0;
|
let page = 0;
|
||||||
|
let expandedClientCode = null;
|
||||||
|
let expandedQnKey = null;
|
||||||
|
let expandedVersionKey = null;
|
||||||
|
let detailByClient = {};
|
||||||
|
|
||||||
export async function clientsPage() {
|
export async function clientsPage() {
|
||||||
const app = document.getElementById('app');
|
const app = document.getElementById('app');
|
||||||
@ -77,6 +81,9 @@ function renderClients() {
|
|||||||
</select>
|
</select>
|
||||||
<span class="data-toolbar-hint" id="clientListCount"></span>
|
<span class="data-toolbar-hint" id="clientListCount"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="data-toolbar-hint" style="margin:0 0 12px">
|
||||||
|
Expand a row to view questionnaire responses and upload version history.
|
||||||
|
</p>
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
@ -95,11 +102,13 @@ function renderClients() {
|
|||||||
document.getElementById('clientListSearch').addEventListener('input', (e) => {
|
document.getElementById('clientListSearch').addEventListener('input', (e) => {
|
||||||
filterSearch = e.target.value.trim();
|
filterSearch = e.target.value.trim();
|
||||||
page = 0;
|
page = 0;
|
||||||
|
clearExpandIfHidden();
|
||||||
renderClientTableBody();
|
renderClientTableBody();
|
||||||
});
|
});
|
||||||
document.getElementById('clientTestFilter').addEventListener('change', (e) => {
|
document.getElementById('clientTestFilter').addEventListener('change', (e) => {
|
||||||
filterTestData = e.target.value;
|
filterTestData = e.target.value;
|
||||||
page = 0;
|
page = 0;
|
||||||
|
clearExpandIfHidden();
|
||||||
renderClientTableBody();
|
renderClientTableBody();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -139,6 +148,19 @@ function renderClientTableBody() {
|
|||||||
body.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match</td></tr>`;
|
body.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match</td></tr>`;
|
||||||
} else {
|
} else {
|
||||||
body.innerHTML = slice.map(c => clientRowHTML(c)).join('');
|
body.innerHTML = slice.map(c => clientRowHTML(c)).join('');
|
||||||
|
body.querySelectorAll('.client-expand-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => toggleClient(btn.dataset.code));
|
||||||
|
});
|
||||||
|
body.querySelectorAll('.client-qn-expand-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => toggleQn(btn.dataset.client, btn.dataset.qn));
|
||||||
|
});
|
||||||
|
body.querySelectorAll('.client-version-expand-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => toggleVersion(
|
||||||
|
btn.dataset.client,
|
||||||
|
btn.dataset.qn,
|
||||||
|
btn.dataset.submission
|
||||||
|
));
|
||||||
|
});
|
||||||
body.querySelectorAll('.delete-client-btn').forEach(btn => {
|
body.querySelectorAll('.delete-client-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => deleteClient(btn.dataset.code));
|
btn.addEventListener('click', () => deleteClient(btn.dataset.code));
|
||||||
});
|
});
|
||||||
@ -157,23 +179,216 @@ function renderClientTableBody() {
|
|||||||
<span>Rows ${from}–${to} of ${filtered.length}</span>
|
<span>Rows ${from}–${to} of ${filtered.length}</span>
|
||||||
<button type="button" class="btn btn-sm" id="clientsPageNext" ${page >= totalPages - 1 ? 'disabled' : ''}>Next</button>
|
<button type="button" class="btn btn-sm" id="clientsPageNext" ${page >= totalPages - 1 ? 'disabled' : ''}>Next</button>
|
||||||
`;
|
`;
|
||||||
document.getElementById('clientsPagePrev')?.addEventListener('click', () => { page--; renderClientTableBody(); });
|
document.getElementById('clientsPagePrev')?.addEventListener('click', () => {
|
||||||
document.getElementById('clientsPageNext')?.addEventListener('click', () => { page++; renderClientTableBody(); });
|
page--;
|
||||||
|
clearExpandIfHidden();
|
||||||
|
renderClientTableBody();
|
||||||
|
});
|
||||||
|
document.getElementById('clientsPageNext')?.addEventListener('click', () => {
|
||||||
|
page++;
|
||||||
|
clearExpandIfHidden();
|
||||||
|
renderClientTableBody();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function qnKey(clientCode, questionnaireID) {
|
||||||
|
return `${clientCode}|${questionnaireID}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionKey(clientCode, questionnaireID, submissionID) {
|
||||||
|
return `${clientCode}|${questionnaireID}|${submissionID}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearExpandIfHidden() {
|
||||||
|
const filtered = getFilteredClientsList();
|
||||||
|
const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||||
|
if (expandedClientCode && !slice.some(c => c.clientCode === expandedClientCode)) {
|
||||||
|
expandedClientCode = null;
|
||||||
|
expandedQnKey = null;
|
||||||
|
expandedVersionKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function answerTableHTML(rows, { highlightLiveDiff = false } = {}) {
|
||||||
|
if (!rows?.length) {
|
||||||
|
return '<p class="data-toolbar-hint">No answers recorded</p>';
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<table class="data-table data-table-compact">
|
||||||
|
<thead><tr><th>Question</th><th>Answer</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${rows.map(r => `
|
||||||
|
<tr class="${highlightLiveDiff && r.changedFromLive ? 'answer-changed' : ''}">
|
||||||
|
<td>${esc(r.label)}</td>
|
||||||
|
<td class="answer-value-cell">${esc(r.value || '—')}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientDetailPanelHTML(clientCode) {
|
||||||
|
const detail = detailByClient[clientCode];
|
||||||
|
if (!detail) {
|
||||||
|
return '<p class="data-toolbar-hint">Loading…</p>';
|
||||||
|
}
|
||||||
|
const cl = detail.client || {};
|
||||||
|
const questionnaires = detail.questionnaires || [];
|
||||||
|
if (!questionnaires.length) {
|
||||||
|
return `
|
||||||
|
<p class="client-detail-meta">
|
||||||
|
Coach: ${esc(cl.coachUsername || '—')}
|
||||||
|
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
|
||||||
|
</p>
|
||||||
|
<p class="data-toolbar-hint">No questionnaire data for this client yet.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<p class="client-detail-meta">
|
||||||
|
Coach: ${esc(cl.coachUsername || '—')}
|
||||||
|
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
|
||||||
|
</p>
|
||||||
|
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientQnBlockHTML(clientCode, q) {
|
||||||
|
const key = qnKey(clientCode, q.questionnaireID);
|
||||||
|
const qnExpanded = expandedQnKey === key;
|
||||||
|
const meta = [
|
||||||
|
q.status ? `Status: ${q.status}` : '',
|
||||||
|
q.sumPoints != null ? `Points: ${q.sumPoints}` : '',
|
||||||
|
q.completedAt ? `Completed: ${q.completedAt}` : '',
|
||||||
|
q.submissionCount ? `${q.submissionCount} upload(s)` : '',
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
|
const versionsHTML = (q.submissions || []).map(s => {
|
||||||
|
const vKey = versionKey(clientCode, q.questionnaireID, s.submissionID);
|
||||||
|
const vExpanded = expandedVersionKey === vKey;
|
||||||
|
const vMeta = [
|
||||||
|
s.submittedAt ? `Uploaded: ${s.submittedAt}` : '',
|
||||||
|
s.status ? `Status: ${s.status}` : '',
|
||||||
|
s.sumPoints != null ? `Points: ${s.sumPoints}` : '',
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
return `
|
||||||
|
<div class="client-qn-block">
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-sm btn-link client-version-expand-btn"
|
||||||
|
data-client="${esc(clientCode)}"
|
||||||
|
data-qn="${esc(q.questionnaireID)}"
|
||||||
|
data-submission="${esc(s.submissionID)}">${vExpanded ? '▼' : '▶'}</button>
|
||||||
|
<strong>Version ${s.version}</strong>
|
||||||
|
${s.changedCount > 0
|
||||||
|
? `<span class="badge badge-warn">${s.changedCount} diff vs live</span>`
|
||||||
|
: '<span class="client-qn-summary">Same as live</span>'}
|
||||||
|
${vMeta ? `<span class="client-qn-summary">${esc(vMeta)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${vExpanded ? `
|
||||||
|
<div class="client-version-panel">
|
||||||
|
${s.changedCount > 0
|
||||||
|
? '<p class="data-toolbar-hint">Highlighted rows differ from current live data.</p>'
|
||||||
|
: ''}
|
||||||
|
${answerTableHTML(s.answers, { highlightLiveDiff: true })}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="client-qn-block" style="margin-bottom:10px">
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-sm btn-link client-qn-expand-btn"
|
||||||
|
data-client="${esc(clientCode)}"
|
||||||
|
data-qn="${esc(q.questionnaireID)}">${qnExpanded ? '▼' : '▶'}</button>
|
||||||
|
<strong>${esc(q.name)}</strong>
|
||||||
|
${q.questionnaireVersion ? `<span class="badge badge-q">v${esc(q.questionnaireVersion)}</span>` : ''}
|
||||||
|
${meta ? `<span class="client-qn-summary">${esc(meta)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${qnExpanded ? `
|
||||||
|
<div class="client-qn-panel">
|
||||||
|
<h4 style="margin:0 0 8px;font-size:0.9rem">Current data (live)</h4>
|
||||||
|
${answerTableHTML(q.currentAnswers)}
|
||||||
|
${(q.submissions || []).length ? `
|
||||||
|
<h4 style="margin:16px 0 8px;font-size:0.9rem">Upload versions</h4>
|
||||||
|
${versionsHTML}
|
||||||
|
` : '<p class="data-toolbar-hint" style="margin-top:12px">No archived upload versions.</p>'}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientHasResponseData(c) {
|
||||||
|
return Number(c.hasResponseData) === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientRowHTML(c) {
|
function clientRowHTML(c) {
|
||||||
const coach = c.coachUsername
|
const coach = c.coachUsername
|
||||||
? `<strong>${esc(c.coachUsername)}</strong>`
|
? `<strong>${esc(c.coachUsername)}</strong>`
|
||||||
: `<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>`;
|
: `<span style="color:var(--text-secondary);font-style:italic">Unassigned</span>`;
|
||||||
|
const canExpand = clientHasResponseData(c);
|
||||||
|
const expanded = canExpand && expandedClientCode === c.clientCode;
|
||||||
|
const expandControl = canExpand
|
||||||
|
? `<button type="button" class="btn btn-sm btn-link client-expand-btn" data-code="${esc(c.clientCode)}">${expanded ? '▼' : '▶'}</button> `
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
|
<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
|
||||||
<td><strong>${esc(c.clientCode)}</strong></td>
|
<td>
|
||||||
|
${expandControl}<strong>${esc(c.clientCode)}</strong>
|
||||||
|
</td>
|
||||||
<td>${coach}</td>
|
<td>${coach}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
|
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>
|
||||||
|
${expanded ? `
|
||||||
|
<tr class="client-detail-row">
|
||||||
|
<td colspan="3">
|
||||||
|
<div class="client-detail-panel">${clientDetailPanelHTML(c.clientCode)}</div>
|
||||||
|
</td>
|
||||||
|
</tr>` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleClient(clientCode) {
|
||||||
|
const row = clientsList.find(c => c.clientCode === clientCode);
|
||||||
|
if (!row || !clientHasResponseData(row)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (expandedClientCode === clientCode) {
|
||||||
|
expandedClientCode = null;
|
||||||
|
expandedQnKey = null;
|
||||||
|
expandedVersionKey = null;
|
||||||
|
renderClientTableBody();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expandedClientCode = clientCode;
|
||||||
|
expandedQnKey = null;
|
||||||
|
expandedVersionKey = null;
|
||||||
|
renderClientTableBody();
|
||||||
|
if (!detailByClient[clientCode]) {
|
||||||
|
try {
|
||||||
|
const data = await apiGet(
|
||||||
|
`clients.php?clientCode=${encodeURIComponent(clientCode)}&detail=1`
|
||||||
|
);
|
||||||
|
detailByClient[clientCode] = data;
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
detailByClient[clientCode] = { client: {}, questionnaires: [] };
|
||||||
|
}
|
||||||
|
renderClientTableBody();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleQn(clientCode, questionnaireID) {
|
||||||
|
const key = qnKey(clientCode, questionnaireID);
|
||||||
|
expandedQnKey = expandedQnKey === key ? null : key;
|
||||||
|
expandedVersionKey = null;
|
||||||
|
renderClientTableBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVersion(clientCode, questionnaireID, submissionID) {
|
||||||
|
const key = versionKey(clientCode, questionnaireID, submissionID);
|
||||||
|
expandedVersionKey = expandedVersionKey === key ? null : key;
|
||||||
|
renderClientTableBody();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteClient(clientCode) {
|
async function deleteClient(clientCode) {
|
||||||
@ -181,6 +396,12 @@ async function deleteClient(clientCode) {
|
|||||||
try {
|
try {
|
||||||
await apiDelete('clients.php', { clientCode });
|
await apiDelete('clients.php', { clientCode });
|
||||||
clientsList = clientsList.filter(c => c.clientCode !== clientCode);
|
clientsList = clientsList.filter(c => c.clientCode !== clientCode);
|
||||||
|
delete detailByClient[clientCode];
|
||||||
|
if (expandedClientCode === clientCode) {
|
||||||
|
expandedClientCode = null;
|
||||||
|
expandedQnKey = null;
|
||||||
|
expandedVersionKey = null;
|
||||||
|
}
|
||||||
showToast(`Client "${clientCode}" deleted`, 'success');
|
showToast(`Client "${clientCode}" deleted`, 'success');
|
||||||
if (!clientsList.length) renderClients();
|
if (!clientsList.length) renderClients();
|
||||||
else renderClientTableBody();
|
else renderClientTableBody();
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export function devPage() {
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="devFixtureFile">Fixture JSON</label>
|
<label for="devFixtureFile">Fixture JSON</label>
|
||||||
<input type="file" id="devFixtureFile" accept=".json,application/json">
|
<input type="file" id="devFixtureFile" accept=".json,application/json">
|
||||||
<span class="field-hint">Use <code>dev-test-fixture.json</code> from the repo root (generate via <code>nat-as-server/scripts/generate_dev_fixture.py</code>; reads <code>questionnaires_bundle_2026-05-28.json</code>, 5× scale).</span>
|
<span class="field-hint">Use <code>dev-test-fixture.json</code> from the repo root (generate via <code>nat-as-server/scripts/generate_dev_fixture.py</code>; uneven coach load, multi-version uploads, natural timestamps).</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="devFixtureSummary" class="field-hint" style="margin:12px 0;display:none"></div>
|
<div id="devFixtureSummary" class="field-hint" style="margin:12px 0;display:none"></div>
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
||||||
@ -77,7 +77,8 @@ export function devPage() {
|
|||||||
${parsedFixture.supervisors?.length ?? st.supervisors ?? 0} supervisors,
|
${parsedFixture.supervisors?.length ?? st.supervisors ?? 0} supervisors,
|
||||||
${parsedFixture.coaches?.length ?? st.coaches ?? 0} coaches,
|
${parsedFixture.coaches?.length ?? st.coaches ?? 0} coaches,
|
||||||
${parsedFixture.clients?.length ?? st.clients ?? 0} clients,
|
${parsedFixture.clients?.length ?? st.clients ?? 0} clients,
|
||||||
${parsedFixture.completions?.length ?? st.completions ?? 0} completions
|
${parsedFixture.completions?.length ?? st.completionRecords ?? st.completions ?? 0} completions,
|
||||||
|
${st.totalUploads ?? '—'} uploads (${st.multiVersionPairs ?? '—'} re-uploaded)
|
||||||
`;
|
`;
|
||||||
summary.style.display = '';
|
summary.style.display = '';
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@ -184,8 +185,9 @@ export function devPage() {
|
|||||||
const imp = res.imported || {};
|
const imp = res.imported || {};
|
||||||
const sk = res.skipped || {};
|
const sk = res.skipped || {};
|
||||||
showToast(
|
showToast(
|
||||||
`Imported: ${imp.completions ?? 0} completions, ${imp.clients ?? 0} clients, `
|
`Imported: ${imp.completions ?? 0} completions, ${imp.submissions ?? 0} uploads, `
|
||||||
+ `${imp.coaches ?? 0} coaches (skipped ${sk.completions ?? 0} existing)`,
|
+ `${imp.clients ?? 0} clients, ${imp.coaches ?? 0} coaches `
|
||||||
|
+ `(skipped ${sk.completions ?? 0} existing)`,
|
||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
if (res.errors?.length) {
|
if (res.errors?.length) {
|
||||||
|
|||||||
Reference in New Issue
Block a user