changes to translation system
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-07-09 14:59:56 +02:00
parent 4afab336ee
commit f04388e0ec
24 changed files with 1882 additions and 415 deletions

View File

@ -4,6 +4,13 @@
require_once __DIR__ . '/lib/text_sanitize.php'; 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). * Values loaded from the project .env file (see qdb_load_dotenv).
* *
@ -374,11 +381,273 @@ function rbac_client_filter(
define('QDB_SOURCE_LANGUAGE', 'de'); define('QDB_SOURCE_LANGUAGE', 'de');
function qdb_ensure_source_language(PDO $pdo): void { 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) $pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
ON CONFLICT(languageCode) DO NOTHING") ON CONFLICT(languageCode) DO NOTHING")
->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']); ->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']);
} }
/** @return list<array{languageCode:string,name:string,enabled:int}> */
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<array{questionnaireID:string,languageCode:string}> */
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<string> */
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<string>
*/
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 { function qdb_upsert_source_translation(PDO $pdo, string $type, string $id, string $text): void {
qdb_put_translation($pdo, $type, $id, QDB_SOURCE_LANGUAGE, $text); 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; 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. */ /** Resolve app lookup key for a question row. */
function qdb_question_key(array $config, string $defaultText): string { function qdb_question_key(array $config, string $defaultText): string {
$key = trim((string)($config['questionKey'] ?? '')); $key = trim((string)($config['questionKey'] ?? ''));
@ -1078,6 +1362,9 @@ function qdb_results_export_columns(array $questions, string $qnID): array
{ {
$columns = []; $columns = [];
foreach ($questions as $q) { foreach ($questions as $q) {
if (qdb_question_is_results_excluded($q)) {
continue;
}
$cfg = json_decode($q['configJson'] ?? '{}', true) ?: []; $cfg = json_decode($q['configJson'] ?? '{}', true) ?: [];
$type = $q['type'] ?? ''; $type = $q['type'] ?? '';
if ($type === 'glass_scale_question') { if ($type === 'glass_scale_question') {

View File

@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) {
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
} }
define('QDB_SCHEMA', __DIR__ . '/schema.sql'); define('QDB_SCHEMA', __DIR__ . '/schema.sql');
define('QDB_VERSION', 13); define('QDB_VERSION', 14);
function qdb_table_exists(PDO $pdo, string $table): bool { function qdb_table_exists(PDO $pdo, string $table): bool {
$stmt = $pdo->prepare( $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(); $currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) { if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) {
$pdo->exec( $pdo->exec(

View File

@ -19,7 +19,10 @@
if ($method === 'GET' && !empty($_GET['translations'])) { if ($method === 'GET' && !empty($_GET['translations'])) {
$opened = qdb_open_read_or_fail(); $opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened; [$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); qdb_discard($tmpDb, $lockFp);
return; return;
} }
@ -588,10 +591,11 @@ if ($qnID) {
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success([ json_success([
'meta' => ['id' => $qnID], 'meta' => ['id' => $qnID],
'structureRevision' => $structureRevision, 'structureRevision' => $structureRevision,
'questions' => $questions, 'questions' => $questions,
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID), 'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
'availableLanguages' => qdb_available_language_codes($pdo, $qnID),
]); ]);
} }

View File

@ -62,7 +62,45 @@ case 'GET':
break; break;
case 'POST': 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'] ?? ''); $coachID = trim($body['coachID'] ?? '');
$bulk = !empty($body['bulk']); $bulk = !empty($body['bulk']);

View File

@ -85,7 +85,7 @@ if ($allVersions) {
$filename = $safeName . '_all_versions_' . date('Y-m-d') . '.csv'; $filename = $safeName . '_all_versions_' . date('Y-m-d') . '.csv';
qdb_http_download( 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-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"', 'Content-Disposition' => 'attachment; filename="' . $filename . '"',
@ -100,7 +100,7 @@ $safeName = qdb_export_safe_basename($questionnaire['name']);
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv'; $filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
qdb_http_download( 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-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"', 'Content-Disposition' => 'attachment; filename="' . $filename . '"',

View File

@ -1,5 +1,7 @@
<?php <?php
require_once __DIR__ . '/../lib/submissions.php';
$tokenRec = require_valid_token_web(); $tokenRec = require_valid_token_web();
if ($method !== 'GET') { if ($method !== 'GET') {
@ -112,12 +114,19 @@ if (!empty($questionIDs) && !empty($clients)) {
unset($c); unset($c);
} }
$scoringSummary = qdb_clients_scoring_summary_for_list($pdo, $tokenRec);
foreach ($clients as &$c) {
$c['scoringProfiles'] = qdb_client_scoring_dots_for_client($c['clientCode'], $scoringSummary);
}
unset($c);
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success([ json_success([
'questionnaire' => $questionnaire, 'questionnaire' => $questionnaire,
'questions' => $questions, 'questions' => $questions,
'clients' => $clients, 'clients' => $clients,
'scoringProfiles' => $scoringSummary['profiles'],
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Load results', null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Load results', null, $tmpDb ?? null, $lockFp ?? null);

View File

@ -2,7 +2,7 @@
$tokenRec = require_valid_token_web(); $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) { switch ($method) {
@ -32,9 +32,13 @@ case 'GET':
try { try {
$opened = qdb_open_read_or_fail(); $opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened; [$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); qdb_discard($tmpDb, $lockFp);
json_success(['languages' => $rows]); json_success([
'languages' => $rows,
'questionnaireLanguageDisables' => $disables,
]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Load languages', null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Load languages', null, $tmpDb ?? null, $lockFp ?? null);
} }
@ -45,9 +49,9 @@ case 'GET':
$opened = qdb_open_read_or_fail(); $opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened; [$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)) { 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(" $qnRows = $pdo->query("
@ -82,11 +86,12 @@ case 'GET':
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success([ json_success([
'languages' => $languages, 'languages' => $languages,
'appStrings' => $appEntries, 'appStrings' => $appEntries,
'questionnaires' => $questionnaires, 'questionnaires' => $questionnaires,
'entries' => $allEntries, 'entries' => $allEntries,
'sourceLanguage' => QDB_SOURCE_LANGUAGE, 'sourceLanguage' => QDB_SOURCE_LANGUAGE,
'questionnaireLanguageDisables' => qdb_questionnaire_language_disables($pdo),
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
@ -99,9 +104,9 @@ case 'GET':
$opened = qdb_open_read_or_fail(); $opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened; [$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)) { 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'); $qnRow = $pdo->prepare('SELECT name FROM questionnaire WHERE questionnaireID = :id');
@ -119,10 +124,11 @@ case 'GET':
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);
json_success([ json_success([
'languages' => $languages, 'languages' => $languages,
'entries' => $entries, 'entries' => $entries,
'questionnaire' => ['questionnaireID' => $qnID, 'name' => (string)$qnName], 'questionnaire' => ['questionnaireID' => $qnID, 'name' => (string)$qnName],
'sourceLanguage' => QDB_SOURCE_LANGUAGE, 'sourceLanguage' => QDB_SOURCE_LANGUAGE,
'questionnaireLanguageDisables' => qdb_questionnaire_language_disables($pdo),
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null); qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
@ -185,6 +191,28 @@ case 'POST':
} }
break; 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); json_error('BAD_REQUEST', 'Unknown action', 400);
break; break;
@ -203,6 +231,10 @@ case 'PUT':
try { try {
if ($lang === QDB_SOURCE_LANGUAGE) { if ($lang === QDB_SOURCE_LANGUAGE) {
qdb_ensure_source_language($pdo); 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 { } else {
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n) $pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name") ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name")
@ -216,6 +248,47 @@ case 'PUT':
break; 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'] ?? ''; $id = $body['id'] ?? '';
$lang = trim($body['languageCode'] ?? ''); $lang = trim($body['languageCode'] ?? '');
$text = $body['text'] ?? ''; $text = $body['text'] ?? '';

View File

@ -281,7 +281,7 @@ function qdb_api_log_finish(): void
} }
$entry = [ $entry = [
'ts' => gmdate('c'), 'ts' => date('c'),
'activity' => $activity, 'activity' => $activity,
'method' => $ctx['method'] ?? '', 'method' => $ctx['method'] ?? '',
'route' => $ctx['route'] ?? '', 'route' => $ctx['route'] ?? '',

View File

@ -835,3 +835,109 @@ function qdb_set_coach_scoring_band(
$row['profileID'] = $profileID; $row['profileID'] = $profileID;
return qdb_scoring_review_row_to_api($row); return qdb_scoring_review_row_to_api($row);
} }
/**
* @return list<array{profileID: string, name: string}>
*/
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<array{profileID: string, name: string}> $profiles
* @return list<array{profileID: string, field: string, header: string}>
*/
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<array{profileID: string, field: string, header: string}> $columnDefs
* @param array<string, array<string, mixed>> $byProfile profileID => result row
* @return array<string, string>
*/
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<string> $clientCodes
* @return array<string, array<string, array<string, mixed>>>
*/
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<string>
*/
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');
}

View File

@ -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; $questions[] = $qRow;
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: []; $cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
@ -293,7 +296,10 @@ function qdb_export_all_versions_rows(
array $optionTextMap, array $optionTextMap,
array $tokenRec array $tokenRec
): array { ): array {
require_once __DIR__ . '/scoring.php';
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$scoringProfiles = qdb_active_scoring_profiles($pdo);
$scoringColumnDefs = qdb_scoring_export_column_defs($scoringProfiles);
$sql = " $sql = "
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole, SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole,
@ -315,6 +321,9 @@ function qdb_export_all_versions_rows(
$stmt->execute($params); $stmt->execute($params);
$submissions = $stmt->fetchAll(PDO::FETCH_ASSOC); $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'); $defaultQuestionIDs = array_column($questions, 'questionID');
$userMap = []; $userMap = [];
@ -353,6 +362,14 @@ function qdb_export_all_versions_rows(
'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '', '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( $answerMap = qdb_load_client_answer_map(
$pdo, $pdo,
$s['clientCode'], $s['clientCode'],
@ -413,8 +430,11 @@ function qdb_export_current_rows(
array $optionTextMap, array $optionTextMap,
array $tokenRec array $tokenRec
): array { ): array {
require_once __DIR__ . '/scoring.php';
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$questionIDs = array_column($questions, 'questionID'); $questionIDs = array_column($questions, 'questionID');
$scoringProfiles = qdb_active_scoring_profiles($pdo);
$scoringColumnDefs = qdb_scoring_export_column_defs($scoringProfiles);
$sql = " $sql = "
SELECT cl.clientCode, cl.coachID, SELECT cl.clientCode, cl.coachID,
@ -433,6 +453,9 @@ function qdb_export_current_rows(
$cStmt->execute($params); $cStmt->execute($params);
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC); $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__'"; $qPlaceholders = !empty($questionIDs) ? implode(',', array_fill(0, count($questionIDs), '?')) : "'__none__'";
$answerStmt = $pdo->prepare(" $answerStmt = $pdo->prepare("
SELECT questionID, answerOptionID, freeTextValue, numericValue 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']) : '', '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); $bindParams = array_merge([$c['clientCode']], $questionIDs);
$answerStmt->execute($bindParams); $answerStmt->execute($bindParams);
$answerMap = []; $answerMap = [];
@ -514,19 +545,23 @@ function qdb_export_rows_to_csv_string(array $rows, array $fallbackHeader = []):
return $csv !== false ? $csv : ''; 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 = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt'];
$header = array_merge($header, qdb_scoring_export_csv_headers($pdo));
foreach ($resultColumns as $col) { foreach ($resultColumns as $col) {
$header[] = $col['header']; $header[] = $col['header'];
} }
return $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 = [ $header = [
'submissionID', 'version', 'submittedAt', 'submittedByRole', 'submittedBy', 'submissionID', 'version', 'submittedAt', 'submittedByRole', 'submittedBy',
'clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt', 'clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt',
]; ];
$header = array_merge($header, qdb_scoring_export_csv_headers($pdo));
foreach ($resultColumns as $col) { foreach ($resultColumns as $col) {
$header[] = $col['header']; $header[] = $col['header'];
} }
@ -566,11 +601,11 @@ function qdb_build_server_export_zip(PDO $pdo, array $tokenRec, bool $allVersion
if ($allVersions) { if ($allVersions) {
$rows = qdb_export_all_versions_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec); $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'; $base = qdb_export_safe_basename($qn['name']) . '_all_versions.csv';
} else { } else {
$rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec); $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'] ?? '')); $ver = preg_replace('/[^a-zA-Z0-9_.-]+/', '_', (string)($qn['version'] ?? ''));
$base = qdb_export_safe_basename($qn['name']) . ($ver !== '' ? "_v{$ver}" : '') . '.csv'; $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). * Delete uploads, answers, and completions for client codes (does not delete client rows).
* *
* @param list<string> $clientCodes * @param list<string> $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 { function qdb_delete_client_response_data(PDO $pdo, array $clientCodes): array {
$clientCodes = array_values(array_unique(array_filter( $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, 'followup_notes' => 0,
'client_answers' => 0, 'client_answers' => 0,
'completed_questionnaires' => 0, 'completed_questionnaires' => 0,
'scoring_results' => 0,
]; ];
if ($clientCodes === []) { if ($clientCodes === []) {
return $deleted; 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 = $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode IN ($ph)");
$stmt->execute($chunk); $stmt->execute($chunk);
$deleted['completed_questionnaires'] += $stmt->rowCount(); $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; return $deleted;
@ -893,9 +934,11 @@ function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'SELECT r.profileID, r.weightedTotal, r.band, r.computedAt, r.questionnaireSnapshot, 'SELECT r.profileID, r.weightedTotal, r.band, r.computedAt, r.questionnaireSnapshot,
r.coachBand, r.coachReviewedAt, r.coachReviewedByUserID, r.coachBand, r.coachReviewedAt, r.coachReviewedByUserID,
u.username AS coachReviewedByUsername,
sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
FROM client_scoring_profile_result r FROM client_scoring_profile_result r
JOIN scoring_profile sp ON sp.profileID = r.profileID JOIN scoring_profile sp ON sp.profileID = r.profileID
LEFT JOIN users u ON u.userID = r.coachReviewedByUserID
WHERE r.clientCode = :cc WHERE r.clientCode = :cc
ORDER BY sp.name' 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) { foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$bands = qdb_normalize_scoring_bands($row); $bands = qdb_normalize_scoring_bands($row);
$coachBand = trim((string)($row['coachBand'] ?? '')); $coachBand = trim((string)($row['coachBand'] ?? ''));
$calcBand = (string)$row['band'];
$coachDiffers = $coachBand !== '' && $coachBand !== $calcBand;
$out[] = [ $out[] = [
'profileID' => $row['profileID'], 'profileID' => $row['profileID'],
'name' => $row['name'], 'name' => $row['name'],
@ -913,6 +958,7 @@ function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array
'coachBand' => $coachBand !== '' ? $coachBand : null, 'coachBand' => $coachBand !== '' ? $coachBand : null,
'effectiveBand' => qdb_effective_scoring_band($row), 'effectiveBand' => qdb_effective_scoring_band($row),
'pendingReview' => $coachBand === '', 'pendingReview' => $coachBand === '',
'coachOverride' => $coachDiffers,
'greenMin' => $bands['greenMin'], 'greenMin' => $bands['greenMin'],
'greenMax' => $bands['greenMax'], 'greenMax' => $bands['greenMax'],
'yellowMin' => $bands['yellowMin'], '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']) : '', 'computedAt' => $row['computedAt'] ? date('Y-m-d H:i', (int)$row['computedAt']) : '',
'coachReviewedAt' => !empty($row['coachReviewedAt']) 'coachReviewedAt' => !empty($row['coachReviewedAt'])
? date('Y-m-d H:i', (int)$row['coachReviewedAt']) : '', ? date('Y-m-d H:i', (int)$row['coachReviewedAt']) : '',
'coachReviewedByUsername' => $coachDiffers
? (string)($row['coachReviewedByUsername'] ?? '') : '',
'snapshot' => json_decode($row['questionnaireSnapshot'] ?? '{}', true) ?: [], '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( $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 FROM client_scoring_profile_result r
INNER JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1 INNER JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
INNER JOIN client cl ON cl.clientCode = r.clientCode INNER JOIN client cl ON cl.clientCode = r.clientCode
LEFT JOIN users u ON u.userID = r.coachReviewedByUserID
WHERE ($rbacClause)" WHERE ($rbacClause)"
); );
$stmt->execute($rbacParams); $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) { foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$cc = (string)$row['clientCode']; $cc = (string)$row['clientCode'];
$coachBand = trim((string)($row['coachBand'] ?? '')); $coachBand = trim((string)($row['coachBand'] ?? ''));
$calcBand = (string)$row['band'];
$coachDiffers = $coachBand !== '' && $coachBand !== $calcBand;
$byClient[$cc][$row['profileID']] = [ $byClient[$cc][$row['profileID']] = [
'band' => (string)$row['band'], 'band' => $calcBand,
'calculatedBand' => (string)$row['band'], 'calculatedBand' => $calcBand,
'coachBand' => $coachBand !== '' ? $coachBand : null, 'coachBand' => $coachBand !== '' ? $coachBand : null,
'effectiveBand' => qdb_effective_scoring_band($row), 'effectiveBand' => qdb_effective_scoring_band($row),
'pendingReview' => $coachBand === '', 'pendingReview' => $coachBand === '',
'coachOverride' => $coachDiffers,
'weightedTotal' => (float)$row['weightedTotal'], 'weightedTotal' => (float)$row['weightedTotal'],
'name' => (string)$row['name'], '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, 'coachBand' => $result['coachBand'] ?? null,
'effectiveBand' => $result['effectiveBand'] ?? null, 'effectiveBand' => $result['effectiveBand'] ?? null,
'pendingReview' => $result['pendingReview'] ?? false, 'pendingReview' => $result['pendingReview'] ?? false,
'coachOverride' => $result['coachOverride'] ?? false,
'coachReviewedAt' => $result['coachReviewedAt'] ?? '',
'coachReviewedByUsername' => $result['coachReviewedByUsername'] ?? '',
'weightedTotal' => $result !== null ? $result['weightedTotal'] : null, 'weightedTotal' => $result !== null ? $result['weightedTotal'] : null,
]; ];
} }

View File

@ -142,7 +142,16 @@ CREATE TABLE IF NOT EXISTS string_translation (
CREATE TABLE IF NOT EXISTS language ( CREATE TABLE IF NOT EXISTS language (
languageCode TEXT NOT NULL PRIMARY KEY, 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 ( CREATE TABLE IF NOT EXISTS session (

View File

@ -27,7 +27,7 @@ final class AssignmentsActivityLogTest extends QdbTestCase
DatabaseSeeder::ADMIN_USERNAME, DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD DatabaseSeeder::PASSWORD
)['token']; )['token'];
$today = gmdate('Y-m-d'); $today = date('Y-m-d');
$res = $this->api()->withToken($token, 'GET', 'activity-log', null, [ $res = $this->api()->withToken($token, 'GET', 'activity-log', null, [
'date' => $today, 'date' => $today,
'limit' => '50', 'limit' => '50',

View File

@ -91,6 +91,43 @@ final class ClientsLifecycleTest extends QdbTestCase
$this->assertContains($code, array_column($activeAgain['data']['clients'], 'clientCode')); $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 public function testAdminDeletesClient(): void
{ {
$token = $this->api()->loginWebAndGetToken( $token = $this->api()->loginWebAndGetToken(

View File

@ -300,8 +300,9 @@ body.logged-in .card > h3 {
body.logged-in :is( body.logged-in :is(
.question-item, .question-item,
.inline-form-card, .inline-form-card,
.trans-lang-panel, .trans-sidebar-section,
.trans-top-bar, .trans-workspace-header,
.trans-qn-availability,
.trans-sheet, .trans-sheet,
.lang-manager, .lang-manager,
.condition-editor, .condition-editor,
@ -335,6 +336,7 @@ body.logged-in :is(
.trans-select, .trans-select,
.lang-add-code, .lang-add-code,
.lang-add-name, .lang-add-name,
.trans-add-input,
.trans-search-input, .trans-search-input,
.followup-note-input, .followup-note-input,
.condition-json-fallback, .condition-json-fallback,
@ -2115,29 +2117,483 @@ body.modal-open {
} }
/* ── Translations page ── */ /* ── Translations page ── */
.trans-page { padding: 20px 24px; } .trans-layout {
.trans-controls { display: grid;
display: flex; grid-template-columns: minmax(260px, 300px) minmax(0, 1fr);
flex-wrap: wrap; gap: 20px;
gap: 16px 24px; align-items: start;
margin-bottom: 16px; 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
flex: 1; }
min-width: 200px; .trans-lang-item {
max-width: 360px; 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; 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); 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 { .trans-select {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 8px;
font-size: .9rem; font-size: .88rem;
font-family: var(--font); font-family: var(--font);
background: var(--surface); background: var(--surface);
} }
@ -2146,23 +2602,22 @@ body.modal-open {
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 3px var(--focus-ring); box-shadow: 0 0 0 3px var(--focus-ring);
} }
.trans-type-select { width: auto; min-width: 140px; flex-shrink: 0; } .trans-type-select { min-width: 140px; flex-shrink: 0; }
.trans-lang-panel { .trans-search-input {
margin-bottom: 16px; flex: 1;
padding: 12px 14px; min-width: 160px;
background: var(--surface-muted); padding: 8px 12px;
border: 1px solid var(--border); 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 { .trans-search-input:focus {
cursor: pointer; outline: none;
font-weight: 600; border-color: var(--primary);
font-size: .9rem; box-shadow: 0 0 0 3px var(--focus-ring);
color: var(--text);
user-select: none;
} }
.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; } .trans-hint { padding: 24px 0; text-align: center; font-size: .9rem; }
.text-muted { color: var(--text-secondary); } .text-muted { color: var(--text-secondary); }
.required-mark { color: var(--danger); font-weight: 600; } .required-mark { color: var(--danger); font-weight: 600; }
@ -2172,8 +2627,9 @@ body.modal-open {
background: var(--surface); background: var(--surface);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: calc(100vh - 420px); max-height: calc(100vh - 320px);
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow), var(--shadow-inset);
} }
.trans-sheet-body { .trans-sheet-body {
overflow-y: auto; overflow-y: auto;
@ -2205,43 +2661,12 @@ body.modal-open {
z-index: 2; z-index: 2;
box-shadow: 0 1px 0 var(--border); 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 { .trans-save-status {
font-size: .82rem; font-size: .82rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
.trans-save-status[data-state="saving"] { color: var(--primary); } .trans-save-status[data-state="saving"] { color: var(--primary); }
.trans-save-status[data-state="error"] { color: var(--danger); } .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-empty-hint { margin-top: 12px; }
.trans-qn-block { .trans-qn-block {
margin-bottom: 28px; margin-bottom: 28px;
@ -2256,9 +2681,7 @@ body.modal-open {
color: var(--text); color: var(--text);
margin: 0 0 14px; margin: 0 0 14px;
} }
.trans-group { .trans-group { margin-bottom: 18px; }
margin-bottom: 18px;
}
.trans-group-hidden { display: none !important; } .trans-group-hidden { display: none !important; }
.trans-group-title { .trans-group-title {
font-size: .8rem; font-size: .8rem;
@ -2298,9 +2721,7 @@ body.modal-open {
content: '▸ '; content: '▸ ';
color: var(--text-secondary); color: var(--text-secondary);
} }
.trans-group-details[open] > .trans-group-summary::before { .trans-group-details[open] > .trans-group-summary::before { content: '▾ '; }
content: '▾ ';
}
.trans-qn-section { .trans-qn-section {
border: none; border: none;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
@ -2325,9 +2746,7 @@ body.modal-open {
content: '▸ '; content: '▸ ';
color: var(--text-secondary); color: var(--text-secondary);
} }
.trans-qn-section[open] > .trans-qn-summary::before { .trans-qn-section[open] > .trans-qn-summary::before { content: '▾ '; }
content: '▾ ';
}
.trans-sheet-body-stack .trans-group-summary { .trans-sheet-body-stack .trans-group-summary {
font-size: .78rem; font-size: .78rem;
padding: 8px 14px 8px 22px; 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 { .trans-sheet-body > .trans-groups-flat .trans-group-details:last-child .trans-list-item:last-child {
border-bottom: none; border-bottom: none;
} }
.trans-list-item:nth-child(even) { .trans-list-item:nth-child(even) { background: var(--table-stripe-bg); }
background: var(--table-stripe-bg);
}
.trans-list-item:hover { background: var(--row-hover-bg); } .trans-list-item:hover { background: var(--row-hover-bg); }
.trans-list-item-missing { .trans-list-item-missing {
background: var(--trans-missing-bg); background: var(--trans-missing-bg);
@ -2394,9 +2811,7 @@ body.modal-open {
color: var(--text-secondary); color: var(--text-secondary);
word-break: break-word; word-break: break-word;
} }
.trans-list-source { .trans-list-source { min-width: 0; }
min-width: 0;
}
.trans-source-text { .trans-source-text {
display: block; display: block;
font-size: .9rem; font-size: .9rem;
@ -2424,6 +2839,19 @@ body.modal-open {
font-style: normal; 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) { @media (max-width: 768px) {
.trans-list-header, .trans-list-header,
.trans-list-item { .trans-list-item {
@ -2449,12 +2877,13 @@ body.modal-open {
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 2px; margin-bottom: 2px;
} }
.trans-list-item .trans-cell-input[data-field="translation"] { .trans-list-item .trans-cell-input[data-field="translation"] { margin-top: 4px; }
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 { .lang-manager {
background: var(--surface-muted); background: var(--surface-muted);
border: 1px solid var(--border); border: 1px solid var(--border);
@ -2490,6 +2919,16 @@ body.modal-open {
line-height: 1; line-height: 1;
} }
.lang-chip-remove:hover { color: var(--danger); } .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 { .lang-add-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
@ -2510,35 +2949,7 @@ body.modal-open {
font-size: .85rem; font-size: .85rem;
} }
/* ── Translations toolbar ── */ /* ── Translations table (legacy/editor) ── */
.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 ── */
.trans-table-wrapper { .trans-table-wrapper {
overflow-x: auto; overflow-x: auto;
border: 1px solid var(--border); border: 1px solid var(--border);
@ -2643,27 +3054,6 @@ body.modal-open {
animation: saveFlash .4s ease; 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 { .trans-progress-fill {
display: block; display: block;
height: 100%; height: 100%;
@ -3087,6 +3477,15 @@ body.logged-in .insights-chart-card.card:hover {
color: var(--text); 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 { .table-row-actions .delete-client-btn {
padding-left: 12px; padding-left: 12px;
padding-right: 12px; padding-right: 12px;

View File

@ -34,15 +34,19 @@ export async function confirmAndPatchClientArchive(clientCode, archived) {
* @param {string} opts.clientCode Escaped client code for data-code attributes * @param {string} opts.clientCode Escaped client code for data-code attributes
* @param {boolean} opts.archived * @param {boolean} opts.archived
* @param {boolean} [opts.showDelete] * @param {boolean} [opts.showDelete]
* @param {boolean} [opts.showReset]
* @param {string} [opts.rawCode] Unescaped code for button labels (optional) * @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 archiveBtn = archived
? `<button type="button" class="btn btn-sm restore-client-btn" data-code="${clientCode}" title="Restore to active lists">Restore</button>` ? `<button type="button" class="btn btn-sm restore-client-btn" data-code="${clientCode}" title="Restore to active lists">Restore</button>`
: `<button type="button" class="btn btn-sm archive-client-btn" data-code="${clientCode}" title="Mark as finished and hide from lists">Archive</button>`; : `<button type="button" class="btn btn-sm archive-client-btn" data-code="${clientCode}" title="Mark as finished and hide from lists">Archive</button>`;
const resetBtn = showReset
? `<button type="button" class="btn btn-sm reset-client-btn" data-code="${clientCode}" title="Clear answers, uploads, and scores; keep client code and counselor">Reset</button>`
: '';
const deleteBtn = showDelete const deleteBtn = showDelete
? `<button type="button" class="btn btn-sm btn-danger delete-client-btn" data-code="${clientCode}" title="Permanently delete client and all data">Delete</button>` ? `<button type="button" class="btn btn-sm btn-danger delete-client-btn" data-code="${clientCode}" title="Permanently delete client and all data">Delete</button>`
: ''; : '';
return `<div class="table-row-actions" role="group" aria-label="Client actions">${archiveBtn}${deleteBtn}</div>`; return `<div class="table-row-actions" role="group" aria-label="Client actions">${archiveBtn}${resetBtn}${deleteBtn}</div>`;
} }

31
website/js/datetime.js Normal file
View File

@ -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) : '';
}

View File

@ -197,6 +197,9 @@ function renderClientTableBody() {
body.querySelectorAll('.delete-client-btn').forEach(btn => { body.querySelectorAll('.delete-client-btn').forEach(btn => {
btn.addEventListener('click', () => deleteClient(btn.dataset.code)); btn.addEventListener('click', () => deleteClient(btn.dataset.code));
}); });
body.querySelectorAll('.reset-client-btn').forEach(btn => {
btn.addEventListener('click', () => resetClient(btn.dataset.code));
});
body.querySelectorAll('.archive-client-btn').forEach(btn => { body.querySelectorAll('.archive-client-btn').forEach(btn => {
btn.addEventListener('click', () => setClientArchived(btn.dataset.code, true)); btn.addEventListener('click', () => setClientArchived(btn.dataset.code, true));
}); });
@ -413,6 +416,9 @@ function profileScoreBlockHTML(p, clientCode = '') {
<span class="client-profile-score-label">Counselor</span> <span class="client-profile-score-label">Counselor</span>
${coachControl} ${coachControl}
${coachDiffers ? '<span class="client-profile-override-hint">override</span>' : ''} ${coachDiffers ? '<span class="client-profile-override-hint">override</span>' : ''}
${coachDiffers && p.coachReviewedByUsername
? `<span class="client-profile-override-meta">by ${esc(p.coachReviewedByUsername)}${p.coachReviewedAt ? ` · ${esc(p.coachReviewedAt)}` : ''}</span>`
: ''}
</div> </div>
</div>`; </div>`;
} }
@ -431,7 +437,10 @@ function renderClientScoringProfiles(profiles, clientCode = '') {
const coachPending = p.pendingReview !== false && !p.coachBand; const coachPending = p.pendingReview !== false && !p.coachBand;
const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : ''; const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : '';
const reviewedAt = p.coachReviewedAt ? `Counselor reviewed ${esc(p.coachReviewedAt)}` : ''; 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 ` return `
<div class="scoring-profile-detail-card"> <div class="scoring-profile-detail-card">
${profileScoreBlockHTML(p, clientCode)} ${profileScoreBlockHTML(p, clientCode)}
@ -732,6 +741,7 @@ function clientRowHTML(c) {
clientCode: esc(c.clientCode), clientCode: esc(c.clientCode),
archived, archived,
showDelete: true, showDelete: true,
showReset: clientHasResponseData(c),
}); });
return ` return `
@ -818,6 +828,44 @@ async function setClientArchived(clientCode, archived) {
else renderClientTableBody(); 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) { async function deleteClient(clientCode) {
if (!(await confirmAction({ if (!(await confirmAction({
title: 'Delete client', title: 'Delete client',

View File

@ -2,6 +2,7 @@ import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin, apiUrl } fr
import { getRole, pageHeaderHTML, showToast } from '../app.js'; import { getRole, pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js'; import { confirmAction } from '../confirm-modal.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
import { berlinDateInputValue, formatBerlinDateTime } from '../datetime.js';
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS'; const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
@ -20,7 +21,7 @@ export function devPage() {
} }
const app = document.getElementById('app'); const app = document.getElementById('app');
const today = new Date().toISOString().slice(0, 10); const today = berlinDateInputValue();
app.innerHTML = ` app.innerHTML = `
${pageHeaderHTML('Admin Settings')} ${pageHeaderHTML('Admin Settings')}
<div class="card security-settings-card" style="max-width:720px;margin-bottom:16px"> <div class="card security-settings-card" style="max-width:720px;margin-bottom:16px">
@ -115,7 +116,7 @@ export function devPage() {
<table class="data-table activity-log-table"> <table class="data-table activity-log-table">
<thead> <thead>
<tr> <tr>
<th>Time (UTC)</th> <th>Time</th>
<th>Kind</th> <th>Kind</th>
<th>Subject</th> <th>Subject</th>
<th>Method</th> <th>Method</th>
@ -326,7 +327,7 @@ async function loadActivityLog() {
const meta = document.getElementById('activityLogMeta'); const meta = document.getElementById('activityLogMeta');
if (!tbody) return; 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 || ''; const activity = document.getElementById('activityLogKind')?.value || '';
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:20px">Loading…</td></tr>'; tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:20px">Loading…</td></tr>';
@ -450,13 +451,8 @@ function formatActivityChangeValue(value) {
function formatActivityTime(iso) { function formatActivityTime(iso) {
if (!iso) return '—'; if (!iso) return '—';
try { const formatted = formatBerlinDateTime(iso);
const d = new Date(iso); return formatted || iso;
if (Number.isNaN(d.getTime())) return iso;
return d.toISOString().replace('T', ' ').slice(0, 19);
} catch {
return iso;
}
} }
function escHtml(s) { function escHtml(s) {

View File

@ -1,5 +1,6 @@
import { apiGet, apiDownloadFetch, apiUrl } from '../api.js'; import { apiGet, apiDownloadFetch, apiUrl } from '../api.js';
import { canEdit, pageHeaderHTML, showToast } from '../app.js'; import { canEdit, pageHeaderHTML, showToast } from '../app.js';
import { berlinDateInputValue } from '../datetime.js';
let questionnairesList = []; let questionnairesList = [];
let filterSearch = ''; let filterSearch = '';
@ -162,7 +163,7 @@ function wireExportActions() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
const stamp = new Date().toISOString().slice(0, 10); const stamp = berlinDateInputValue();
a.download = `questionnaires_bundle_${stamp}.json`; a.download = `questionnaires_bundle_${stamp}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
@ -215,7 +216,7 @@ async function onExportAllVersionsClick(ev) {
btn.textContent = 'Downloading…'; btn.textContent = 'Downloading…';
try { try {
const url = apiUrl(`export?questionnaireID=${encodeURIComponent(btn.dataset.id)}&allVersions=1`); 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`); await downloadExportCsv(btn, url, `${btn.dataset.name}_all_versions_${stamp}.csv`);
showToast('All versions download started', 'success'); showToast('All versions download started', 'success');
} catch (e) { } catch (e) {

View File

@ -1,6 +1,7 @@
import { apiGet } from '../api.js'; import { apiGet } from '../api.js';
import { homeNavButton, showToast } from '../app.js'; import { homeNavButton, showToast } from '../app.js';
import { buildResultColumns, resultColumnCellValue } from '../test-data.js'; import { buildResultColumns, resultColumnCellValue } from '../test-data.js';
import { formatBerlinDateTimeShort } from '../datetime.js';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@ -9,6 +10,7 @@ let sortDir = 'asc';
let allClients = []; let allClients = [];
let questionsDef = []; let questionsDef = [];
let resultColumns = []; let resultColumns = [];
let scoringProfileCatalog = [];
let questionnaireMeta = null; let questionnaireMeta = null;
let filterCoach = ''; let filterCoach = '';
let filterSupervisor = ''; let filterSupervisor = '';
@ -52,6 +54,7 @@ export async function resultsPage(params) {
const data = await apiGet(`results.php?questionnaireID=${encodeURIComponent(params.id)}`); const data = await apiGet(`results.php?questionnaireID=${encodeURIComponent(params.id)}`);
questionnaireMeta = data.questionnaire; questionnaireMeta = data.questionnaire;
questionsDef = data.questions || []; questionsDef = data.questions || [];
scoringProfileCatalog = data.scoringProfiles || [];
allClients = data.clients || []; allClients = data.clients || [];
document.getElementById('resultsTitle').textContent = `Results: ${questionnaireMeta.name} v${questionnaireMeta.version}`; document.getElementById('resultsTitle').textContent = `Results: ${questionnaireMeta.name} v${questionnaireMeta.version}`;
renderResults(); renderResults();
@ -181,6 +184,40 @@ function renderResults() {
renderTableRows(); 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() { function renderTableHead() {
const head = document.getElementById('resultsHead'); const head = document.getElementById('resultsHead');
const fixedCols = [ const fixedCols = [
@ -191,7 +228,8 @@ function renderTableHead() {
{ key: 'sumPoints', label: 'Score' }, { key: 'sumPoints', label: 'Score' },
{ key: 'completedAt', label: 'Completed' }, { key: 'completedAt', label: 'Completed' },
]; ];
const allCols = [...fixedCols, ...resultColumns.map(col => ({ const scoringCols = scoringTableColumns();
const allCols = [...fixedCols, ...scoringCols, ...resultColumns.map(col => ({
key: col.key, key: col.key,
label: col.label, label: col.label,
title: col.title, title: col.title,
@ -381,6 +419,11 @@ function renderTableRows() {
return `<td>${esc(String(val))}</td>`; return `<td>${esc(String(val))}</td>`;
}).join(''); }).join('');
const scoringCells = scoringTableColumns().map(col => {
const val = scoringCellValue(c, col.profileID, col.field);
return `<td>${val ? esc(String(val)) : '—'}</td>`;
}).join('');
return `<tr> return `<tr>
<td class="sticky-col">${esc(c.clientCode)}</td> <td class="sticky-col">${esc(c.clientCode)}</td>
<td>${esc(coachDisplayName(c))}</td> <td>${esc(coachDisplayName(c))}</td>
@ -388,6 +431,7 @@ function renderTableRows() {
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td>${c.sumPoints !== null ? c.sumPoints : '—'}</td> <td>${c.sumPoints !== null ? c.sumPoints : '—'}</td>
<td>${completedDate}</td> <td>${completedDate}</td>
${scoringCells}
${questionCells} ${questionCells}
</tr>`; </tr>`;
}).join(''); }).join('');
@ -400,6 +444,10 @@ function getCellValue(client, key) {
if (key === 'status') return client.status; if (key === 'status') return client.status;
if (key === 'sumPoints') return client.sumPoints; if (key === 'sumPoints') return client.sumPoints;
if (key === 'completedAt') return client.completedAt; 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); const col = resultColumns.find(c => c.key === key);
if (col) { if (col) {
const ans = client.answers && client.answers[col.questionID]; const ans = client.answers && client.answers[col.questionID];
@ -424,19 +472,23 @@ function exportCSV() {
}); });
const headers = ['clientCode', 'Counselor', 'supervisor', 'status', 'sumPoints', 'completedAt', const headers = ['clientCode', 'Counselor', 'supervisor', 'status', 'sumPoints', 'completedAt',
...scoringTableColumns().map(col => col.label),
...resultColumns.map(col => col.label)]; ...resultColumns.map(col => col.label)];
const rows = clients.map(c => { const rows = clients.map(c => {
const base = [ const base = [
c.clientCode, coachDisplayName(c), c.supervisorUsername || '', c.status, c.sumPoints ?? '', 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 answerCols = resultColumns.map(col => {
const ans = c.answers && c.answers[col.questionID]; const ans = c.answers && c.answers[col.questionID];
const val = resultColumnCellValue(col, ans, optionMap); const val = resultColumnCellValue(col, ans, optionMap);
return val != null ? val : ''; return val != null ? val : '';
}); });
return [...base, ...answerCols]; return [...base, ...scoringCols, ...answerCols];
}); });
let csv = '\uFEFF'; // BOM let csv = '\uFEFF'; // BOM

View File

@ -6,6 +6,7 @@ import {
normalizeEntry, normalizeEntry,
translationFor, translationFor,
targetLanguages, targetLanguages,
isLanguageGloballyEnabled,
sourceLanguageLabel, sourceLanguageLabel,
translationListHeaderHTML, translationListHeaderHTML,
buildTranslationGroups, buildTranslationGroups,
@ -32,15 +33,19 @@ let savesInFlight = 0;
export async function translationsPage() { export async function translationsPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
app.innerHTML = ` app.innerHTML = `
${pageHeaderHTML('Translations', '<span id="transHeaderActions"></span>')} ${pageHeaderHTML(
'Translations',
'<span id="transHeaderActions"></span>',
{ subtitle: 'Manage languages, availability, and translated text for the coach app.' },
)}
<div id="transPageRoot"><div class="spinner"></div></div> <div id="transPageRoot"><div class="spinner"></div></div>
`; `;
if (canEdit()) { if (canEdit()) {
document.getElementById('transHeaderActions').innerHTML = ` document.getElementById('transHeaderActions').innerHTML = `
<input type="file" id="importTransBundleFile" accept=".json,application/json" hidden> <input type="file" id="importTransBundleFile" accept=".json,application/json" hidden>
<button type="button" class="btn" id="importTransBundleBtn">Import translations</button> <button type="button" class="btn" id="importTransBundleBtn" title="Import JSON bundle">Import</button>
<button type="button" class="btn btn-primary" id="exportTransBundleBtn">Export translations</button> <button type="button" class="btn btn-primary" id="exportTransBundleBtn" title="Download JSON bundle">Export</button>
`; `;
bindTranslationBundleActions(); bindTranslationBundleActions();
} }
@ -56,47 +61,49 @@ export async function translationsPage() {
function renderShell() { function renderShell() {
document.getElementById('transPageRoot').innerHTML = ` document.getElementById('transPageRoot').innerHTML = `
<div class="card trans-page"> <div class="trans-layout">
<div class="trans-controls"> <aside class="trans-sidebar" id="transSidebar">
<label class="trans-control"> <section class="trans-sidebar-section">
<span>Translate into</span> <div class="trans-sidebar-head">
<select id="transLangSelect" class="trans-select" disabled> <h2 class="trans-sidebar-title">Languages</h2>
<option value="">—</option> <p class="trans-sidebar-hint">Select a language to translate into. Toggle to show or hide in the app.</p>
</select> </div>
</label> <div id="transLangSidebar" class="trans-lang-list">
</div> <div class="spinner spinner-sm"></div>
</div>
<div id="transAddLang" class="trans-add-lang"></div>
</section>
<details class="trans-lang-panel"> <section class="trans-sidebar-section">
<summary>More languages</summary> <div class="trans-sidebar-head">
<div id="transLangPanel"><span class="text-muted">Loading…</span></div> <h2 class="trans-sidebar-title">Content</h2>
</details> <p class="trans-sidebar-hint">Choose what you are translating.</p>
</div>
<nav id="transContentNav" class="trans-content-nav" aria-label="Translation content">
<div class="spinner spinner-sm"></div>
</nav>
</section>
<div class="trans-toolbar"> <section class="trans-sidebar-section trans-sidebar-advanced">
<label class="trans-control trans-toolbar-field"> <details class="trans-advanced-panel" id="transAdvancedPanel">
<span>Content</span> <summary class="trans-advanced-summary">Advanced</summary>
<select id="transScopeSelect" class="trans-select" disabled> <div id="transAdvancedBody" class="trans-advanced-body">
<option value="">Loading…</option> <p class="text-muted trans-advanced-note">
</select> Bulk-delete translated text. German source is never removed.
</label> </p>
<label class="trans-control trans-toolbar-field"> <div id="transDeleteForm"></div>
<span>Show</span> </div>
<select id="transShowMode" class="trans-select trans-type-select" disabled> </details>
<option value="missing">Missing only</option> </section>
<option value="all">All</option> </aside>
<option value="complete">Complete only</option>
</select>
</label>
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search in scope…" disabled>
<select id="transTypeFilter" class="trans-select trans-type-select" disabled>
<option value="">All types</option>
<option value="app_string">App strings</option>
<option value="string">Questionnaire UI strings</option>
<option value="question">Questions</option>
<option value="answer_option">Answer options</option>
</select>
</div>
<div id="transListArea"><div class="spinner"></div></div> <main class="trans-main" id="transMain">
<div id="transWorkspaceHeader" class="trans-workspace-header"></div>
<div id="transQnAvailability" class="trans-qn-availability" hidden></div>
<div id="transListArea" class="trans-list-area">
<div class="spinner"></div>
</div>
</main>
</div> </div>
`; `;
} }
@ -105,72 +112,72 @@ function pickDefaultTargetLang(languages) {
const targets = targetLanguages(languages); const targets = targetLanguages(languages);
if (!targets.length) return ''; if (!targets.length) return '';
const stored = localStorage.getItem(STORAGE_LANG); const stored = localStorage.getItem(STORAGE_LANG);
if (stored && targets.some(l => l.languageCode === stored)) { if (stored && targets.some(l => l.languageCode === stored)) return stored;
return stored; if (selectedLang && targets.some(l => l.languageCode === selectedLang)) return selectedLang;
}
if (selectedLang && targets.some(l => l.languageCode === selectedLang)) {
return selectedLang;
}
return targets[0].languageCode; return targets[0].languageCode;
} }
function pickDefaultShowMode() { function pickDefaultShowMode() {
const stored = localStorage.getItem(STORAGE_SHOW); const stored = localStorage.getItem(STORAGE_SHOW);
if (stored === 'missing' || stored === 'all' || stored === 'complete') { if (stored === 'missing' || stored === 'all' || stored === 'complete') return stored;
return stored;
}
return 'missing'; return 'missing';
} }
function pickDefaultScope(sections, entries, lang) { function pickDefaultScope(sections, entries, lang) {
const stored = localStorage.getItem(STORAGE_SCOPE); const stored = localStorage.getItem(STORAGE_SCOPE);
if (stored === '__all__' || sections.some(s => s.questionnaireID === stored)) { if (stored === '__all__' || sections.some(s => s.questionnaireID === stored)) return stored;
return stored;
}
for (const sec of sections) { for (const sec of sections) {
const slice = sectionEntries(entries, sec); if (countMissing(sectionEntries(entries, sec), lang) > 0) return sec.questionnaireID;
if (countMissing(slice, lang) > 0) {
return sec.questionnaireID;
}
} }
return sections[0]?.questionnaireID || '__app__'; return sections[0]?.questionnaireID || '__app__';
} }
function scopeLabel(sec, entries, lang) { function scopeLabel(sec, entries, lang) {
const slice = sectionEntries(entries, sec); const missing = countMissing(sectionEntries(entries, sec), lang);
const missing = countMissing(slice, lang); return { name: sec.name, missing, complete: missing === 0 };
const suffix = missing ? ` (${missing} missing)` : ' (complete)';
return `${sec.name}${suffix}`;
} }
function scopedSections() { function scopedSections() {
const sections = transData?.sections || []; const sections = transData?.sections || [];
if (!selectedScope || selectedScope === '__all__') { if (!selectedScope || selectedScope === '__all__') return sections;
return sections;
}
return sections.filter(s => s.questionnaireID === selectedScope); return sections.filter(s => s.questionnaireID === selectedScope);
} }
function entriesInScope() { function entriesInScope() {
const sections = scopedSections(); const sections = scopedSections();
const out = []; const out = [];
for (const sec of sections) { for (const sec of sections) out.push(...sectionEntries(allEntries, sec));
out.push(...sectionEntries(allEntries, sec));
}
return out; 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) { function setSaveStatus(state) {
const el = document.getElementById('transSaveStatus'); const el = document.getElementById('transSaveStatus');
if (!el) return; if (!el) return;
el.dataset.state = state; el.dataset.state = state;
if (state === 'saving') { el.textContent = state === 'saving' ? 'Saving…' : state === 'error' ? 'Save failed' : 'All changes saved';
el.textContent = 'Saving…';
} else if (state === 'error') {
el.textContent = 'Save failed';
} else {
el.textContent = 'All changes saved';
}
} }
async function ensureGermanLanguage() { async function ensureGermanLanguage() {
@ -181,10 +188,6 @@ async function ensureGermanLanguage() {
async function loadTranslations() { async function loadTranslations() {
const listArea = document.getElementById('transListArea'); const listArea = document.getElementById('transListArea');
const langSelect = document.getElementById('transLangSelect');
const filter = document.getElementById('transFilter');
const typeFilter = document.getElementById('transTypeFilter');
if (listArea) listArea.innerHTML = '<div class="spinner"></div>'; if (listArea) listArea.innerHTML = '<div class="spinner"></div>';
saveTimers = {}; saveTimers = {};
@ -203,93 +206,137 @@ async function loadTranslations() {
showMode = pickDefaultShowMode(); showMode = pickDefaultShowMode();
selectedScope = pickDefaultScope(flat.sections, allEntries, selectedLang); selectedScope = pickDefaultScope(flat.sections, allEntries, selectedLang);
if (langSelect) { renderLanguageSidebar(languages);
const targets = targetLanguages(languages); renderContentNav();
langSelect.disabled = !targets.length; renderDeleteForm();
langSelect.innerHTML = targets.length renderWorkspaceHeader();
? targets.map(l => { renderQnAvailabilityStrip();
const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode;
return `<option value="${esc(l.languageCode)}" ${l.languageCode === selectedLang ? 'selected' : ''}>${esc(label)}</option>`;
}).join('')
: '<option value="">Add a language below</option>';
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);
renderEntryList(); renderEntryList();
bindFilters(); bindFilters();
setSaveStatus('saved'); setSaveStatus('saved');
} catch (e) { } catch (e) {
if (listArea) listArea.innerHTML = `<p class="error-text">${esc(e.message)}</p>`; if (listArea) listArea.innerHTML = `<p class="error-text trans-hint">${esc(e.message)}</p>`;
showToast(e.message, 'error'); showToast(e.message, 'error');
} }
} }
function renderLanguagePanel(languages) { function renderLanguageSidebar(languages) {
const panel = document.getElementById('transLangPanel'); const list = document.getElementById('transLangSidebar');
if (!panel) return; const addBox = document.getElementById('transAddLang');
if (!list || !addBox) return;
const others = targetLanguages(languages); const others = targetLanguages(languages);
const source = languages.find(l => l.languageCode === SOURCE_LANG);
panel.innerHTML = ` list.innerHTML = `
<p class="text-muted" style="font-size:.85rem;margin:0 0 10px"> <div class="trans-lang-item trans-lang-item-source" aria-disabled="true">
German source text is edited in the questionnaire editor. Add other languages here. <span class="trans-lang-code">DE</span>
</p> <span class="trans-lang-meta">
<div class="lang-chips"> <span class="trans-lang-name">${esc(source?.name || 'German')}</span>
<span class="lang-chip lang-chip-fixed"> <span class="trans-lang-sub">Source language</span>
<strong>DE</strong>
<span class="lang-chip-name">German (source)</span>
</span> </span>
${others.map(l => ` <span class="trans-lang-badge trans-lang-badge-source">Source</span>
<span class="lang-chip">
<strong>${esc(l.languageCode.toUpperCase())}</strong>
${l.name ? `<span class="lang-chip-name">${esc(l.name)}</span>` : ''}
<button type="button" class="lang-chip-remove" data-lc="${esc(l.languageCode)}" title="Remove language">&times;</button>
</span>
`).join('')}
</div>
<div class="lang-add-row">
<input type="text" id="newLangCode" placeholder="Code (en)" class="lang-add-code" maxlength="8">
<input type="text" id="newLangName" placeholder="Name (English)" class="lang-add-name">
<button type="button" class="btn btn-sm btn-primary" id="addLangBtn">Add</button>
</div> </div>
${others.length ? others.map(lang => {
const selected = lang.languageCode === selectedLang;
const enabled = isLanguageGloballyEnabled(lang);
const missing = countMissing(allEntries, lang.languageCode);
return `
<button type="button"
class="trans-lang-item${selected ? ' is-selected' : ''}${!enabled ? ' is-disabled' : ''}"
data-lc="${esc(lang.languageCode)}"
aria-pressed="${selected}">
<span class="trans-lang-code">${esc(lang.languageCode.toUpperCase())}</span>
<span class="trans-lang-meta">
<span class="trans-lang-name">${esc(lang.name || lang.languageCode)}</span>
<span class="trans-lang-sub">${missing ? `${missing} missing overall` : 'Fully translated'}</span>
</span>
<span class="trans-lang-actions" data-stop-select>
<label class="trans-toggle" title="Show in app language picker">
<input type="checkbox" class="trans-global-lang-toggle" data-lc="${esc(lang.languageCode)}"
${enabled ? 'checked' : ''}>
<span class="trans-toggle-ui" aria-hidden="true"></span>
</label>
${canEdit() ? `<button type="button" class="trans-lang-remove" data-lc="${esc(lang.languageCode)}" title="Remove language">&times;</button>` : ''}
</span>
</button>`;
}).join('') : `
<p class="trans-empty-sidebar">No target languages yet. Add one below to start translating.</p>
`}
`; `;
addBox.innerHTML = canEdit() ? `
<div class="trans-add-lang-form">
<div class="trans-add-lang-fields">
<input type="text" id="newLangCode" placeholder="Code (en)" class="trans-add-input" maxlength="8" aria-label="Language code">
<input type="text" id="newLangName" placeholder="Name (English)" class="trans-add-input" aria-label="Language name">
</div>
<button type="button" class="btn btn-sm btn-primary trans-add-lang-btn" id="addLangBtn">Add language</button>
</div>
` : '';
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 () => { document.getElementById('addLangBtn')?.addEventListener('click', async () => {
const code = document.getElementById('newLangCode').value.trim().toLowerCase(); const code = document.getElementById('newLangCode').value.trim().toLowerCase();
const name = document.getElementById('newLangName').value.trim(); const name = document.getElementById('newLangName').value.trim();
if (!code) { showToast('Language code is required', 'error'); return; } if (!code) { showToast('Language code is required', 'error'); return; }
if (code === SOURCE_LANG) { 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; return;
} }
if (languages.some(l => l.languageCode === code)) { if (languages.some(l => l.languageCode === code)) {
@ -301,48 +348,285 @@ function renderLanguagePanel(languages) {
showToast(`Language "${code}" added`, 'success'); showToast(`Language "${code}" added`, 'success');
selectedLang = code; selectedLang = code;
await loadTranslations(); await loadTranslations();
} catch (e) { } catch (err) {
showToast(e.message, 'error'); showToast(err.message, 'error');
} }
}); });
}
panel.querySelectorAll('.lang-chip-remove').forEach(btn => { function renderContentNav() {
btn.addEventListener('click', async () => { const nav = document.getElementById('transContentNav');
const lc = btn.dataset.lc; if (!nav || !transData) return;
if (!(await confirmAction({
title: 'Remove language', const sections = transData.sections || [];
message: `Remove "${lc}" and all its translations?`, const items = sections.map(sec => {
confirmLabel: 'Remove', const { name, missing, complete } = scopeLabel(sec, allEntries, selectedLang);
variant: 'danger', const active = sec.questionnaireID === selectedScope;
}))) return; const badge = missing
? `<span class="trans-nav-badge trans-nav-badge-warn">${missing}</span>`
: `<span class="trans-nav-badge trans-nav-badge-ok" title="Complete">✓</span>`;
const icon = sec.isApp ? '◇' : '▢';
return `
<button type="button"
class="trans-content-item${active ? ' is-active' : ''}"
data-scope="${esc(sec.questionnaireID)}"
aria-current="${active ? 'page' : 'false'}">
<span class="trans-content-icon" aria-hidden="true">${icon}</span>
<span class="trans-content-label">${esc(name)}</span>
${selectedLang ? badge : ''}
</button>`;
});
items.push(`
<button type="button"
class="trans-content-item trans-content-item-all${selectedScope === '__all__' ? ' is-active' : ''}"
data-scope="__all__"
aria-current="${selectedScope === '__all__' ? 'page' : 'false'}">
<span class="trans-content-icon" aria-hidden="true">≡</span>
<span class="trans-content-label">All content</span>
</button>
`);
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 = `
<div class="trans-qn-availability-inner">
<span class="trans-qn-availability-label">Available in app for this questionnaire</span>
<div class="trans-qn-availability-toggles">
${languages.map(lang => {
const on = !questionnaireLanguageDisabled(scope.id, lang.languageCode);
return `
<label class="trans-qn-pill${on ? ' is-on' : ''}">
<input type="checkbox" class="trans-qn-lang-toggle"
data-qid="${esc(scope.id)}"
data-lc="${esc(lang.languageCode)}"
${on ? 'checked' : ''}>
<span>${esc(lang.languageCode.toUpperCase())}</span>
</label>`;
}).join('')}
</div>
</div>
`;
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 { try {
await apiDelete('translations.php', { type: 'language', languageCode: lc }); await apiPut('translations.php', {
showToast(`Language "${lc}" removed`, 'success'); type: 'questionnaire_language',
if (selectedLang === lc) selectedLang = ''; questionnaireID: qid,
await loadTranslations(); languageCode: lc,
} catch (e) { enabled,
showToast(e.message, 'error'); });
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() { function renderDeleteForm() {
const scopeSelect = document.getElementById('transScopeSelect'); const form = document.getElementById('transDeleteForm');
if (!scopeSelect || !transData) return; if (!form || !transData || !canEdit()) {
if (form) form.innerHTML = '';
const sections = transData.sections || []; return;
const options = sections.map(sec =>
`<option value="${esc(sec.questionnaireID)}"${sec.questionnaireID === selectedScope ? ' selected' : ''}>${esc(scopeLabel(sec, allEntries, selectedLang))}</option>`
);
options.push(`<option value="__all__"${selectedScope === '__all__' ? ' selected' : ''}>All content</option>`);
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);
} }
const languages = targetLanguages(transData.languages || []);
const sections = (transData.sections || []).filter(sec => sec.questionnaireID !== '__app__');
const defaultScope = selectedScope && selectedScope !== '__all__' ? selectedScope : '__app__';
form.innerHTML = `
<label class="trans-field">
<span>Content area</span>
<select id="transDeleteScope" class="trans-select">
<option value="__app__"${defaultScope === '__app__' ? ' selected' : ''}>App UI strings</option>
${sections.map(sec => `
<option value="${esc(sec.questionnaireID)}"${defaultScope === sec.questionnaireID ? ' selected' : ''}>
${esc(sec.name)}
</option>
`).join('')}
<option value="__all__"${defaultScope === '__all__' ? ' selected' : ''}>All content</option>
</select>
</label>
<label class="trans-field">
<span>Language</span>
<select id="transDeleteLang" class="trans-select">
<option value="">All target languages</option>
${languages.map(l => `
<option value="${esc(l.languageCode)}">${esc(l.name || l.languageCode)} (${esc(l.languageCode)})</option>
`).join('')}
</select>
</label>
<button type="button" class="btn btn-danger btn-sm trans-delete-btn" id="transDeleteBtn">Delete translations</button>
`;
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 = `
<div class="trans-workspace-top">
<div class="trans-workspace-title-wrap">
<h2 class="trans-workspace-title">${esc(scope.name)}</h2>
<p class="trans-workspace-subtitle">
${selectedLang
? `Translating into <strong>${esc(lang.name)}</strong> (${esc(lang.code)})`
: 'Add a target language to begin'}
${!lang.enabled && selectedLang ? ' · <span class="trans-warn">Hidden from app</span>' : ''}
</p>
</div>
<div class="trans-workspace-status">
<span id="transSaveStatus" class="trans-save-status" data-state="saved">All changes saved</span>
<button type="button" class="btn btn-sm" id="transJumpMissingBtn"${!selectedLang ? ' disabled' : ''}>
Next missing
</button>
</div>
</div>
<div class="trans-workspace-progress"${!selectedLang ? ' hidden' : ''}>
<div class="trans-progress-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
<div class="trans-progress-fill" style="width:${pct}%"></div>
</div>
<span class="trans-progress-text" id="transVisibleCount">
${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} strings
<span class="trans-progress-pct">${pct}% complete</span>
</span>
</div>
<div class="trans-workspace-filters">
<div class="trans-filter-pills" role="group" aria-label="Show">
<button type="button" class="trans-pill${showMode === 'missing' ? ' is-active' : ''}" data-show="missing">Missing</button>
<button type="button" class="trans-pill${showMode === 'all' ? ' is-active' : ''}" data-show="all">All</button>
<button type="button" class="trans-pill${showMode === 'complete' ? ' is-active' : ''}" data-show="complete">Complete</button>
</div>
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search strings…" ${!selectedLang ? 'disabled' : ''}>
<select id="transTypeFilter" class="trans-select trans-type-select" ${!selectedLang ? 'disabled' : ''}>
<option value="">All types</option>
<option value="app_string">App strings</option>
<option value="string">Questionnaire UI</option>
<option value="question">Questions</option>
<option value="answer_option">Answer options</option>
</select>
</div>
`;
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() { function renderEntryList() {
@ -355,30 +639,33 @@ function renderEntryList() {
if (!targets.length) { if (!targets.length) {
listArea.innerHTML = ` listArea.innerHTML = `
<p class="text-muted trans-hint">Add at least one language under <strong>More languages</strong> to translate into.</p>`; <div class="trans-empty-state">
<p class="trans-empty-title">Add a language to get started</p>
<p class="text-muted">Use the form in the sidebar to add English, French, or any other target language.</p>
</div>`;
return; return;
} }
if (!selectedLang) { if (!selectedLang) {
listArea.innerHTML = `<p class="text-muted trans-hint">Choose a language above.</p>`; listArea.innerHTML = `
<div class="trans-empty-state">
<p class="trans-empty-title">Select a target language</p>
<p class="text-muted">Click a language in the sidebar to begin translating.</p>
</div>`;
return; return;
} }
if (!transData.sections?.length) { if (!transData.sections?.length) {
listArea.innerHTML = ` listArea.innerHTML = `
<p class="text-muted trans-hint">No translatable content yet. Add questionnaires and questions in the editor first.</p>`; <div class="trans-empty-state">
<p class="trans-empty-title">No translatable content</p>
<p class="text-muted">Create questionnaires in the editor first. German source text is edited there.</p>
</div>`;
return; return;
} }
const sourceLabel = sourceLanguageLabel(languages); const sourceLabel = sourceLanguageLabel(languages);
const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase(); 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 useQnDetails = selectedScope === '__all__';
const sectionsHtml = sections.map(sec => { const sectionsHtml = sections.map(sec => {
@ -393,9 +680,7 @@ function renderEntryList() {
const open = secMissing > 0 && sections.filter(s => const open = secMissing > 0 && sections.filter(s =>
countMissing(sectionEntries(allEntries, s), selectedLang) > 0 countMissing(sectionEntries(allEntries, s), selectedLang) > 0
)[0]?.questionnaireID === sec.questionnaireID; )[0]?.questionnaireID === sec.questionnaireID;
const summary = secMissing const summary = secMissing ? `${sec.name} · ${secMissing} missing` : `${sec.name} · complete`;
? `${sec.name} · ${secMissing} missing`
: `${sec.name} · complete`;
return ` return `
<details class="trans-qn-section"${open ? ' open' : ''} data-qn="${esc(sec.questionnaireID)}"> <details class="trans-qn-section"${open ? ' open' : ''} data-qn="${esc(sec.questionnaireID)}">
<summary class="trans-qn-summary">${esc(summary)}</summary> <summary class="trans-qn-summary">${esc(summary)}</summary>
@ -407,27 +692,12 @@ function renderEntryList() {
}).join(''); }).join('');
listArea.innerHTML = ` listArea.innerHTML = `
<div class="trans-top-bar">
<div class="trans-top-stats">
<span id="transVisibleCount">${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} in scope</span>
<span class="trans-stats-progress">
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
<span class="trans-progress-label">${pct}% in ${esc(selectedLang.toUpperCase())}</span>
</span>
</div>
<div class="trans-top-actions">
<span id="transSaveStatus" class="trans-save-status" data-state="saved">All changes saved</span>
<button type="button" class="btn btn-sm" id="transJumpMissingBtn">Jump to next missing</button>
</div>
</div>
<div class="trans-sheet"> <div class="trans-sheet">
${headerOnce} ${translationListHeaderHTML(langLabel, sourceLabel)}
<div id="transGroupedRoot" class="trans-sheet-body${useQnDetails ? ' trans-sheet-body-stack' : ''}">${sectionsHtml}</div> <div id="transGroupedRoot" class="trans-sheet-body${useQnDetails ? ' trans-sheet-body-stack' : ''}">${sectionsHtml}</div>
</div> </div>
`; `;
document.getElementById('transJumpMissingBtn')?.addEventListener('click', jumpToNextMissing);
bindEntryEditing(allEntries); bindEntryEditing(allEntries);
applyFilters(); applyFilters();
} }
@ -458,8 +728,7 @@ function jumpToNextMissing() {
} }
const target = rows[start] || rows[0]; const target = rows[start] || rows[0];
openAncestors(target); openAncestors(target);
const inp = target.querySelector('[data-field="translation"]'); target.querySelector('[data-field="translation"]')?.focus({ preventScroll: false });
inp?.focus({ preventScroll: false });
target.scrollIntoView({ behavior: 'smooth', block: 'center' }); target.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
@ -518,7 +787,8 @@ async function saveTranslation(entry, inp, isGerman = false) {
savesInFlight = Math.max(0, savesInFlight - 1); savesInFlight = Math.max(0, savesInFlight - 1);
if (savesInFlight === 0) { if (savesInFlight === 0) {
setSaveStatus('saved'); setSaveStatus('saved');
populateScopeSelect(); renderContentNav();
renderLanguageSidebar(transData?.languages || []);
} }
} }
} }
@ -531,8 +801,10 @@ function updateProgress() {
: 0; : 0;
const fill = document.querySelector('.trans-progress-fill'); const fill = document.querySelector('.trans-progress-fill');
if (fill) fill.style.width = `${pct}%`; if (fill) fill.style.width = `${pct}%`;
const label = document.querySelector('.trans-progress-label'); const track = document.querySelector('.trans-progress-track');
if (label) label.textContent = `${pct}% in ${selectedLang.toUpperCase()}`; 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) { if (!document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) {
updateVisibleCountSummary(); updateVisibleCountSummary();
} }
@ -544,14 +816,20 @@ function updateVisibleCountSummary(visible = null, visibleMissing = null) {
const scopeEntries = entriesInScope(); const scopeEntries = entriesInScope();
const text = document.getElementById('transFilter')?.value || ''; const text = document.getElementById('transFilter')?.value || '';
const type = document.getElementById('transTypeFilter')?.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 (text || type || showMode !== 'missing') {
if (visible != null) { if (visible != null) {
countEl.textContent = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''}`; countEl.innerHTML = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''}
<span class="trans-progress-pct">${pct}% complete</span>`;
} }
return; return;
} }
const missing = countMissing(scopeEntries, selectedLang); countEl.innerHTML = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} strings
countEl.textContent = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} in scope`; <span class="trans-progress-pct">${pct}% complete</span>`;
} }
let filtersBound = false; let filtersBound = false;
@ -562,7 +840,6 @@ function bindFilters() {
return; return;
} }
filtersBound = true; filtersBound = true;
document.getElementById('transFilter')?.addEventListener('input', applyFilters); document.getElementById('transFilter')?.addEventListener('input', applyFilters);
document.getElementById('transTypeFilter')?.addEventListener('change', applyFilters); document.getElementById('transTypeFilter')?.addEventListener('change', applyFilters);
} }
@ -590,13 +867,12 @@ function applyFilters() {
row.classList.toggle('trans-row-hidden', !show); row.classList.toggle('trans-row-hidden', !show);
if (show) { if (show) {
visible++; visible++;
if (row.dataset.missing === '1') visibleMissing++; if (isMissing) visibleMissing++;
} }
}); });
document.querySelectorAll('#transGroupedRoot .trans-group-details').forEach(group => { document.querySelectorAll('#transGroupedRoot .trans-group-details').forEach(group => {
const rows = group.querySelectorAll('.trans-list-item'); const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
const anyVisible = [...rows].some(r => !r.classList.contains('trans-row-hidden'));
group.classList.toggle('trans-group-hidden', !anyVisible); group.classList.toggle('trans-group-hidden', !anyVisible);
}); });
@ -643,8 +919,7 @@ function bindTranslationBundleActions() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
const stamp = new Date().toISOString().slice(0, 10); a.download = `translations_bundle_${new Date().toISOString().slice(0, 10)}.json`;
a.download = `translations_bundle_${stamp}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
showToast('Translations bundle downloaded', 'success'); showToast('Translations bundle downloaded', 'success');
@ -676,10 +951,9 @@ function bindTranslationBundleActions() {
return; return;
} }
const count = bundle.entries.length;
if (!(await confirmAction({ if (!(await confirmAction({
title: 'Import translations', 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', confirmLabel: 'Import',
}))) return; }))) return;
@ -687,10 +961,7 @@ function bindTranslationBundleActions() {
const prev = importBtn.textContent; const prev = importBtn.textContent;
importBtn.textContent = 'Importing…'; importBtn.textContent = 'Importing…';
try { try {
const result = await apiPost('translations.php', { const result = await apiPost('translations.php', { action: 'importBundle', bundle });
action: 'importBundle',
bundle,
});
const imported = result.imported ?? 0; const imported = result.imported ?? 0;
const skipped = result.skipped ?? 0; const skipped = result.skipped ?? 0;
let msg = `Imported ${imported} translation(s)`; let msg = `Imported ${imported} translation(s)`;

View File

@ -2,6 +2,7 @@ import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js'; import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js'; import { confirmAction } from '../confirm-modal.js';
import { openAdminResetPasswordModal } from '../password-modal.js'; import { openAdminResetPasswordModal } from '../password-modal.js';
import { berlinDateInputValue, formatBerlinDateTimeShort } from '../datetime.js';
// Roles an admin can create; supervisors can only create coaches // Roles an admin can create; supervisors can only create coaches
const CREATEABLE_ROLES = { const CREATEABLE_ROLES = {
@ -529,7 +530,7 @@ function downloadUsersCSV() {
u.role === 'coach' ? '' : (u.location || ''), u.role === 'coach' ? '' : (u.location || ''),
u.role === 'coach' ? (u.supervisorUsername || u.supervisorID || '') : '', u.role === 'coach' ? (u.supervisorUsername || u.supervisorID || '') : '',
u.mustChangePassword == 1 ? 'yes' : 'no', 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, u.userID,
]); ]);
@ -541,7 +542,7 @@ function downloadUsersCSV() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `users_${new Date().toISOString().slice(0, 10)}.csv`; a.download = `users_${berlinDateInputValue()}.csv`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
showToast('Users exported', 'success'); showToast('Users exported', 'success');

View File

@ -1,4 +1,13 @@
const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/; 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). */ /** Local question id from full questionID (hash__q5 → q5). */
export function questionLocalKey(questionID, fallback = '') { export function questionLocalKey(questionID, fallback = '') {
@ -62,6 +71,7 @@ export function glassSymptomAnswerValue(raw, symptomKey) {
export function buildResultColumns(questions) { export function buildResultColumns(questions) {
const cols = []; const cols = [];
for (const q of questions || []) { for (const q of questions || []) {
if (isResultsExcludedQuestion(q)) continue;
const cfg = parseQuestionConfig(q); const cfg = parseQuestionConfig(q);
const symptoms = cfg.symptoms; const symptoms = cfg.symptoms;
if (q.type === 'glass_scale_question' && Array.isArray(symptoms) && symptoms.length > 0) { if (q.type === 'glass_scale_question' && Array.isArray(symptoms) && symptoms.length > 0) {

View File

@ -69,6 +69,16 @@ export function targetLanguages(languages) {
return (languages || []).filter(l => l.languageCode !== SOURCE_LANG); 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 */ /** Sort rows within a group: missing translations first, then questionnaire order */
export function sortGroupItems(items, targetLang) { export function sortGroupItems(items, targetLang) {
return [...items].sort((a, b) => { return [...items].sort((a, b) => {