From f04388e0ec05a9cecb184df8b8f29aa98087fbd1 Mon Sep 17 00:00:00 2001 From: "tom.hempel" Date: Thu, 9 Jul 2026 14:59:56 +0200 Subject: [PATCH] changes to translation system --- common.php | 287 +++++++ db_init.php | 22 +- handlers/app_questionnaires.php | 14 +- handlers/clients.php | 40 +- handlers/export.php | 4 +- handlers/results.php | 15 +- handlers/translations.php | 105 ++- lib/api_log.php | 2 +- lib/scoring.php | 106 +++ lib/submissions.php | 77 +- schema.sql | 11 +- .../AssignmentsActivityLogTest.php | 2 +- tests/Integration/ClientsLifecycleTest.php | 37 + website/css/style.css | 659 ++++++++++++---- website/js/client-archive.js | 8 +- website/js/datetime.js | 31 + website/js/pages/clients.js | 50 +- website/js/pages/dev.js | 16 +- website/js/pages/export.js | 5 +- website/js/pages/results.js | 58 +- website/js/pages/translations.js | 723 ++++++++++++------ website/js/pages/users.js | 5 +- website/js/test-data.js | 10 + website/js/translations-helpers.js | 10 + 24 files changed, 1882 insertions(+), 415 deletions(-) create mode 100644 website/js/datetime.js diff --git a/common.php b/common.php index 640fc52..00c2445 100644 --- a/common.php +++ b/common.php @@ -4,6 +4,13 @@ require_once __DIR__ . '/lib/text_sanitize.php'; +/** IANA timezone for all human-readable timestamps (CET/CEST). */ +function qdb_app_timezone(): string { + return 'Europe/Berlin'; +} + +date_default_timezone_set(qdb_app_timezone()); + /** * Values loaded from the project .env file (see qdb_load_dotenv). * @@ -374,11 +381,273 @@ function rbac_client_filter( define('QDB_SOURCE_LANGUAGE', 'de'); function qdb_ensure_source_language(PDO $pdo): void { + if (qdb_column_exists($pdo, 'language', 'enabled')) { + $pdo->prepare("INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1) + ON CONFLICT(languageCode) DO UPDATE SET enabled = 1") + ->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']); + return; + } $pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n) ON CONFLICT(languageCode) DO NOTHING") ->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']); } +/** @return list */ +function qdb_load_languages(PDO $pdo): array { + qdb_ensure_source_language($pdo); + $hasEnabled = qdb_column_exists($pdo, 'language', 'enabled'); + if ($hasEnabled) { + $rows = $pdo->query( + 'SELECT languageCode, name, COALESCE(enabled, 1) AS enabled + FROM language ORDER BY languageCode' + )->fetchAll(PDO::FETCH_ASSOC); + } else { + $rows = $pdo->query('SELECT languageCode, name FROM language ORDER BY languageCode') + ->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as &$row) { + $row['enabled'] = 1; + } + unset($row); + } + if (!array_filter($rows, fn($l) => ($l['languageCode'] ?? '') === QDB_SOURCE_LANGUAGE)) { + array_unshift($rows, [ + 'languageCode' => QDB_SOURCE_LANGUAGE, + 'name' => 'German', + 'enabled' => 1, + ]); + } + foreach ($rows as &$row) { + if (($row['languageCode'] ?? '') === QDB_SOURCE_LANGUAGE) { + $row['enabled'] = 1; + } else { + $row['enabled'] = (int)($row['enabled'] ?? 1) ? 1 : 0; + } + } + unset($row); + return $rows; +} + +function qdb_set_language_enabled(PDO $pdo, string $languageCode, bool $enabled): void { + $languageCode = trim($languageCode); + if ($languageCode === '') { + throw new InvalidArgumentException('languageCode required'); + } + if ($languageCode === QDB_SOURCE_LANGUAGE && !$enabled) { + throw new InvalidArgumentException('German (de) cannot be disabled'); + } + qdb_ensure_source_language($pdo); + if (!qdb_column_exists($pdo, 'language', 'enabled')) { + throw new RuntimeException('Language enablement is not available on this database'); + } + $pdo->prepare('UPDATE language SET enabled = :en WHERE languageCode = :lc') + ->execute([':en' => $enabled ? 1 : 0, ':lc' => $languageCode]); + if ($pdo->query('SELECT changes()')->fetchColumn() == 0) { + throw new InvalidArgumentException('Language not found'); + } +} + +/** @return list */ +function qdb_questionnaire_language_disables(PDO $pdo): array { + if (!qdb_table_exists($pdo, 'questionnaire_language_disable')) { + return []; + } + return $pdo->query( + 'SELECT questionnaireID, languageCode + FROM questionnaire_language_disable + ORDER BY questionnaireID, languageCode' + )->fetchAll(PDO::FETCH_ASSOC); +} + +/** @return list */ +function qdb_questionnaire_disabled_language_codes(PDO $pdo, string $questionnaireID): array { + if (!qdb_table_exists($pdo, 'questionnaire_language_disable')) { + return []; + } + $stmt = $pdo->prepare( + 'SELECT languageCode FROM questionnaire_language_disable + WHERE questionnaireID = :qid ORDER BY languageCode' + ); + $stmt->execute([':qid' => $questionnaireID]); + return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'languageCode'); +} + +function qdb_set_questionnaire_language_disabled( + PDO $pdo, + string $questionnaireID, + string $languageCode, + bool $disabled +): void { + $questionnaireID = trim($questionnaireID); + $languageCode = trim($languageCode); + if ($questionnaireID === '' || $languageCode === '') { + throw new InvalidArgumentException('questionnaireID and languageCode required'); + } + if ($languageCode === QDB_SOURCE_LANGUAGE) { + throw new InvalidArgumentException('German (de) cannot be disabled per questionnaire'); + } + if (!qdb_table_exists($pdo, 'questionnaire_language_disable')) { + throw new RuntimeException('Per-questionnaire language settings are not available on this database'); + } + $exists = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id LIMIT 1'); + $exists->execute([':id' => $questionnaireID]); + if (!$exists->fetchColumn()) { + throw new InvalidArgumentException('Questionnaire not found'); + } + if ($disabled) { + $pdo->prepare( + 'INSERT INTO questionnaire_language_disable (questionnaireID, languageCode) + VALUES (:qid, :lc) ON CONFLICT(questionnaireID, languageCode) DO NOTHING' + )->execute([':qid' => $questionnaireID, ':lc' => $languageCode]); + return; + } + $pdo->prepare( + 'DELETE FROM questionnaire_language_disable + WHERE questionnaireID = :qid AND languageCode = :lc' + )->execute([':qid' => $questionnaireID, ':lc' => $languageCode]); +} + +/** + * Language codes selectable in the app for global UI or a questionnaire. + * + * @return list + */ +function qdb_available_language_codes(PDO $pdo, ?string $questionnaireID = null): array { + $codes = []; + foreach (qdb_load_languages($pdo) as $lang) { + if ((int)($lang['enabled'] ?? 1) === 1) { + $codes[] = $lang['languageCode']; + } + } + if ($questionnaireID !== null && $questionnaireID !== '') { + $disabled = qdb_questionnaire_disabled_language_codes($pdo, $questionnaireID); + if ($disabled) { + $blocked = array_fill_keys($disabled, true); + $codes = array_values(array_filter($codes, fn($code) => !isset($blocked[$code]))); + } + } + if (!in_array(QDB_SOURCE_LANGUAGE, $codes, true)) { + array_unshift($codes, QDB_SOURCE_LANGUAGE); + } + sort($codes); + return $codes; +} + +/** Delete non-source translations for app UI, one questionnaire, or everything. */ +function qdb_delete_translations_for_scope( + PDO $pdo, + string $scope, + ?string $questionnaireID = null, + ?string $languageCode = null +): int { + $scope = trim($scope); + if (!in_array($scope, ['app', 'questionnaire', 'all'], true)) { + throw new InvalidArgumentException('scope must be app, questionnaire, or all'); + } + if ($scope === 'questionnaire' && trim((string)$questionnaireID) === '') { + throw new InvalidArgumentException('questionnaireID required for questionnaire scope'); + } + $languageCode = $languageCode !== null ? trim($languageCode) : ''; + if ($languageCode === QDB_SOURCE_LANGUAGE) { + throw new InvalidArgumentException('German (de) source translations cannot be bulk-deleted'); + } + + $languageCodes = []; + if ($languageCode !== '') { + $languageCodes = [$languageCode]; + } else { + foreach (qdb_load_languages($pdo) as $lang) { + if (($lang['languageCode'] ?? '') !== QDB_SOURCE_LANGUAGE) { + $languageCodes[] = $lang['languageCode']; + } + } + } + if (!$languageCodes) { + return 0; + } + + $deleted = 0; + + if ($scope === 'app' || $scope === 'all') { + $appKeys = array_column(qdb_app_ui_string_entries_for_website($pdo), 'entityId'); + if ($appKeys) { + $ph = implode(',', array_fill(0, count($appKeys), '?')); + $stmt = $pdo->prepare( + "DELETE FROM string_translation + WHERE stringKey IN ($ph) AND languageCode = :lang" + ); + foreach ($languageCodes as $lc) { + $stmt->execute(array_merge($appKeys, [$lc])); + $deleted += (int)$stmt->rowCount(); + } + } + } + + if ($scope === 'questionnaire' || $scope === 'all') { + $questionnaireIDs = []; + if ($scope === 'questionnaire') { + $questionnaireIDs = [trim((string)$questionnaireID)]; + } else { + $questionnaireIDs = array_column( + $pdo->query('SELECT questionnaireID FROM questionnaire ORDER BY orderIndex, name')->fetchAll(PDO::FETCH_ASSOC), + 'questionnaireID' + ); + } + + foreach ($questionnaireIDs as $qnID) { + $lists = qdb_translation_entry_lists($pdo, $qnID); + $entries = array_merge($lists['stringEntries'], $lists['contentEntries']); + $questionIDs = []; + $answerOptionIDs = []; + $stringKeys = []; + foreach ($entries as $entry) { + if (($entry['type'] ?? '') === 'question') { + $questionIDs[] = $entry['entityId']; + } elseif (($entry['type'] ?? '') === 'answer_option') { + $answerOptionIDs[] = $entry['entityId']; + } elseif (($entry['type'] ?? '') === 'string') { + $stringKeys[] = $entry['entityId']; + } + } + + if ($questionIDs) { + $ph = implode(',', array_fill(0, count($questionIDs), '?')); + $stmt = $pdo->prepare( + "DELETE FROM question_translation + WHERE questionID IN ($ph) AND languageCode = ?" + ); + foreach ($languageCodes as $lc) { + $stmt->execute(array_merge($questionIDs, [$lc])); + $deleted += (int)$stmt->rowCount(); + } + } + if ($answerOptionIDs) { + $ph = implode(',', array_fill(0, count($answerOptionIDs), '?')); + $stmt = $pdo->prepare( + "DELETE FROM answer_option_translation + WHERE answerOptionID IN ($ph) AND languageCode = ?" + ); + foreach ($languageCodes as $lc) { + $stmt->execute(array_merge($answerOptionIDs, [$lc])); + $deleted += (int)$stmt->rowCount(); + } + } + if ($stringKeys) { + $ph = implode(',', array_fill(0, count($stringKeys), '?')); + $stmt = $pdo->prepare( + "DELETE FROM string_translation + WHERE stringKey IN ($ph) AND languageCode = ?" + ); + foreach ($languageCodes as $lc) { + $stmt->execute(array_merge($stringKeys, [$lc])); + $deleted += (int)$stmt->rowCount(); + } + } + } + } + + return $deleted; +} + function qdb_upsert_source_translation(PDO $pdo, string $type, string $id, string $text): void { qdb_put_translation($pdo, $type, $id, QDB_SOURCE_LANGUAGE, $text); } @@ -972,6 +1241,21 @@ function qdb_validate_stable_key(string $s, string $label = 'Key'): string { return $normalized; } +/** Question keys omitted from results tables and CSV exports (e.g. last-page warnings). */ +function qdb_results_excluded_question_keys(): array { + return ['data_final_warning']; +} + +/** Whether a question row should be hidden from results tables and CSV exports. */ +function qdb_question_is_results_excluded(array $qRow): bool { + if (($qRow['type'] ?? '') === 'last_page') { + return true; + } + $cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: []; + $key = qdb_question_key($cfg, (string)($qRow['defaultText'] ?? '')); + return in_array($key, qdb_results_excluded_question_keys(), true); +} + /** Resolve app lookup key for a question row. */ function qdb_question_key(array $config, string $defaultText): string { $key = trim((string)($config['questionKey'] ?? '')); @@ -1078,6 +1362,9 @@ function qdb_results_export_columns(array $questions, string $qnID): array { $columns = []; foreach ($questions as $q) { + if (qdb_question_is_results_excluded($q)) { + continue; + } $cfg = json_decode($q['configJson'] ?? '{}', true) ?: []; $type = $q['type'] ?? ''; if ($type === 'glass_scale_question') { diff --git a/db_init.php b/db_init.php index 3aa0649..18311a8 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', 13); +define('QDB_VERSION', 14); function qdb_table_exists(PDO $pdo, string $table): bool { $stmt = $pdo->prepare( @@ -348,6 +348,26 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { } } + $currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); + if ($currentVersion < 14) { + if (qdb_table_exists($pdo, 'language') && !qdb_column_exists($pdo, 'language', 'enabled')) { + $pdo->exec('ALTER TABLE language ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1'); + $changed = true; + } + if (!qdb_table_exists($pdo, 'questionnaire_language_disable')) { + $pdo->exec( + 'CREATE TABLE questionnaire_language_disable ( + questionnaireID TEXT NOT NULL, + languageCode TEXT NOT NULL, + PRIMARY KEY (questionnaireID, languageCode), + FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE, + FOREIGN KEY(languageCode) REFERENCES language(languageCode) ON DELETE CASCADE + )' + ); + $changed = true; + } + } + $currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn(); if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) { $pdo->exec( diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index afa655f..c978a0d 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -19,7 +19,10 @@ if ($method === 'GET' && !empty($_GET['translations'])) { $opened = qdb_open_read_or_fail(); [$pdo, $tmpDb, $lockFp] = $opened; - json_success(['translations' => qdb_build_app_translations_map($pdo)]); + json_success([ + 'translations' => qdb_build_app_translations_map($pdo), + 'availableLanguages' => qdb_available_language_codes($pdo), + ]); qdb_discard($tmpDb, $lockFp); return; } @@ -588,10 +591,11 @@ if ($qnID) { qdb_discard($tmpDb, $lockFp); json_success([ - 'meta' => ['id' => $qnID], - 'structureRevision' => $structureRevision, - 'questions' => $questions, - 'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID), + 'meta' => ['id' => $qnID], + 'structureRevision' => $structureRevision, + 'questions' => $questions, + 'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID), + 'availableLanguages' => qdb_available_language_codes($pdo, $qnID), ]); } diff --git a/handlers/clients.php b/handlers/clients.php index 305a038..77d776d 100644 --- a/handlers/clients.php +++ b/handlers/clients.php @@ -62,7 +62,45 @@ case 'GET': break; case 'POST': - $body = read_json_body(); + $body = read_json_body(); + + if (($body['action'] ?? '') === 'reset') { + $clientCode = trim((string)($body['clientCode'] ?? '')); + if ($clientCode === '') { + json_error('MISSING_FIELDS', 'clientCode is required', 400); + } + + try { + [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); + + [$clause, $params] = rbac_client_filter($tokenRec, 'cl', 'all'); + $chk = $pdo->prepare( + "SELECT clientCode FROM client cl WHERE cl.clientCode = :cc AND ($clause)" + ); + $chk->execute(array_merge([':cc' => $clientCode], $params)); + if (!$chk->fetch()) { + qdb_discard($tmpDb, $lockFp); + json_error('NOT_FOUND', 'Client not found or not authorized', 404); + } + + require_once __DIR__ . '/../lib/submissions.php'; + + $pdo->beginTransaction(); + $cleared = qdb_delete_client_response_data($pdo, [$clientCode]); + $pdo->commit(); + + qdb_save($tmpDb, $lockFp); + json_success([ + 'clientCode' => $clientCode, + 'reset' => true, + 'cleared' => $cleared, + ]); + } catch (Throwable $e) { + qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); + } + break; + } + $coachID = trim($body['coachID'] ?? ''); $bulk = !empty($body['bulk']); diff --git a/handlers/export.php b/handlers/export.php index 39d6110..0c9cb23 100644 --- a/handlers/export.php +++ b/handlers/export.php @@ -85,7 +85,7 @@ if ($allVersions) { $filename = $safeName . '_all_versions_' . date('Y-m-d') . '.csv'; qdb_http_download( - qdb_export_rows_to_csv_string($rows, qdb_export_all_versions_csv_fallback_header($resultColumns)), + qdb_export_rows_to_csv_string($rows, qdb_export_all_versions_csv_fallback_header($pdo, $resultColumns)), [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', @@ -100,7 +100,7 @@ $safeName = qdb_export_safe_basename($questionnaire['name']); $filename = $safeName . '_v' . $questionnaire['version'] . '.csv'; qdb_http_download( - qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($resultColumns)), + qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($pdo, $resultColumns)), [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', diff --git a/handlers/results.php b/handlers/results.php index 234c97d..14ad52b 100644 --- a/handlers/results.php +++ b/handlers/results.php @@ -1,5 +1,7 @@ $questionnaire, - 'questions' => $questions, - 'clients' => $clients, + 'questionnaire' => $questionnaire, + 'questions' => $questions, + 'clients' => $clients, + 'scoringProfiles' => $scoringSummary['profiles'], ]); } catch (Throwable $e) { qdb_handler_fail($e, 'Load results', null, $tmpDb ?? null, $lockFp ?? null); diff --git a/handlers/translations.php b/handlers/translations.php index 44d109d..3e06fa2 100644 --- a/handlers/translations.php +++ b/handlers/translations.php @@ -2,7 +2,7 @@ $tokenRec = require_valid_token_web(); -$validTypes = ['question', 'answer_option', 'string', 'app_string', 'language']; +$validTypes = ['question', 'answer_option', 'string', 'app_string', 'language', 'language_enabled', 'questionnaire_language']; switch ($method) { @@ -32,9 +32,13 @@ case 'GET': try { $opened = qdb_open_read_or_fail(); [$pdo, $tmpDb, $lockFp] = $opened; - $rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC); + $rows = qdb_load_languages($pdo); + $disables = qdb_questionnaire_language_disables($pdo); qdb_discard($tmpDb, $lockFp); - json_success(['languages' => $rows]); + json_success([ + 'languages' => $rows, + 'questionnaireLanguageDisables' => $disables, + ]); } catch (Throwable $e) { qdb_handler_fail($e, 'Load languages', null, $tmpDb ?? null, $lockFp ?? null); } @@ -45,9 +49,9 @@ case 'GET': $opened = qdb_open_read_or_fail(); [$pdo, $tmpDb, $lockFp] = $opened; - $languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC); + $languages = qdb_load_languages($pdo); if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) { - array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']); + array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German', 'enabled' => 1]); } $qnRows = $pdo->query(" @@ -82,11 +86,12 @@ case 'GET': qdb_discard($tmpDb, $lockFp); json_success([ - 'languages' => $languages, - 'appStrings' => $appEntries, - 'questionnaires' => $questionnaires, - 'entries' => $allEntries, - 'sourceLanguage' => QDB_SOURCE_LANGUAGE, + 'languages' => $languages, + 'appStrings' => $appEntries, + 'questionnaires' => $questionnaires, + 'entries' => $allEntries, + 'sourceLanguage' => QDB_SOURCE_LANGUAGE, + 'questionnaireLanguageDisables' => qdb_questionnaire_language_disables($pdo), ]); } catch (Throwable $e) { qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null); @@ -99,9 +104,9 @@ case 'GET': $opened = qdb_open_read_or_fail(); [$pdo, $tmpDb, $lockFp] = $opened; - $languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC); + $languages = qdb_load_languages($pdo); if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) { - array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']); + array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German', 'enabled' => 1]); } $qnRow = $pdo->prepare('SELECT name FROM questionnaire WHERE questionnaireID = :id'); @@ -119,10 +124,11 @@ case 'GET': qdb_discard($tmpDb, $lockFp); json_success([ - 'languages' => $languages, - 'entries' => $entries, - 'questionnaire' => ['questionnaireID' => $qnID, 'name' => (string)$qnName], - 'sourceLanguage' => QDB_SOURCE_LANGUAGE, + 'languages' => $languages, + 'entries' => $entries, + 'questionnaire' => ['questionnaireID' => $qnID, 'name' => (string)$qnName], + 'sourceLanguage' => QDB_SOURCE_LANGUAGE, + 'questionnaireLanguageDisables' => qdb_questionnaire_language_disables($pdo), ]); } catch (Throwable $e) { qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null); @@ -185,6 +191,28 @@ case 'POST': } break; } + if (($body['action'] ?? '') === 'deleteTranslationsScope') { + $scope = trim((string)($body['scope'] ?? '')); + $qnID = trim((string)($body['questionnaireID'] ?? '')); + $lang = isset($body['languageCode']) ? trim((string)$body['languageCode']) : ''; + [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); + try { + $deleted = qdb_delete_translations_for_scope( + $pdo, + $scope, + $qnID !== '' ? $qnID : null, + $lang !== '' ? $lang : null + ); + qdb_save($tmpDb, $lockFp); + json_success(['deleted' => $deleted]); + } catch (InvalidArgumentException $e) { + qdb_discard($tmpDb, $lockFp); + json_error('INVALID_FIELD', $e->getMessage(), 400); + } catch (Throwable $e) { + qdb_handler_fail($e, 'Delete translations', null, $tmpDb, $lockFp); + } + break; + } json_error('BAD_REQUEST', 'Unknown action', 400); break; @@ -203,6 +231,10 @@ case 'PUT': try { if ($lang === QDB_SOURCE_LANGUAGE) { qdb_ensure_source_language($pdo); + } elseif (qdb_column_exists($pdo, 'language', 'enabled')) { + $pdo->prepare("INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1) + ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name") + ->execute([':lc' => $lang, ':n' => $name]); } else { $pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n) ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name") @@ -216,6 +248,47 @@ case 'PUT': break; } + if ($type === 'language_enabled') { + $lang = trim($body['languageCode'] ?? ''); + if (!$lang) { + json_error('MISSING_FIELDS', 'languageCode required', 400); + } + $enabled = !empty($body['enabled']); + [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); + try { + qdb_set_language_enabled($pdo, $lang, $enabled); + qdb_save($tmpDb, $lockFp); + json_success([]); + } catch (InvalidArgumentException $e) { + qdb_discard($tmpDb, $lockFp); + json_error('INVALID_FIELD', $e->getMessage(), 400); + } catch (Throwable $e) { + qdb_handler_fail($e, 'Save language setting', null, $tmpDb, $lockFp); + } + break; + } + + if ($type === 'questionnaire_language') { + $qnID = trim($body['questionnaireID'] ?? ''); + $lang = trim($body['languageCode'] ?? ''); + if (!$qnID || !$lang) { + json_error('MISSING_FIELDS', 'questionnaireID and languageCode required', 400); + } + $enabled = !empty($body['enabled']); + [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); + try { + qdb_set_questionnaire_language_disabled($pdo, $qnID, $lang, !$enabled); + qdb_save($tmpDb, $lockFp); + json_success([]); + } catch (InvalidArgumentException $e) { + qdb_discard($tmpDb, $lockFp); + json_error('INVALID_FIELD', $e->getMessage(), 400); + } catch (Throwable $e) { + qdb_handler_fail($e, 'Save questionnaire language setting', null, $tmpDb, $lockFp); + } + break; + } + $id = $body['id'] ?? ''; $lang = trim($body['languageCode'] ?? ''); $text = $body['text'] ?? ''; diff --git a/lib/api_log.php b/lib/api_log.php index 4224273..d4e3779 100644 --- a/lib/api_log.php +++ b/lib/api_log.php @@ -281,7 +281,7 @@ function qdb_api_log_finish(): void } $entry = [ - 'ts' => gmdate('c'), + 'ts' => date('c'), 'activity' => $activity, 'method' => $ctx['method'] ?? '', 'route' => $ctx['route'] ?? '', diff --git a/lib/scoring.php b/lib/scoring.php index 0cfd769..db58c25 100644 --- a/lib/scoring.php +++ b/lib/scoring.php @@ -835,3 +835,109 @@ function qdb_set_coach_scoring_band( $row['profileID'] = $profileID; return qdb_scoring_review_row_to_api($row); } + +/** + * @return list + */ +function qdb_active_scoring_profiles(PDO $pdo): array { + $rows = $pdo->query( + "SELECT profileID, name FROM scoring_profile WHERE isActive = 1 ORDER BY name" + )->fetchAll(PDO::FETCH_ASSOC); + return array_map(static fn(array $p) => [ + 'profileID' => (string)$p['profileID'], + 'name' => (string)$p['name'], + ], $rows); +} + +/** + * CSV / results table columns for active scoring profiles. + * + * @param list $profiles + * @return list + */ +function qdb_scoring_export_column_defs(array $profiles): array { + $cols = []; + foreach ($profiles as $p) { + $name = trim((string)($p['name'] ?? '')); + $prefix = $name !== '' ? $name : (string)($p['profileID'] ?? ''); + $profileID = (string)($p['profileID'] ?? ''); + if ($profileID === '') { + continue; + } + $cols[] = ['profileID' => $profileID, 'field' => 'calculated', 'header' => "{$prefix} calculated category"]; + $cols[] = ['profileID' => $profileID, 'field' => 'counselor', 'header' => "{$prefix} counselor category"]; + $cols[] = ['profileID' => $profileID, 'field' => 'override_by', 'header' => "{$prefix} override by"]; + $cols[] = ['profileID' => $profileID, 'field' => 'override_at', 'header' => "{$prefix} override at"]; + } + return $cols; +} + +function qdb_scoring_export_cell_value(array $profileResult, string $field): string { + $calc = (string)($profileResult['band'] ?? ''); + $coach = trim((string)($profileResult['coachBand'] ?? '')); + $differs = $coach !== '' && $coach !== $calc; + return match ($field) { + 'calculated' => $calc, + 'counselor' => $coach, + 'override_by' => $differs ? (string)($profileResult['coachReviewedByUsername'] ?? '') : '', + 'override_at' => $differs && !empty($profileResult['coachReviewedAt']) + ? date('Y-m-d H:i', (int)$profileResult['coachReviewedAt']) : '', + default => '', + }; +} + +/** + * @param list $columnDefs + * @param array> $byProfile profileID => result row + * @return array + */ +function qdb_scoring_export_row_values(array $byProfile, array $columnDefs): array { + $row = []; + foreach ($columnDefs as $col) { + $profileID = (string)($col['profileID'] ?? ''); + $result = $byProfile[$profileID] ?? null; + $row[$col['header']] = is_array($result) + ? qdb_scoring_export_cell_value($result, (string)($col['field'] ?? '')) + : ''; + } + return $row; +} + +/** + * Scoring results for export, keyed by clientCode then profileID. + * + * @param list $clientCodes + * @return array>> + */ +function qdb_scoring_export_results_map(PDO $pdo, array $clientCodes, array $tokenRec): array { + if ($clientCodes === []) { + return []; + } + [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); + $placeholders = implode(',', array_fill(0, count($clientCodes), '?')); + $stmt = $pdo->prepare( + "SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.coachReviewedAt, r.coachReviewedByUserID, + u.username AS coachReviewedByUsername + FROM client_scoring_profile_result r + INNER JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1 + INNER JOIN client cl ON cl.clientCode = r.clientCode + LEFT JOIN users u ON u.userID = r.coachReviewedByUserID + WHERE r.clientCode IN ($placeholders) AND ($rbacClause)" + ); + $stmt->execute(array_merge(array_values($clientCodes), $rbacParams)); + + $map = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $cc = (string)$row['clientCode']; + $map[$cc][(string)$row['profileID']] = $row; + } + return $map; +} + +/** + * @return list + */ +function qdb_scoring_export_csv_headers(PDO $pdo): array { + $profiles = qdb_active_scoring_profiles($pdo); + return array_column(qdb_scoring_export_column_defs($profiles), 'header'); +} diff --git a/lib/submissions.php b/lib/submissions.php index 79994dd..0dccc72 100644 --- a/lib/submissions.php +++ b/lib/submissions.php @@ -43,6 +43,9 @@ function qdb_display_context_from_manifest(PDO $pdo, array $manifest): array { ), ]; } + if (qdb_question_is_results_excluded($qRow)) { + continue; + } $questions[] = $qRow; $cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: []; @@ -293,7 +296,10 @@ function qdb_export_all_versions_rows( array $optionTextMap, array $tokenRec ): array { + require_once __DIR__ . '/scoring.php'; [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); + $scoringProfiles = qdb_active_scoring_profiles($pdo); + $scoringColumnDefs = qdb_scoring_export_column_defs($scoringProfiles); $sql = " SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole, @@ -315,6 +321,9 @@ function qdb_export_all_versions_rows( $stmt->execute($params); $submissions = $stmt->fetchAll(PDO::FETCH_ASSOC); + $clientCodes = array_values(array_unique(array_column($submissions, 'clientCode'))); + $scoringByClient = qdb_scoring_export_results_map($pdo, $clientCodes, $tokenRec); + $defaultQuestionIDs = array_column($questions, 'questionID'); $userMap = []; @@ -353,6 +362,14 @@ function qdb_export_all_versions_rows( 'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '', ]; + $row = array_merge( + $row, + qdb_scoring_export_row_values( + $scoringByClient[$s['clientCode']] ?? [], + $scoringColumnDefs + ) + ); + $answerMap = qdb_load_client_answer_map( $pdo, $s['clientCode'], @@ -413,8 +430,11 @@ function qdb_export_current_rows( array $optionTextMap, array $tokenRec ): array { + require_once __DIR__ . '/scoring.php'; [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); $questionIDs = array_column($questions, 'questionID'); + $scoringProfiles = qdb_active_scoring_profiles($pdo); + $scoringColumnDefs = qdb_scoring_export_column_defs($scoringProfiles); $sql = " SELECT cl.clientCode, cl.coachID, @@ -433,6 +453,9 @@ function qdb_export_current_rows( $cStmt->execute($params); $clients = $cStmt->fetchAll(PDO::FETCH_ASSOC); + $clientCodes = array_column($clients, 'clientCode'); + $scoringByClient = qdb_scoring_export_results_map($pdo, $clientCodes, $tokenRec); + $qPlaceholders = !empty($questionIDs) ? implode(',', array_fill(0, count($questionIDs), '?')) : "'__none__'"; $answerStmt = $pdo->prepare(" SELECT questionID, answerOptionID, freeTextValue, numericValue @@ -452,6 +475,14 @@ function qdb_export_current_rows( 'completedAt' => $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '', ]; + $row = array_merge( + $row, + qdb_scoring_export_row_values( + $scoringByClient[$c['clientCode']] ?? [], + $scoringColumnDefs + ) + ); + $bindParams = array_merge([$c['clientCode']], $questionIDs); $answerStmt->execute($bindParams); $answerMap = []; @@ -514,19 +545,23 @@ function qdb_export_rows_to_csv_string(array $rows, array $fallbackHeader = []): return $csv !== false ? $csv : ''; } -function qdb_export_current_csv_fallback_header(array $resultColumns): array { +function qdb_export_current_csv_fallback_header(PDO $pdo, array $resultColumns): array { + require_once __DIR__ . '/scoring.php'; $header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt']; + $header = array_merge($header, qdb_scoring_export_csv_headers($pdo)); foreach ($resultColumns as $col) { $header[] = $col['header']; } return $header; } -function qdb_export_all_versions_csv_fallback_header(array $resultColumns): array { +function qdb_export_all_versions_csv_fallback_header(PDO $pdo, array $resultColumns): array { + require_once __DIR__ . '/scoring.php'; $header = [ 'submissionID', 'version', 'submittedAt', 'submittedByRole', 'submittedBy', 'clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt', ]; + $header = array_merge($header, qdb_scoring_export_csv_headers($pdo)); foreach ($resultColumns as $col) { $header[] = $col['header']; } @@ -566,11 +601,11 @@ function qdb_build_server_export_zip(PDO $pdo, array $tokenRec, bool $allVersion if ($allVersions) { $rows = qdb_export_all_versions_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec); - $fallback = qdb_export_all_versions_csv_fallback_header($resultColumns); + $fallback = qdb_export_all_versions_csv_fallback_header($pdo, $resultColumns); $base = qdb_export_safe_basename($qn['name']) . '_all_versions.csv'; } else { $rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec); - $fallback = qdb_export_current_csv_fallback_header($resultColumns); + $fallback = qdb_export_current_csv_fallback_header($pdo, $resultColumns); $ver = preg_replace('/[^a-zA-Z0-9_.-]+/', '_', (string)($qn['version'] ?? '')); $base = qdb_export_safe_basename($qn['name']) . ($ver !== '' ? "_v{$ver}" : '') . '.csv'; } @@ -593,7 +628,7 @@ function qdb_build_server_export_zip(PDO $pdo, array $tokenRec, bool $allVersion * Delete uploads, answers, and completions for client codes (does not delete client rows). * * @param list $clientCodes - * @return array{submissions: int, followup_notes: int, client_answers: int, completed_questionnaires: int} + * @return array{submissions: int, followup_notes: int, client_answers: int, completed_questionnaires: int, scoring_results: int} */ function qdb_delete_client_response_data(PDO $pdo, array $clientCodes): array { $clientCodes = array_values(array_unique(array_filter( @@ -605,6 +640,7 @@ function qdb_delete_client_response_data(PDO $pdo, array $clientCodes): array { 'followup_notes' => 0, 'client_answers' => 0, 'completed_questionnaires' => 0, + 'scoring_results' => 0, ]; if ($clientCodes === []) { return $deleted; @@ -628,6 +664,11 @@ function qdb_delete_client_response_data(PDO $pdo, array $clientCodes): array { $stmt = $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode IN ($ph)"); $stmt->execute($chunk); $deleted['completed_questionnaires'] += $stmt->rowCount(); + if (qdb_table_exists($pdo, 'client_scoring_profile_result')) { + $stmt = $pdo->prepare("DELETE FROM client_scoring_profile_result WHERE clientCode IN ($ph)"); + $stmt->execute($chunk); + $deleted['scoring_results'] += $stmt->rowCount(); + } } return $deleted; @@ -893,9 +934,11 @@ function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array $stmt = $pdo->prepare( 'SELECT r.profileID, r.weightedTotal, r.band, r.computedAt, r.questionnaireSnapshot, r.coachBand, r.coachReviewedAt, r.coachReviewedByUserID, + u.username AS coachReviewedByUsername, sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin FROM client_scoring_profile_result r JOIN scoring_profile sp ON sp.profileID = r.profileID + LEFT JOIN users u ON u.userID = r.coachReviewedByUserID WHERE r.clientCode = :cc ORDER BY sp.name' ); @@ -904,6 +947,8 @@ function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $bands = qdb_normalize_scoring_bands($row); $coachBand = trim((string)($row['coachBand'] ?? '')); + $calcBand = (string)$row['band']; + $coachDiffers = $coachBand !== '' && $coachBand !== $calcBand; $out[] = [ 'profileID' => $row['profileID'], 'name' => $row['name'], @@ -913,6 +958,7 @@ function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array 'coachBand' => $coachBand !== '' ? $coachBand : null, 'effectiveBand' => qdb_effective_scoring_band($row), 'pendingReview' => $coachBand === '', + 'coachOverride' => $coachDiffers, 'greenMin' => $bands['greenMin'], 'greenMax' => $bands['greenMax'], 'yellowMin' => $bands['yellowMin'], @@ -921,6 +967,8 @@ function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array 'computedAt' => $row['computedAt'] ? date('Y-m-d H:i', (int)$row['computedAt']) : '', 'coachReviewedAt' => !empty($row['coachReviewedAt']) ? date('Y-m-d H:i', (int)$row['coachReviewedAt']) : '', + 'coachReviewedByUsername' => $coachDiffers + ? (string)($row['coachReviewedByUsername'] ?? '') : '', 'snapshot' => json_decode($row['questionnaireSnapshot'] ?? '{}', true) ?: [], ]; } @@ -945,10 +993,13 @@ function qdb_clients_scoring_summary_for_list(PDO $pdo, array $tokenRec): array } $stmt = $pdo->prepare( - "SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.weightedTotal, sp.name + "SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.weightedTotal, + r.coachReviewedAt, r.coachReviewedByUserID, u.username AS coachReviewedByUsername, + sp.name FROM client_scoring_profile_result r INNER JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1 INNER JOIN client cl ON cl.clientCode = r.clientCode + LEFT JOIN users u ON u.userID = r.coachReviewedByUserID WHERE ($rbacClause)" ); $stmt->execute($rbacParams); @@ -957,14 +1008,21 @@ function qdb_clients_scoring_summary_for_list(PDO $pdo, array $tokenRec): array foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $cc = (string)$row['clientCode']; $coachBand = trim((string)($row['coachBand'] ?? '')); + $calcBand = (string)$row['band']; + $coachDiffers = $coachBand !== '' && $coachBand !== $calcBand; $byClient[$cc][$row['profileID']] = [ - 'band' => (string)$row['band'], - 'calculatedBand' => (string)$row['band'], + 'band' => $calcBand, + 'calculatedBand' => $calcBand, 'coachBand' => $coachBand !== '' ? $coachBand : null, 'effectiveBand' => qdb_effective_scoring_band($row), 'pendingReview' => $coachBand === '', + 'coachOverride' => $coachDiffers, 'weightedTotal' => (float)$row['weightedTotal'], 'name' => (string)$row['name'], + 'coachReviewedAt' => !empty($row['coachReviewedAt']) + ? date('Y-m-d H:i', (int)$row['coachReviewedAt']) : '', + 'coachReviewedByUsername' => $coachDiffers + ? (string)($row['coachReviewedByUsername'] ?? '') : '', ]; } @@ -992,6 +1050,9 @@ function qdb_client_scoring_dots_for_client(string $clientCode, array $summary): 'coachBand' => $result['coachBand'] ?? null, 'effectiveBand' => $result['effectiveBand'] ?? null, 'pendingReview' => $result['pendingReview'] ?? false, + 'coachOverride' => $result['coachOverride'] ?? false, + 'coachReviewedAt' => $result['coachReviewedAt'] ?? '', + 'coachReviewedByUsername' => $result['coachReviewedByUsername'] ?? '', 'weightedTotal' => $result !== null ? $result['weightedTotal'] : null, ]; } diff --git a/schema.sql b/schema.sql index f9edb1f..64dd9bf 100644 --- a/schema.sql +++ b/schema.sql @@ -142,7 +142,16 @@ CREATE TABLE IF NOT EXISTS string_translation ( CREATE TABLE IF NOT EXISTS language ( languageCode TEXT NOT NULL PRIMARY KEY, - name TEXT NOT NULL DEFAULT '' + name TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS questionnaire_language_disable ( + questionnaireID TEXT NOT NULL, + languageCode TEXT NOT NULL, + PRIMARY KEY (questionnaireID, languageCode), + FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE, + FOREIGN KEY(languageCode) REFERENCES language(languageCode) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS session ( diff --git a/tests/Integration/AssignmentsActivityLogTest.php b/tests/Integration/AssignmentsActivityLogTest.php index 2f57454..2407e68 100644 --- a/tests/Integration/AssignmentsActivityLogTest.php +++ b/tests/Integration/AssignmentsActivityLogTest.php @@ -27,7 +27,7 @@ final class AssignmentsActivityLogTest extends QdbTestCase DatabaseSeeder::ADMIN_USERNAME, DatabaseSeeder::PASSWORD )['token']; - $today = gmdate('Y-m-d'); + $today = date('Y-m-d'); $res = $this->api()->withToken($token, 'GET', 'activity-log', null, [ 'date' => $today, 'limit' => '50', diff --git a/tests/Integration/ClientsLifecycleTest.php b/tests/Integration/ClientsLifecycleTest.php index 5870053..c95d363 100644 --- a/tests/Integration/ClientsLifecycleTest.php +++ b/tests/Integration/ClientsLifecycleTest.php @@ -91,6 +91,43 @@ final class ClientsLifecycleTest extends QdbTestCase $this->assertContains($code, array_column($activeAgain['data']['clients'], 'clientCode')); } + public function testAdminResetsClient(): void + { + $this->submitFixtureQuestionnaire(); + $token = $this->api()->loginWebAndGetToken( + DatabaseSeeder::ADMIN_USERNAME, + DatabaseSeeder::PASSWORD + )['token']; + $code = $this->fixture()->clientCode; + + $reset = $this->api()->withToken($token, 'POST', 'clients', [ + 'action' => 'reset', + 'clientCode' => $code, + ]); + $this->assertApiOk($reset); + $this->assertTrue($reset['data']['reset'] ?? false); + + $detail = $this->api()->withToken($token, 'GET', 'clients', null, [ + 'clientCode' => $code, + 'detail' => '1', + ]); + $this->assertApiOk($detail); + $this->assertSame($code, $detail['data']['client']['clientCode'] ?? ''); + $this->assertSame([], $detail['data']['questionnaires'] ?? null); + + $list = $this->api()->withToken($token, 'GET', 'clients'); + $this->assertApiOk($list); + $row = null; + foreach ($list['data']['clients'] as $client) { + if (($client['clientCode'] ?? '') === $code) { + $row = $client; + break; + } + } + $this->assertNotNull($row); + $this->assertSame(0, (int)($row['hasResponseData'] ?? 1)); + } + public function testAdminDeletesClient(): void { $token = $this->api()->loginWebAndGetToken( diff --git a/website/css/style.css b/website/css/style.css index e8a6dfd..816521d 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -300,8 +300,9 @@ body.logged-in .card > h3 { body.logged-in :is( .question-item, .inline-form-card, - .trans-lang-panel, - .trans-top-bar, + .trans-sidebar-section, + .trans-workspace-header, + .trans-qn-availability, .trans-sheet, .lang-manager, .condition-editor, @@ -335,6 +336,7 @@ body.logged-in :is( .trans-select, .lang-add-code, .lang-add-name, + .trans-add-input, .trans-search-input, .followup-note-input, .condition-json-fallback, @@ -2115,29 +2117,483 @@ body.modal-open { } /* ── Translations page ── */ -.trans-page { padding: 20px 24px; } -.trans-controls { - display: flex; - flex-wrap: wrap; - gap: 16px 24px; - margin-bottom: 16px; +.trans-layout { + display: grid; + grid-template-columns: minmax(260px, 300px) minmax(0, 1fr); + gap: 20px; + align-items: start; + min-height: calc(100vh - 180px); } -.trans-control { +.trans-sidebar { + display: flex; + flex-direction: column; + gap: 14px; + position: sticky; + top: 16px; + max-height: calc(100vh - 100px); + overflow-y: auto; +} +.trans-sidebar-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 14px; + box-shadow: var(--shadow), var(--shadow-inset); +} +.trans-sidebar-advanced { + margin-top: auto; + background: var(--surface-muted); +} +.trans-sidebar-head { margin-bottom: 10px; } +.trans-sidebar-title { + margin: 0; + font-size: .92rem; + font-weight: 600; + color: var(--text); +} +.trans-sidebar-hint { + margin: 4px 0 0; + font-size: .78rem; + line-height: 1.4; + color: var(--text-secondary); +} +.trans-lang-list { display: flex; flex-direction: column; gap: 6px; - flex: 1; - min-width: 200px; - max-width: 360px; +} +.trans-lang-item { + display: grid; + grid-template-columns: 40px 1fr auto; + gap: 10px; + align-items: center; + width: 100%; + padding: 10px 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-muted); + text-align: left; + font: inherit; + color: inherit; + cursor: pointer; + transition: border-color .15s, background .15s, box-shadow .15s; +} +button.trans-lang-item { appearance: none; } +.trans-lang-item:hover { + border-color: var(--primary-border); + background: var(--surface-hover); +} +.trans-lang-item.is-selected { + border-color: var(--primary); + background: var(--primary-subtle); + box-shadow: 0 0 0 1px var(--primary-border); +} +.trans-lang-item.is-disabled { opacity: .68; } +.trans-lang-item-source { + cursor: default; + background: var(--lang-chip-fixed-bg); + border-color: var(--lang-chip-fixed-border); +} +.trans-lang-item-source:hover { + border-color: var(--lang-chip-fixed-border); + background: var(--lang-chip-fixed-bg); +} +.trans-lang-code { + font-size: .78rem; + font-weight: 700; + letter-spacing: .04em; + color: var(--primary); + text-align: center; +} +.trans-lang-meta { min-width: 0; } +.trans-lang-name { + display: block; + font-size: .88rem; + font-weight: 600; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.trans-lang-sub { + display: block; + font-size: .72rem; + color: var(--text-secondary); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.trans-lang-badge { + font-size: .65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + padding: 3px 7px; + border-radius: 999px; + white-space: nowrap; +} +.trans-lang-badge-source { + background: var(--primary-subtle); + color: var(--primary); + border: 1px solid var(--primary-border); +} +.trans-lang-actions { + display: flex; + align-items: center; + gap: 4px; +} +.trans-lang-remove { + width: 24px; + height: 24px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity .15s, color .15s, background .15s; +} +.trans-lang-item:hover .trans-lang-remove, +.trans-lang-item.is-selected .trans-lang-remove { opacity: 1; } +.trans-lang-remove:hover { + color: var(--danger); + background: var(--toast-error-bg); +} +.trans-toggle { + position: relative; + display: inline-flex; + cursor: pointer; +} +.trans-toggle input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} +.trans-toggle-ui { + display: block; + width: 34px; + height: 20px; + border-radius: 999px; + background: var(--border); + transition: background .2s; + position: relative; +} +.trans-toggle-ui::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(0,0,0,.15); + transition: transform .2s; +} +.trans-toggle input:checked + .trans-toggle-ui { background: var(--success); } +.trans-toggle input:checked + .trans-toggle-ui::after { transform: translateX(14px); } +.trans-toggle input:focus-visible + .trans-toggle-ui { + box-shadow: 0 0 0 3px var(--focus-ring); +} +.trans-add-lang { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.trans-add-lang-form { + display: flex; + flex-direction: column; + gap: 8px; +} +.trans-add-lang-fields { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 6px; +} +.trans-add-input { + width: 100%; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; font-size: .85rem; + background: var(--surface); + box-sizing: border-box; +} +.trans-add-lang-btn { + display: flex; + width: 100%; + justify-content: center; + padding: 8px 12px; +} +.trans-add-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--focus-ring); +} +.trans-content-nav { + display: flex; + flex-direction: column; + gap: 4px; +} +.trans-content-item { + display: grid; + grid-template-columns: 22px 1fr auto; + gap: 8px; + align-items: center; + width: 100%; + padding: 9px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + text-align: left; + font: inherit; + color: var(--text); + cursor: pointer; + transition: background .15s, border-color .15s; +} +.trans-content-item:hover { background: var(--surface-muted); } +.trans-content-item.is-active { + background: var(--primary-subtle); + border-color: var(--primary-border); + font-weight: 600; +} +.trans-content-item-all { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 12px; } +.trans-content-icon { + font-size: .75rem; + color: var(--text-secondary); + text-align: center; +} +.trans-content-label { + font-size: .86rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.trans-nav-badge { + font-size: .7rem; + font-weight: 700; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.trans-nav-badge-warn { + background: var(--badge-warn-bg); + color: var(--badge-warn-fg); +} +.trans-nav-badge-ok { + background: var(--success-subtle, #ecfdf5); + color: var(--success); + font-size: .8rem; +} +.trans-advanced-panel { border: none; } +.trans-advanced-summary { + cursor: pointer; + font-size: .85rem; + font-weight: 600; + color: var(--text-secondary); + user-select: none; +} +.trans-advanced-body { margin-top: 12px; } +.trans-advanced-note { font-size: .8rem; margin: 0 0 10px; } +.trans-field { + display: flex; + flex-direction: column; + gap: 5px; + margin-bottom: 10px; + font-size: .8rem; color: var(--text-secondary); } -.trans-control span { font-weight: 500; } +.trans-field span { font-weight: 500; } +.trans-delete-btn { width: 100%; margin-top: 4px; } +.trans-main { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} +.trans-workspace-header { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px 18px; + box-shadow: var(--shadow), var(--shadow-inset); +} +.trans-workspace-top { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px 20px; + margin-bottom: 14px; +} +.trans-workspace-title { + margin: 0; + font-size: 1.15rem; + font-weight: 600; + color: var(--text); +} +.trans-workspace-subtitle { + margin: 4px 0 0; + font-size: .86rem; + color: var(--text-secondary); +} +.trans-workspace-subtitle strong { color: var(--text); } +.trans-warn { color: var(--trans-missing-accent); font-weight: 600; } +.trans-workspace-status { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} +.trans-workspace-progress { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} +.trans-workspace-progress[hidden] { display: none !important; } +.trans-progress-track { + flex: 1; + max-width: 280px; + height: 8px; + background: var(--progress-track); + border-radius: 999px; + overflow: hidden; +} +.trans-progress-text { + font-size: .82rem; + color: var(--text-secondary); + white-space: nowrap; +} +.trans-progress-pct { + margin-left: 6px; + color: var(--success); + font-weight: 600; +} +.trans-workspace-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} +.trans-filter-pills { + display: inline-flex; + padding: 3px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 999px; + gap: 2px; +} +.trans-pill { + border: none; + background: transparent; + padding: 6px 12px; + border-radius: 999px; + font-size: .8rem; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: background .15s, color .15s; +} +.trans-pill:hover { color: var(--text); } +.trans-pill.is-active { + background: var(--surface); + color: var(--primary); + box-shadow: 0 1px 2px rgba(0,0,0,.06); +} +.trans-qn-availability { + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 12px 16px; +} +.trans-qn-availability-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; +} +.trans-qn-availability-label { + font-size: .82rem; + font-weight: 600; + color: var(--text-secondary); +} +.trans-qn-availability-toggles { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.trans-qn-pill { + display: inline-flex; + align-items: center; + cursor: pointer; + user-select: none; +} +.trans-qn-pill input { position: absolute; opacity: 0; pointer-events: none; } +.trans-qn-pill span { + display: inline-block; + padding: 5px 10px; + border-radius: 999px; + font-size: .75rem; + font-weight: 700; + letter-spacing: .03em; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-secondary); + transition: background .15s, border-color .15s, color .15s; +} +.trans-qn-pill.is-on span, +.trans-qn-pill input:checked + span { + background: var(--primary-subtle); + border-color: var(--primary-border); + color: var(--primary); +} +.trans-qn-pill:not(.is-on) span { opacity: .55; text-decoration: line-through; } +.trans-list-area { min-height: 200px; } +.trans-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 48px 24px; + border: 1px dashed var(--border); + border-radius: var(--radius-lg); + background: var(--surface-muted); +} +.trans-empty-title { + margin: 0 0 8px; + font-size: 1rem; + font-weight: 600; + color: var(--text); +} +.trans-empty-sidebar { + margin: 0; + padding: 12px; + font-size: .82rem; + color: var(--text-secondary); + text-align: center; +} +.spinner-sm { + width: 22px; + height: 22px; + margin: 12px auto; + border-width: 2px; +} .trans-select { padding: 8px 12px; border: 1px solid var(--border); - border-radius: 6px; - font-size: .9rem; + border-radius: 8px; + font-size: .88rem; font-family: var(--font); background: var(--surface); } @@ -2146,23 +2602,22 @@ body.modal-open { border-color: var(--primary); box-shadow: 0 0 0 3px var(--focus-ring); } -.trans-type-select { width: auto; min-width: 140px; flex-shrink: 0; } -.trans-lang-panel { - margin-bottom: 16px; - padding: 12px 14px; - background: var(--surface-muted); +.trans-type-select { min-width: 140px; flex-shrink: 0; } +.trans-search-input { + flex: 1; + min-width: 160px; + padding: 8px 12px; border: 1px solid var(--border); - border-radius: var(--radius-lg); + border-radius: 8px; + font-size: .88rem; + font-family: var(--font); + background: var(--surface); } -.trans-lang-panel summary { - cursor: pointer; - font-weight: 600; - font-size: .9rem; - color: var(--text); - user-select: none; +.trans-search-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--focus-ring); } -.trans-lang-panel[open] summary { margin-bottom: 12px; } -.trans-lang-panel .lang-chips { margin-bottom: 10px; } .trans-hint { padding: 24px 0; text-align: center; font-size: .9rem; } .text-muted { color: var(--text-secondary); } .required-mark { color: var(--danger); font-weight: 600; } @@ -2172,8 +2627,9 @@ body.modal-open { background: var(--surface); display: flex; flex-direction: column; - max-height: calc(100vh - 420px); + max-height: calc(100vh - 320px); overflow: hidden; + box-shadow: var(--shadow), var(--shadow-inset); } .trans-sheet-body { overflow-y: auto; @@ -2205,43 +2661,12 @@ body.modal-open { z-index: 2; box-shadow: 0 1px 0 var(--border); } -.trans-top-bar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px 20px; - margin-bottom: 12px; - padding: 12px 14px; - background: var(--surface-muted); - border: 1px solid var(--border); - border-radius: var(--radius-lg); -} -.trans-top-stats { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px 20px; - font-size: .88rem; - color: var(--text-secondary); -} -.trans-top-actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; -} .trans-save-status { font-size: .82rem; color: var(--text-secondary); } .trans-save-status[data-state="saving"] { color: var(--primary); } .trans-save-status[data-state="error"] { color: var(--danger); } -.trans-toolbar-field { - min-width: 180px; - max-width: 280px; - margin: 0; -} .trans-empty-hint { margin-top: 12px; } .trans-qn-block { margin-bottom: 28px; @@ -2256,9 +2681,7 @@ body.modal-open { color: var(--text); margin: 0 0 14px; } -.trans-group { - margin-bottom: 18px; -} +.trans-group { margin-bottom: 18px; } .trans-group-hidden { display: none !important; } .trans-group-title { font-size: .8rem; @@ -2298,9 +2721,7 @@ body.modal-open { content: '▸ '; color: var(--text-secondary); } -.trans-group-details[open] > .trans-group-summary::before { - content: '▾ '; -} +.trans-group-details[open] > .trans-group-summary::before { content: '▾ '; } .trans-qn-section { border: none; border-top: 1px solid var(--border); @@ -2325,9 +2746,7 @@ body.modal-open { content: '▸ '; color: var(--text-secondary); } -.trans-qn-section[open] > .trans-qn-summary::before { - content: '▾ '; -} +.trans-qn-section[open] > .trans-qn-summary::before { content: '▾ '; } .trans-sheet-body-stack .trans-group-summary { font-size: .78rem; padding: 8px 14px 8px 22px; @@ -2372,9 +2791,7 @@ body.modal-open { .trans-sheet-body > .trans-groups-flat .trans-group-details:last-child .trans-list-item:last-child { border-bottom: none; } -.trans-list-item:nth-child(even) { - background: var(--table-stripe-bg); -} +.trans-list-item:nth-child(even) { background: var(--table-stripe-bg); } .trans-list-item:hover { background: var(--row-hover-bg); } .trans-list-item-missing { background: var(--trans-missing-bg); @@ -2394,9 +2811,7 @@ body.modal-open { color: var(--text-secondary); word-break: break-word; } -.trans-list-source { - min-width: 0; -} +.trans-list-source { min-width: 0; } .trans-source-text { display: block; font-size: .9rem; @@ -2424,6 +2839,19 @@ body.modal-open { font-style: normal; } +@media (max-width: 1024px) { + .trans-layout { + grid-template-columns: 1fr; + } + .trans-sidebar { + position: static; + max-height: none; + } + .trans-sheet { + max-height: calc(100vh - 380px); + } +} + @media (max-width: 768px) { .trans-list-header, .trans-list-item { @@ -2449,12 +2877,13 @@ body.modal-open { color: var(--text-secondary); margin-bottom: 2px; } - .trans-list-item .trans-cell-input[data-field="translation"] { - margin-top: 4px; - } + .trans-list-item .trans-cell-input[data-field="translation"] { margin-top: 4px; } + .trans-workspace-filters { flex-direction: column; align-items: stretch; } + .trans-filter-pills { align-self: flex-start; } + .trans-add-lang-fields { grid-template-columns: 1fr; } } -/* ── Language manager ── */ +/* ── Language manager (editor) ── */ .lang-manager { background: var(--surface-muted); border: 1px solid var(--border); @@ -2490,6 +2919,16 @@ body.modal-open { line-height: 1; } .lang-chip-remove:hover { color: var(--danger); } +.lang-chip-disabled { opacity: .72; } +.lang-chip-badge { + font-size: .7rem; + text-transform: uppercase; + letter-spacing: .03em; + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: 999px; + padding: 1px 6px; +} .lang-add-row { display: flex; gap: 8px; @@ -2510,35 +2949,7 @@ body.modal-open { font-size: .85rem; } -/* ── Translations toolbar ── */ -.trans-toolbar { - display: flex; - gap: 16px; - align-items: center; - margin-bottom: 14px; - flex-wrap: wrap; -} -.trans-search-wrap { flex: 1; min-width: 200px; } -.trans-search-input { - width: 100%; - padding: 8px 12px; - border: 1px solid var(--border); - border-radius: 6px; - font-size: .9rem; - font-family: var(--font); -} -.trans-search-input:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 3px var(--focus-ring); -} -.trans-filter-types { - display: flex; - gap: 12px; - flex-shrink: 0; -} - -/* ── Translations table ── */ +/* ── Translations table (legacy/editor) ── */ .trans-table-wrapper { overflow-x: auto; border: 1px solid var(--border); @@ -2643,27 +3054,6 @@ body.modal-open { animation: saveFlash .4s ease; } -/* ── Stats bar ── */ -.trans-stats { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 12px; - font-size: .82rem; - color: var(--text-secondary); -} -.trans-stats-progress { - display: flex; - align-items: center; - gap: 8px; -} -.trans-progress-bar { - width: 120px; - height: 6px; - background: var(--progress-track); - border-radius: 3px; - overflow: hidden; -} .trans-progress-fill { display: block; height: 100%; @@ -3087,6 +3477,15 @@ body.logged-in .insights-chart-card.card:hover { color: var(--text); } +.table-row-actions .reset-client-btn { + color: #b45309; + border-color: color-mix(in srgb, #b45309 35%, transparent); +} + +.table-row-actions .reset-client-btn:hover { + background: color-mix(in srgb, #b45309 10%, transparent); +} + .table-row-actions .delete-client-btn { padding-left: 12px; padding-right: 12px; diff --git a/website/js/client-archive.js b/website/js/client-archive.js index 35bbd66..6a0fa25 100644 --- a/website/js/client-archive.js +++ b/website/js/client-archive.js @@ -34,15 +34,19 @@ export async function confirmAndPatchClientArchive(clientCode, archived) { * @param {string} opts.clientCode Escaped client code for data-code attributes * @param {boolean} opts.archived * @param {boolean} [opts.showDelete] + * @param {boolean} [opts.showReset] * @param {string} [opts.rawCode] Unescaped code for button labels (optional) */ -export function clientTableActionsHTML({ clientCode, archived, showDelete = false }) { +export function clientTableActionsHTML({ clientCode, archived, showDelete = false, showReset = false }) { const archiveBtn = archived ? `` : ``; + const resetBtn = showReset + ? `` + : ''; const deleteBtn = showDelete ? `` : ''; - return `
${archiveBtn}${deleteBtn}
`; + return `
${archiveBtn}${resetBtn}${deleteBtn}
`; } diff --git a/website/js/datetime.js b/website/js/datetime.js new file mode 100644 index 0000000..3bfa991 --- /dev/null +++ b/website/js/datetime.js @@ -0,0 +1,31 @@ +/** App timezone for display (Germany: CET/CEST). */ +export const APP_TIMEZONE = 'Europe/Berlin'; + +/** YYYY-MM-DD in app timezone (date inputs, log filters, filename stamps). */ +export function berlinDateInputValue(date = new Date()) { + return new Intl.DateTimeFormat('en-CA', { timeZone: APP_TIMEZONE }).format(date); +} + +/** YYYY-MM-DD HH:mm:ss in app timezone. */ +export function formatBerlinDateTime(value) { + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) return ''; + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: APP_TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(d); + const get = (type) => parts.find((p) => p.type === type)?.value ?? ''; + return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`; +} + +/** YYYY-MM-DD HH:mm in app timezone. */ +export function formatBerlinDateTimeShort(value) { + const full = formatBerlinDateTime(value); + return full ? full.slice(0, 16) : ''; +} diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js index d58813c..98f9701 100644 --- a/website/js/pages/clients.js +++ b/website/js/pages/clients.js @@ -197,6 +197,9 @@ function renderClientTableBody() { body.querySelectorAll('.delete-client-btn').forEach(btn => { btn.addEventListener('click', () => deleteClient(btn.dataset.code)); }); + body.querySelectorAll('.reset-client-btn').forEach(btn => { + btn.addEventListener('click', () => resetClient(btn.dataset.code)); + }); body.querySelectorAll('.archive-client-btn').forEach(btn => { btn.addEventListener('click', () => setClientArchived(btn.dataset.code, true)); }); @@ -413,6 +416,9 @@ function profileScoreBlockHTML(p, clientCode = '') { Counselor ${coachControl} ${coachDiffers ? 'override' : ''} + ${coachDiffers && p.coachReviewedByUsername + ? `by ${esc(p.coachReviewedByUsername)}${p.coachReviewedAt ? ` · ${esc(p.coachReviewedAt)}` : ''}` + : ''} `; } @@ -431,7 +437,10 @@ function renderClientScoringProfiles(profiles, clientCode = '') { const coachPending = p.pendingReview !== false && !p.coachBand; const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : ''; const reviewedAt = p.coachReviewedAt ? `Counselor reviewed ${esc(p.coachReviewedAt)}` : ''; - const meta = [computedAt, reviewedAt].filter(Boolean).join(' · '); + const overrideMeta = p.coachOverride && p.coachReviewedByUsername + ? `Overridden by ${esc(p.coachReviewedByUsername)}${p.coachReviewedAt ? ` · ${esc(p.coachReviewedAt)}` : ''}` + : ''; + const meta = [computedAt, reviewedAt, overrideMeta].filter(Boolean).join(' · '); return `
${profileScoreBlockHTML(p, clientCode)} @@ -732,6 +741,7 @@ function clientRowHTML(c) { clientCode: esc(c.clientCode), archived, showDelete: true, + showReset: clientHasResponseData(c), }); return ` @@ -818,6 +828,44 @@ async function setClientArchived(clientCode, archived) { else renderClientTableBody(); } +async function resetClient(clientCode) { + if (!(await confirmAction({ + title: 'Reset client', + message: `Reset client "${clientCode}"? This clears all questionnaire answers, upload history, and scoring results. The client code and counselor assignment are kept.`, + confirmLabel: 'Reset', + variant: 'danger', + }))) return; + try { + await apiPost('clients.php', { action: 'reset', clientCode }); + const row = clientsList.find(c => c.clientCode === clientCode); + if (row) { + row.hasResponseData = 0; + row.scoringProfiles = (row.scoringProfiles || []).map(p => ({ + ...p, + band: null, + calculatedBand: null, + coachBand: null, + effectiveBand: null, + pendingReview: false, + coachOverride: false, + coachReviewedAt: '', + coachReviewedByUsername: '', + weightedTotal: null, + })); + } + delete detailByClient[clientCode]; + if (expandedClientCode === clientCode) { + expandedClientCode = null; + expandedQnKey = null; + expandedVersionKey = null; + } + showToast(`Client "${clientCode}" reset`, 'success'); + renderClientTableBody(); + } catch (e) { + showToast(e.message, 'error'); + } +} + async function deleteClient(clientCode) { if (!(await confirmAction({ title: 'Delete client', diff --git a/website/js/pages/dev.js b/website/js/pages/dev.js index a29efea..76dddf6 100644 --- a/website/js/pages/dev.js +++ b/website/js/pages/dev.js @@ -2,6 +2,7 @@ import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin, apiUrl } fr import { getRole, pageHeaderHTML, showToast } from '../app.js'; import { confirmAction } from '../confirm-modal.js'; import { navigate } from '../router.js'; +import { berlinDateInputValue, formatBerlinDateTime } from '../datetime.js'; const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS'; @@ -20,7 +21,7 @@ export function devPage() { } const app = document.getElementById('app'); - const today = new Date().toISOString().slice(0, 10); + const today = berlinDateInputValue(); app.innerHTML = ` ${pageHeaderHTML('Admin Settings')}
@@ -115,7 +116,7 @@ export function devPage() { - + @@ -326,7 +327,7 @@ async function loadActivityLog() { const meta = document.getElementById('activityLogMeta'); if (!tbody) return; - const date = document.getElementById('activityLogDate')?.value || new Date().toISOString().slice(0, 10); + const date = document.getElementById('activityLogDate')?.value || berlinDateInputValue(); const activity = document.getElementById('activityLogKind')?.value || ''; tbody.innerHTML = ''; @@ -450,13 +451,8 @@ function formatActivityChangeValue(value) { function formatActivityTime(iso) { if (!iso) return '—'; - try { - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return iso; - return d.toISOString().replace('T', ' ').slice(0, 19); - } catch { - return iso; - } + const formatted = formatBerlinDateTime(iso); + return formatted || iso; } function escHtml(s) { diff --git a/website/js/pages/export.js b/website/js/pages/export.js index 314f8d9..d9f6533 100644 --- a/website/js/pages/export.js +++ b/website/js/pages/export.js @@ -1,5 +1,6 @@ import { apiGet, apiDownloadFetch, apiUrl } from '../api.js'; import { canEdit, pageHeaderHTML, showToast } from '../app.js'; +import { berlinDateInputValue } from '../datetime.js'; let questionnairesList = []; let filterSearch = ''; @@ -162,7 +163,7 @@ function wireExportActions() { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - const stamp = new Date().toISOString().slice(0, 10); + const stamp = berlinDateInputValue(); a.download = `questionnaires_bundle_${stamp}.json`; a.click(); URL.revokeObjectURL(url); @@ -215,7 +216,7 @@ async function onExportAllVersionsClick(ev) { btn.textContent = 'Downloading…'; try { const url = apiUrl(`export?questionnaireID=${encodeURIComponent(btn.dataset.id)}&allVersions=1`); - const stamp = new Date().toISOString().slice(0, 10); + const stamp = berlinDateInputValue(); await downloadExportCsv(btn, url, `${btn.dataset.name}_all_versions_${stamp}.csv`); showToast('All versions download started', 'success'); } catch (e) { diff --git a/website/js/pages/results.js b/website/js/pages/results.js index 5674a34..d5a4e7f 100644 --- a/website/js/pages/results.js +++ b/website/js/pages/results.js @@ -1,6 +1,7 @@ import { apiGet } from '../api.js'; import { homeNavButton, showToast } from '../app.js'; import { buildResultColumns, resultColumnCellValue } from '../test-data.js'; +import { formatBerlinDateTimeShort } from '../datetime.js'; const PAGE_SIZE = 50; @@ -9,6 +10,7 @@ let sortDir = 'asc'; let allClients = []; let questionsDef = []; let resultColumns = []; +let scoringProfileCatalog = []; let questionnaireMeta = null; let filterCoach = ''; let filterSupervisor = ''; @@ -52,6 +54,7 @@ export async function resultsPage(params) { const data = await apiGet(`results.php?questionnaireID=${encodeURIComponent(params.id)}`); questionnaireMeta = data.questionnaire; questionsDef = data.questions || []; + scoringProfileCatalog = data.scoringProfiles || []; allClients = data.clients || []; document.getElementById('resultsTitle').textContent = `Results: ${questionnaireMeta.name} v${questionnaireMeta.version}`; renderResults(); @@ -181,6 +184,40 @@ function renderResults() { renderTableRows(); } +function scoringTableColumns() { + const cols = []; + for (const profile of scoringProfileCatalog) { + const name = profile.name || profile.profileID; + cols.push( + { key: `score_${profile.profileID}_calc`, label: `${name} calculated`, profileID: profile.profileID, field: 'calculated', title: `${name} — server-calculated category` }, + { key: `score_${profile.profileID}_coach`, label: `${name} counselor`, profileID: profile.profileID, field: 'counselor', title: `${name} — counselor category` }, + { key: `score_${profile.profileID}_by`, label: `${name} override by`, profileID: profile.profileID, field: 'override_by', title: `${name} — who overrode the calculated category` }, + { key: `score_${profile.profileID}_at`, label: `${name} override at`, profileID: profile.profileID, field: 'override_at', title: `${name} — when the override was set` }, + ); + } + return cols; +} + +function scoringCellValue(client, profileID, field) { + const p = (client.scoringProfiles || []).find(x => x.profileID === profileID); + if (!p) return ''; + const calc = p.calculatedBand || p.band || ''; + const coach = p.coachBand || ''; + const differs = Boolean(p.coachOverride) || (coach && coach !== calc); + switch (field) { + case 'calculated': + return calc; + case 'counselor': + return coach; + case 'override_by': + return differs ? (p.coachReviewedByUsername || '') : ''; + case 'override_at': + return differs ? (p.coachReviewedAt || '') : ''; + default: + return ''; + } +} + function renderTableHead() { const head = document.getElementById('resultsHead'); const fixedCols = [ @@ -191,7 +228,8 @@ function renderTableHead() { { key: 'sumPoints', label: 'Score' }, { key: 'completedAt', label: 'Completed' }, ]; - const allCols = [...fixedCols, ...resultColumns.map(col => ({ + const scoringCols = scoringTableColumns(); + const allCols = [...fixedCols, ...scoringCols, ...resultColumns.map(col => ({ key: col.key, label: col.label, title: col.title, @@ -381,6 +419,11 @@ function renderTableRows() { return ``; }).join(''); + const scoringCells = scoringTableColumns().map(col => { + const val = scoringCellValue(c, col.profileID, col.field); + return ``; + }).join(''); + return ` @@ -388,6 +431,7 @@ function renderTableRows() { + ${scoringCells} ${questionCells} `; }).join(''); @@ -400,6 +444,10 @@ function getCellValue(client, key) { if (key === 'status') return client.status; if (key === 'sumPoints') return client.sumPoints; if (key === 'completedAt') return client.completedAt; + const scoringCol = scoringTableColumns().find(c => c.key === key); + if (scoringCol) { + return scoringCellValue(client, scoringCol.profileID, scoringCol.field); + } const col = resultColumns.find(c => c.key === key); if (col) { const ans = client.answers && client.answers[col.questionID]; @@ -424,19 +472,23 @@ function exportCSV() { }); const headers = ['clientCode', 'Counselor', 'supervisor', 'status', 'sumPoints', 'completedAt', + ...scoringTableColumns().map(col => col.label), ...resultColumns.map(col => col.label)]; const rows = clients.map(c => { const base = [ c.clientCode, coachDisplayName(c), c.supervisorUsername || '', c.status, c.sumPoints ?? '', - c.completedAt ? new Date(c.completedAt * 1000).toISOString() : '' + c.completedAt ? formatBerlinDateTimeShort(c.completedAt * 1000) : '' ]; + const scoringCols = scoringTableColumns().map(col => + scoringCellValue(c, col.profileID, col.field) + ); const answerCols = resultColumns.map(col => { const ans = c.answers && c.answers[col.questionID]; const val = resultColumnCellValue(col, ans, optionMap); return val != null ? val : ''; }); - return [...base, ...answerCols]; + return [...base, ...scoringCols, ...answerCols]; }); let csv = '\uFEFF'; // BOM diff --git a/website/js/pages/translations.js b/website/js/pages/translations.js index 1103a5e..8ac379d 100644 --- a/website/js/pages/translations.js +++ b/website/js/pages/translations.js @@ -6,6 +6,7 @@ import { normalizeEntry, translationFor, targetLanguages, + isLanguageGloballyEnabled, sourceLanguageLabel, translationListHeaderHTML, buildTranslationGroups, @@ -32,15 +33,19 @@ let savesInFlight = 0; export async function translationsPage() { const app = document.getElementById('app'); app.innerHTML = ` - ${pageHeaderHTML('Translations', '')} + ${pageHeaderHTML( + 'Translations', + '', + { subtitle: 'Manage languages, availability, and translated text for the coach app.' }, + )}
`; if (canEdit()) { document.getElementById('transHeaderActions').innerHTML = ` - - + + `; bindTranslationBundleActions(); } @@ -56,47 +61,49 @@ export async function translationsPage() { function renderShell() { document.getElementById('transPageRoot').innerHTML = ` -
-
- -
+
+ -
+
+
+ +
+
+
+
`; } @@ -105,72 +112,72 @@ function pickDefaultTargetLang(languages) { const targets = targetLanguages(languages); if (!targets.length) return ''; const stored = localStorage.getItem(STORAGE_LANG); - if (stored && targets.some(l => l.languageCode === stored)) { - return stored; - } - if (selectedLang && targets.some(l => l.languageCode === selectedLang)) { - return selectedLang; - } + if (stored && targets.some(l => l.languageCode === stored)) return stored; + if (selectedLang && targets.some(l => l.languageCode === selectedLang)) return selectedLang; return targets[0].languageCode; } function pickDefaultShowMode() { const stored = localStorage.getItem(STORAGE_SHOW); - if (stored === 'missing' || stored === 'all' || stored === 'complete') { - return stored; - } + if (stored === 'missing' || stored === 'all' || stored === 'complete') return stored; return 'missing'; } function pickDefaultScope(sections, entries, lang) { const stored = localStorage.getItem(STORAGE_SCOPE); - if (stored === '__all__' || sections.some(s => s.questionnaireID === stored)) { - return stored; - } + if (stored === '__all__' || sections.some(s => s.questionnaireID === stored)) return stored; for (const sec of sections) { - const slice = sectionEntries(entries, sec); - if (countMissing(slice, lang) > 0) { - return sec.questionnaireID; - } + if (countMissing(sectionEntries(entries, sec), lang) > 0) return sec.questionnaireID; } return sections[0]?.questionnaireID || '__app__'; } function scopeLabel(sec, entries, lang) { - const slice = sectionEntries(entries, sec); - const missing = countMissing(slice, lang); - const suffix = missing ? ` (${missing} missing)` : ' (complete)'; - return `${sec.name}${suffix}`; + const missing = countMissing(sectionEntries(entries, sec), lang); + return { name: sec.name, missing, complete: missing === 0 }; } function scopedSections() { const sections = transData?.sections || []; - if (!selectedScope || selectedScope === '__all__') { - return sections; - } + if (!selectedScope || selectedScope === '__all__') return sections; return sections.filter(s => s.questionnaireID === selectedScope); } function entriesInScope() { const sections = scopedSections(); const out = []; - for (const sec of sections) { - out.push(...sectionEntries(allEntries, sec)); - } + for (const sec of sections) out.push(...sectionEntries(allEntries, sec)); return out; } +function currentScopeMeta() { + const sections = transData?.sections || []; + if (selectedScope === '__all__') return { id: '__all__', name: 'All content', isApp: false, isQuestionnaire: false }; + const sec = sections.find(s => s.questionnaireID === selectedScope); + if (!sec) return { id: selectedScope, name: 'Content', isApp: false, isQuestionnaire: false }; + return { + id: sec.questionnaireID, + name: sec.name, + isApp: !!sec.isApp, + isQuestionnaire: !sec.isApp && sec.questionnaireID !== '__app__', + }; +} + +function currentLangMeta() { + const languages = transData?.languages || []; + const row = languages.find(l => l.languageCode === selectedLang); + return { + code: selectedLang, + name: row?.name || selectedLang.toUpperCase(), + enabled: row ? isLanguageGloballyEnabled(row) : true, + }; +} + function setSaveStatus(state) { const el = document.getElementById('transSaveStatus'); if (!el) return; el.dataset.state = state; - if (state === 'saving') { - el.textContent = 'Saving…'; - } else if (state === 'error') { - el.textContent = 'Save failed'; - } else { - el.textContent = 'All changes saved'; - } + el.textContent = state === 'saving' ? 'Saving…' : state === 'error' ? 'Save failed' : 'All changes saved'; } async function ensureGermanLanguage() { @@ -181,10 +188,6 @@ async function ensureGermanLanguage() { async function loadTranslations() { const listArea = document.getElementById('transListArea'); - const langSelect = document.getElementById('transLangSelect'); - const filter = document.getElementById('transFilter'); - const typeFilter = document.getElementById('transTypeFilter'); - if (listArea) listArea.innerHTML = '
'; saveTimers = {}; @@ -203,93 +206,137 @@ async function loadTranslations() { showMode = pickDefaultShowMode(); selectedScope = pickDefaultScope(flat.sections, allEntries, selectedLang); - if (langSelect) { - const targets = targetLanguages(languages); - langSelect.disabled = !targets.length; - langSelect.innerHTML = targets.length - ? targets.map(l => { - const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode; - return ``; - }).join('') - : ''; - langSelect.onchange = () => { - selectedLang = langSelect.value; - localStorage.setItem(STORAGE_LANG, selectedLang); - populateScopeSelect(); - renderEntryList(); - }; - } - - const scopeSelect = document.getElementById('transScopeSelect'); - const showSelect = document.getElementById('transShowMode'); - if (scopeSelect) { - scopeSelect.disabled = false; - populateScopeSelect(); - scopeSelect.onchange = () => { - selectedScope = scopeSelect.value; - localStorage.setItem(STORAGE_SCOPE, selectedScope); - renderEntryList(); - }; - } - if (showSelect) { - showSelect.disabled = false; - showSelect.value = showMode; - showSelect.onchange = () => { - showMode = showSelect.value; - localStorage.setItem(STORAGE_SHOW, showMode); - applyFilters(); - }; - } - - if (filter) filter.disabled = false; - if (typeFilter) typeFilter.disabled = false; - - renderLanguagePanel(languages); + renderLanguageSidebar(languages); + renderContentNav(); + renderDeleteForm(); + renderWorkspaceHeader(); + renderQnAvailabilityStrip(); renderEntryList(); bindFilters(); setSaveStatus('saved'); } catch (e) { - if (listArea) listArea.innerHTML = `

${esc(e.message)}

`; + if (listArea) listArea.innerHTML = `

${esc(e.message)}

`; showToast(e.message, 'error'); } } -function renderLanguagePanel(languages) { - const panel = document.getElementById('transLangPanel'); - if (!panel) return; +function renderLanguageSidebar(languages) { + const list = document.getElementById('transLangSidebar'); + const addBox = document.getElementById('transAddLang'); + if (!list || !addBox) return; const others = targetLanguages(languages); + const source = languages.find(l => l.languageCode === SOURCE_LANG); - panel.innerHTML = ` -

- German source text is edited in the questionnaire editor. Add other languages here. -

-
- - DE - German (source) + list.innerHTML = ` +
+ DE + + ${esc(source?.name || 'German')} + Source language - ${others.map(l => ` - - ${esc(l.languageCode.toUpperCase())} - ${l.name ? `${esc(l.name)}` : ''} - - - `).join('')} -
-
- - - + Source
+ ${others.length ? others.map(lang => { + const selected = lang.languageCode === selectedLang; + const enabled = isLanguageGloballyEnabled(lang); + const missing = countMissing(allEntries, lang.languageCode); + return ` + ` : ''} +
+ `; + }).join('') : ` +

No target languages yet. Add one below to start translating.

+ `} `; + addBox.innerHTML = canEdit() ? ` +
+
+ + +
+ +
+ ` : ''; + + list.querySelectorAll('.trans-lang-item[data-lc]').forEach(btn => { + btn.addEventListener('click', e => { + if (e.target.closest('[data-stop-select]')) return; + selectedLang = btn.dataset.lc; + localStorage.setItem(STORAGE_LANG, selectedLang); + renderLanguageSidebar(languages); + renderContentNav(); + renderWorkspaceHeader(); + renderQnAvailabilityStrip(); + renderEntryList(); + }); + }); + + list.querySelectorAll('.trans-global-lang-toggle').forEach(input => { + input.addEventListener('change', async e => { + e.stopPropagation(); + const lc = input.dataset.lc; + const enabled = input.checked; + input.disabled = true; + try { + await apiPut('translations.php', { type: 'language_enabled', languageCode: lc, enabled }); + const lang = languages.find(l => l.languageCode === lc); + if (lang) lang.enabled = enabled ? 1 : 0; + showToast(enabled ? `${lc} enabled in app` : `${lc} hidden from app`, 'success'); + renderLanguageSidebar(languages); + renderQnAvailabilityStrip(); + } catch (err) { + input.checked = !enabled; + showToast(err.message, 'error'); + } finally { + input.disabled = false; + } + }); + }); + + list.querySelectorAll('.trans-lang-remove').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + const lc = btn.dataset.lc; + if (!(await confirmAction({ + title: 'Remove language', + message: `Remove "${lc}" and delete all of its translations?`, + confirmLabel: 'Remove', + variant: 'danger', + }))) return; + try { + await apiDelete('translations.php', { type: 'language', languageCode: lc }); + showToast(`Language "${lc}" removed`, 'success'); + if (selectedLang === lc) selectedLang = ''; + await loadTranslations(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + }); + document.getElementById('addLangBtn')?.addEventListener('click', async () => { const code = document.getElementById('newLangCode').value.trim().toLowerCase(); const name = document.getElementById('newLangName').value.trim(); if (!code) { showToast('Language code is required', 'error'); return; } if (code === SOURCE_LANG) { - showToast('German (de) is always available as the source language', 'error'); + showToast('German (de) is always the source language', 'error'); return; } if (languages.some(l => l.languageCode === code)) { @@ -301,48 +348,285 @@ function renderLanguagePanel(languages) { showToast(`Language "${code}" added`, 'success'); selectedLang = code; await loadTranslations(); - } catch (e) { - showToast(e.message, 'error'); + } catch (err) { + showToast(err.message, 'error'); } }); +} - panel.querySelectorAll('.lang-chip-remove').forEach(btn => { - btn.addEventListener('click', async () => { - const lc = btn.dataset.lc; - if (!(await confirmAction({ - title: 'Remove language', - message: `Remove "${lc}" and all its translations?`, - confirmLabel: 'Remove', - variant: 'danger', - }))) return; +function renderContentNav() { + const nav = document.getElementById('transContentNav'); + if (!nav || !transData) return; + + const sections = transData.sections || []; + const items = sections.map(sec => { + const { name, missing, complete } = scopeLabel(sec, allEntries, selectedLang); + const active = sec.questionnaireID === selectedScope; + const badge = missing + ? `${missing}` + : ``; + const icon = sec.isApp ? '◇' : '▢'; + return ` + `; + }); + + items.push(` + + `); + + nav.innerHTML = items.join(''); + + nav.querySelectorAll('.trans-content-item').forEach(btn => { + btn.addEventListener('click', () => { + selectedScope = btn.dataset.scope; + localStorage.setItem(STORAGE_SCOPE, selectedScope); + renderContentNav(); + renderWorkspaceHeader(); + renderQnAvailabilityStrip(); + renderDeleteForm(); + renderEntryList(); + }); + }); +} + +function questionnaireLanguageDisabled(questionnaireID, languageCode) { + return (transData?.questionnaireLanguageDisables || []).some( + row => row.questionnaireID === questionnaireID && row.languageCode === languageCode + ); +} + +function setQuestionnaireLanguageDisabled(questionnaireID, languageCode, disabled) { + if (!transData) return; + const rows = transData.questionnaireLanguageDisables || []; + const idx = rows.findIndex( + row => row.questionnaireID === questionnaireID && row.languageCode === languageCode + ); + if (disabled) { + if (idx < 0) rows.push({ questionnaireID, languageCode }); + } else if (idx >= 0) { + rows.splice(idx, 1); + } + transData.questionnaireLanguageDisables = rows; +} + +function renderQnAvailabilityStrip() { + const strip = document.getElementById('transQnAvailability'); + if (!strip || !transData) return; + + const scope = currentScopeMeta(); + const languages = targetLanguages(transData.languages || []).filter(isLanguageGloballyEnabled); + + if (!scope.isQuestionnaire || !languages.length) { + strip.hidden = true; + strip.innerHTML = ''; + return; + } + + strip.hidden = false; + strip.innerHTML = ` +
+ Available in app for this questionnaire +
+ ${languages.map(lang => { + const on = !questionnaireLanguageDisabled(scope.id, lang.languageCode); + return ` + `; + }).join('')} +
+
+ `; + + strip.querySelectorAll('.trans-qn-lang-toggle').forEach(input => { + input.addEventListener('change', async () => { + const qid = input.dataset.qid; + const lc = input.dataset.lc; + const enabled = input.checked; + input.disabled = true; try { - await apiDelete('translations.php', { type: 'language', languageCode: lc }); - showToast(`Language "${lc}" removed`, 'success'); - if (selectedLang === lc) selectedLang = ''; - await loadTranslations(); - } catch (e) { - showToast(e.message, 'error'); + await apiPut('translations.php', { + type: 'questionnaire_language', + questionnaireID: qid, + languageCode: lc, + enabled, + }); + setQuestionnaireLanguageDisabled(qid, lc, !enabled); + renderQnAvailabilityStrip(); + showToast( + enabled ? `${lc} enabled for this questionnaire` : `${lc} hidden for this questionnaire`, + 'success' + ); + } catch (err) { + input.checked = !enabled; + showToast(err.message, 'error'); + } finally { + input.disabled = false; } }); }); } -function populateScopeSelect() { - const scopeSelect = document.getElementById('transScopeSelect'); - if (!scopeSelect || !transData) return; - - const sections = transData.sections || []; - const options = sections.map(sec => - `` - ); - options.push(``); - - scopeSelect.innerHTML = options.join(''); - if (!selectedScope || (selectedScope !== '__all__' && !sections.some(s => s.questionnaireID === selectedScope))) { - selectedScope = pickDefaultScope(sections, allEntries, selectedLang); - scopeSelect.value = selectedScope; - localStorage.setItem(STORAGE_SCOPE, selectedScope); +function renderDeleteForm() { + const form = document.getElementById('transDeleteForm'); + if (!form || !transData || !canEdit()) { + if (form) form.innerHTML = ''; + return; } + + const languages = targetLanguages(transData.languages || []); + const sections = (transData.sections || []).filter(sec => sec.questionnaireID !== '__app__'); + const defaultScope = selectedScope && selectedScope !== '__all__' ? selectedScope : '__app__'; + + form.innerHTML = ` + + + + `; + + document.getElementById('transDeleteBtn')?.addEventListener('click', onDeleteTranslations); +} + +async function onDeleteTranslations() { + const scopeVal = document.getElementById('transDeleteScope')?.value || '__app__'; + const lang = document.getElementById('transDeleteLang')?.value || ''; + const scope = scopeVal === '__app__' ? 'app' : (scopeVal === '__all__' ? 'all' : 'questionnaire'); + const sections = (transData?.sections || []).filter(sec => sec.questionnaireID !== '__app__'); + const scopeLabel = scopeVal === '__app__' + ? 'App UI strings' + : (scopeVal === '__all__' ? 'all content' : (sections.find(s => s.questionnaireID === scopeVal)?.name || 'this questionnaire')); + const langLabel = lang ? lang.toUpperCase() : 'all target languages'; + + if (!(await confirmAction({ + title: 'Delete translations', + message: `Delete ${langLabel} translations for ${scopeLabel}? This cannot be undone.`, + confirmLabel: 'Delete', + variant: 'danger', + }))) return; + + const btn = document.getElementById('transDeleteBtn'); + btn.disabled = true; + try { + const body = { action: 'deleteTranslationsScope', scope }; + if (scope === 'questionnaire') body.questionnaireID = scopeVal; + if (lang) body.languageCode = lang; + const result = await apiPost('translations.php', body); + showToast(`Deleted ${result.deleted ?? 0} translation row(s)`, 'success'); + await loadTranslations(); + } catch (e) { + showToast(e.message, 'error'); + } finally { + btn.disabled = false; + } +} + +function renderWorkspaceHeader() { + const header = document.getElementById('transWorkspaceHeader'); + if (!header || !transData) return; + + const scope = currentScopeMeta(); + const lang = currentLangMeta(); + const scopeEntries = entriesInScope(); + const missingCount = selectedLang ? countMissing(scopeEntries, selectedLang) : 0; + const pct = scopeEntries.length && selectedLang + ? Math.round(((scopeEntries.length - missingCount) / scopeEntries.length) * 100) + : 0; + + header.innerHTML = ` +
+
+

${esc(scope.name)}

+

+ ${selectedLang + ? `Translating into ${esc(lang.name)} (${esc(lang.code)})` + : 'Add a target language to begin'} + ${!lang.enabled && selectedLang ? ' · Hidden from app' : ''} +

+
+
+ All changes saved + +
+
+ +
+
+
+
+ + ${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} strings + ${pct}% complete + +
+ +
+
+ + + +
+ + +
+ `; + + header.querySelectorAll('.trans-pill').forEach(btn => { + btn.addEventListener('click', () => { + showMode = btn.dataset.show; + localStorage.setItem(STORAGE_SHOW, showMode); + header.querySelectorAll('.trans-pill').forEach(p => p.classList.toggle('is-active', p.dataset.show === showMode)); + applyFilters(); + }); + }); + + document.getElementById('transJumpMissingBtn')?.addEventListener('click', jumpToNextMissing); + + filtersBound = false; + bindFilters(); } function renderEntryList() { @@ -355,30 +639,33 @@ function renderEntryList() { if (!targets.length) { listArea.innerHTML = ` -

Add at least one language under More languages to translate into.

`; +
+

Add a language to get started

+

Use the form in the sidebar to add English, French, or any other target language.

+
`; return; } if (!selectedLang) { - listArea.innerHTML = `

Choose a language above.

`; + listArea.innerHTML = ` +
+

Select a target language

+

Click a language in the sidebar to begin translating.

+
`; return; } if (!transData.sections?.length) { listArea.innerHTML = ` -

No translatable content yet. Add questionnaires and questions in the editor first.

`; +
+

No translatable content

+

Create questionnaires in the editor first. German source text is edited there.

+
`; return; } const sourceLabel = sourceLanguageLabel(languages); const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase(); - const scopeEntries = entriesInScope(); - const missingCount = countMissing(scopeEntries, selectedLang); - const pct = scopeEntries.length - ? Math.round(((scopeEntries.length - missingCount) / scopeEntries.length) * 100) - : 0; - - const headerOnce = translationListHeaderHTML(langLabel, sourceLabel); const useQnDetails = selectedScope === '__all__'; const sectionsHtml = sections.map(sec => { @@ -393,9 +680,7 @@ function renderEntryList() { const open = secMissing > 0 && sections.filter(s => countMissing(sectionEntries(allEntries, s), selectedLang) > 0 )[0]?.questionnaireID === sec.questionnaireID; - const summary = secMissing - ? `${sec.name} · ${secMissing} missing` - : `${sec.name} · complete`; + const summary = secMissing ? `${sec.name} · ${secMissing} missing` : `${sec.name} · complete`; return `
${esc(summary)} @@ -407,27 +692,12 @@ function renderEntryList() { }).join(''); listArea.innerHTML = ` -
-
- ${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} in scope - - - ${pct}% in ${esc(selectedLang.toUpperCase())} - -
-
- All changes saved - -
-
- ${headerOnce} + ${translationListHeaderHTML(langLabel, sourceLabel)}
${sectionsHtml}
`; - document.getElementById('transJumpMissingBtn')?.addEventListener('click', jumpToNextMissing); - bindEntryEditing(allEntries); applyFilters(); } @@ -458,8 +728,7 @@ function jumpToNextMissing() { } const target = rows[start] || rows[0]; openAncestors(target); - const inp = target.querySelector('[data-field="translation"]'); - inp?.focus({ preventScroll: false }); + target.querySelector('[data-field="translation"]')?.focus({ preventScroll: false }); target.scrollIntoView({ behavior: 'smooth', block: 'center' }); } @@ -518,7 +787,8 @@ async function saveTranslation(entry, inp, isGerman = false) { savesInFlight = Math.max(0, savesInFlight - 1); if (savesInFlight === 0) { setSaveStatus('saved'); - populateScopeSelect(); + renderContentNav(); + renderLanguageSidebar(transData?.languages || []); } } } @@ -531,8 +801,10 @@ function updateProgress() { : 0; const fill = document.querySelector('.trans-progress-fill'); if (fill) fill.style.width = `${pct}%`; - const label = document.querySelector('.trans-progress-label'); - if (label) label.textContent = `${pct}% in ${selectedLang.toUpperCase()}`; + const track = document.querySelector('.trans-progress-track'); + if (track) track.setAttribute('aria-valuenow', String(pct)); + const pctEl = document.querySelector('.trans-progress-pct'); + if (pctEl) pctEl.textContent = `${pct}% complete`; if (!document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) { updateVisibleCountSummary(); } @@ -544,14 +816,20 @@ function updateVisibleCountSummary(visible = null, visibleMissing = null) { const scopeEntries = entriesInScope(); const text = document.getElementById('transFilter')?.value || ''; const type = document.getElementById('transTypeFilter')?.value || ''; + const missing = countMissing(scopeEntries, selectedLang); + const pct = scopeEntries.length + ? Math.round(((scopeEntries.length - missing) / scopeEntries.length) * 100) + : 0; + if (text || type || showMode !== 'missing') { if (visible != null) { - countEl.textContent = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''}`; + countEl.innerHTML = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''} + ${pct}% complete`; } return; } - const missing = countMissing(scopeEntries, selectedLang); - countEl.textContent = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} in scope`; + countEl.innerHTML = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} strings + ${pct}% complete`; } let filtersBound = false; @@ -562,7 +840,6 @@ function bindFilters() { return; } filtersBound = true; - document.getElementById('transFilter')?.addEventListener('input', applyFilters); document.getElementById('transTypeFilter')?.addEventListener('change', applyFilters); } @@ -590,13 +867,12 @@ function applyFilters() { row.classList.toggle('trans-row-hidden', !show); if (show) { visible++; - if (row.dataset.missing === '1') visibleMissing++; + if (isMissing) visibleMissing++; } }); document.querySelectorAll('#transGroupedRoot .trans-group-details').forEach(group => { - const rows = group.querySelectorAll('.trans-list-item'); - const anyVisible = [...rows].some(r => !r.classList.contains('trans-row-hidden')); + const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden')); group.classList.toggle('trans-group-hidden', !anyVisible); }); @@ -643,8 +919,7 @@ function bindTranslationBundleActions() { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - const stamp = new Date().toISOString().slice(0, 10); - a.download = `translations_bundle_${stamp}.json`; + a.download = `translations_bundle_${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); showToast('Translations bundle downloaded', 'success'); @@ -676,10 +951,9 @@ function bindTranslationBundleActions() { return; } - const count = bundle.entries.length; if (!(await confirmAction({ title: 'Import translations', - message: `Import translations for ${count} entries from this file?`, + message: `Import translations for ${bundle.entries.length} entries from this file?`, confirmLabel: 'Import', }))) return; @@ -687,10 +961,7 @@ function bindTranslationBundleActions() { const prev = importBtn.textContent; importBtn.textContent = 'Importing…'; try { - const result = await apiPost('translations.php', { - action: 'importBundle', - bundle, - }); + const result = await apiPost('translations.php', { action: 'importBundle', bundle }); const imported = result.imported ?? 0; const skipped = result.skipped ?? 0; let msg = `Imported ${imported} translation(s)`; diff --git a/website/js/pages/users.js b/website/js/pages/users.js index 9f8a537..1251a7d 100644 --- a/website/js/pages/users.js +++ b/website/js/pages/users.js @@ -2,6 +2,7 @@ import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js'; import { confirmAction } from '../confirm-modal.js'; import { openAdminResetPasswordModal } from '../password-modal.js'; +import { berlinDateInputValue, formatBerlinDateTimeShort } from '../datetime.js'; // Roles an admin can create; supervisors can only create coaches const CREATEABLE_ROLES = { @@ -529,7 +530,7 @@ function downloadUsersCSV() { u.role === 'coach' ? '' : (u.location || ''), u.role === 'coach' ? (u.supervisorUsername || u.supervisorID || '') : '', u.mustChangePassword == 1 ? 'yes' : 'no', - u.createdAt ? new Date(parseInt(u.createdAt, 10) * 1000).toISOString() : '', + u.createdAt ? formatBerlinDateTimeShort(parseInt(u.createdAt, 10) * 1000) : '', u.userID, ]); @@ -541,7 +542,7 @@ function downloadUsersCSV() { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `users_${new Date().toISOString().slice(0, 10)}.csv`; + a.download = `users_${berlinDateInputValue()}.csv`; a.click(); URL.revokeObjectURL(url); showToast('Users exported', 'success'); diff --git a/website/js/test-data.js b/website/js/test-data.js index d7a7111..7201f3b 100644 --- a/website/js/test-data.js +++ b/website/js/test-data.js @@ -1,4 +1,13 @@ const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/; +const RESULTS_EXCLUDED_KEYS = new Set(['data_final_warning']); +const RESULTS_EXCLUDED_TYPES = new Set(['last_page']); + +/** Questions hidden from results tables and CSV exports (e.g. last-page warnings). */ +export function isResultsExcludedQuestion(q) { + if (!q) return false; + if (RESULTS_EXCLUDED_TYPES.has(q.type)) return true; + return RESULTS_EXCLUDED_KEYS.has(questionDisplayKey(q)); +} /** Local question id from full questionID (hash__q5 → q5). */ export function questionLocalKey(questionID, fallback = '') { @@ -62,6 +71,7 @@ export function glassSymptomAnswerValue(raw, symptomKey) { export function buildResultColumns(questions) { const cols = []; for (const q of questions || []) { + if (isResultsExcludedQuestion(q)) continue; const cfg = parseQuestionConfig(q); const symptoms = cfg.symptoms; if (q.type === 'glass_scale_question' && Array.isArray(symptoms) && symptoms.length > 0) { diff --git a/website/js/translations-helpers.js b/website/js/translations-helpers.js index f97c860..ac7fed3 100644 --- a/website/js/translations-helpers.js +++ b/website/js/translations-helpers.js @@ -69,6 +69,16 @@ export function targetLanguages(languages) { return (languages || []).filter(l => l.languageCode !== SOURCE_LANG); } +export function isLanguageGloballyEnabled(lang) { + if (!lang) return false; + if (lang.languageCode === SOURCE_LANG) return true; + return Number(lang.enabled ?? 1) === 1; +} + +export function globallyEnabledLanguages(languages) { + return (languages || []).filter(isLanguageGloballyEnabled); +} + /** Sort rows within a group: missing translations first, then questionnaire order */ export function sortGroupItems(items, targetLang) { return [...items].sort((a, b) => {
Time (UTC)Time Kind Subject Method
Loading…
${esc(String(val))}${val ? esc(String(val)) : '—'}
${esc(c.clientCode)} ${esc(coachDisplayName(c))}${statusBadge} ${c.sumPoints !== null ? c.sumPoints : '—'} ${completedDate}