This commit is contained in:
287
common.php
287
common.php
@ -4,6 +4,13 @@
|
||||
|
||||
require_once __DIR__ . '/lib/text_sanitize.php';
|
||||
|
||||
/** IANA timezone for all human-readable timestamps (CET/CEST). */
|
||||
function qdb_app_timezone(): string {
|
||||
return 'Europe/Berlin';
|
||||
}
|
||||
|
||||
date_default_timezone_set(qdb_app_timezone());
|
||||
|
||||
/**
|
||||
* Values loaded from the project .env file (see qdb_load_dotenv).
|
||||
*
|
||||
@ -374,11 +381,273 @@ function rbac_client_filter(
|
||||
define('QDB_SOURCE_LANGUAGE', 'de');
|
||||
|
||||
function qdb_ensure_source_language(PDO $pdo): void {
|
||||
if (qdb_column_exists($pdo, 'language', 'enabled')) {
|
||||
$pdo->prepare("INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1)
|
||||
ON CONFLICT(languageCode) DO UPDATE SET enabled = 1")
|
||||
->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']);
|
||||
return;
|
||||
}
|
||||
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
|
||||
ON CONFLICT(languageCode) DO NOTHING")
|
||||
->execute([':lc' => QDB_SOURCE_LANGUAGE, ':n' => 'German']);
|
||||
}
|
||||
|
||||
/** @return list<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 {
|
||||
qdb_put_translation($pdo, $type, $id, QDB_SOURCE_LANGUAGE, $text);
|
||||
}
|
||||
@ -972,6 +1241,21 @@ function qdb_validate_stable_key(string $s, string $label = 'Key'): string {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/** Question keys omitted from results tables and CSV exports (e.g. last-page warnings). */
|
||||
function qdb_results_excluded_question_keys(): array {
|
||||
return ['data_final_warning'];
|
||||
}
|
||||
|
||||
/** Whether a question row should be hidden from results tables and CSV exports. */
|
||||
function qdb_question_is_results_excluded(array $qRow): bool {
|
||||
if (($qRow['type'] ?? '') === 'last_page') {
|
||||
return true;
|
||||
}
|
||||
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
|
||||
$key = qdb_question_key($cfg, (string)($qRow['defaultText'] ?? ''));
|
||||
return in_array($key, qdb_results_excluded_question_keys(), true);
|
||||
}
|
||||
|
||||
/** Resolve app lookup key for a question row. */
|
||||
function qdb_question_key(array $config, string $defaultText): string {
|
||||
$key = trim((string)($config['questionKey'] ?? ''));
|
||||
@ -1078,6 +1362,9 @@ function qdb_results_export_columns(array $questions, string $qnID): array
|
||||
{
|
||||
$columns = [];
|
||||
foreach ($questions as $q) {
|
||||
if (qdb_question_is_results_excluded($q)) {
|
||||
continue;
|
||||
}
|
||||
$cfg = json_decode($q['configJson'] ?? '{}', true) ?: [];
|
||||
$type = $q['type'] ?? '';
|
||||
if ($type === 'glass_scale_question') {
|
||||
|
||||
22
db_init.php
22
db_init.php
@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) {
|
||||
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
|
||||
}
|
||||
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
||||
define('QDB_VERSION', 13);
|
||||
define('QDB_VERSION', 14);
|
||||
|
||||
function qdb_table_exists(PDO $pdo, string $table): bool {
|
||||
$stmt = $pdo->prepare(
|
||||
@ -348,6 +348,26 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
|
||||
}
|
||||
}
|
||||
|
||||
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
||||
if ($currentVersion < 14) {
|
||||
if (qdb_table_exists($pdo, 'language') && !qdb_column_exists($pdo, 'language', 'enabled')) {
|
||||
$pdo->exec('ALTER TABLE language ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1');
|
||||
$changed = true;
|
||||
}
|
||||
if (!qdb_table_exists($pdo, 'questionnaire_language_disable')) {
|
||||
$pdo->exec(
|
||||
'CREATE TABLE questionnaire_language_disable (
|
||||
questionnaireID TEXT NOT NULL,
|
||||
languageCode TEXT NOT NULL,
|
||||
PRIMARY KEY (questionnaireID, languageCode),
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(languageCode) REFERENCES language(languageCode) ON DELETE CASCADE
|
||||
)'
|
||||
);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
||||
if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||
$pdo->exec(
|
||||
|
||||
@ -19,7 +19,10 @@
|
||||
if ($method === 'GET' && !empty($_GET['translations'])) {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
json_success(['translations' => qdb_build_app_translations_map($pdo)]);
|
||||
json_success([
|
||||
'translations' => qdb_build_app_translations_map($pdo),
|
||||
'availableLanguages' => qdb_available_language_codes($pdo),
|
||||
]);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
return;
|
||||
}
|
||||
@ -592,6 +595,7 @@ if ($qnID) {
|
||||
'structureRevision' => $structureRevision,
|
||||
'questions' => $questions,
|
||||
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
|
||||
'availableLanguages' => qdb_available_language_codes($pdo, $qnID),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -63,6 +63,44 @@ case 'GET':
|
||||
|
||||
case 'POST':
|
||||
$body = read_json_body();
|
||||
|
||||
if (($body['action'] ?? '') === 'reset') {
|
||||
$clientCode = trim((string)($body['clientCode'] ?? ''));
|
||||
if ($clientCode === '') {
|
||||
json_error('MISSING_FIELDS', 'clientCode is required', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
[$clause, $params] = rbac_client_filter($tokenRec, 'cl', 'all');
|
||||
$chk = $pdo->prepare(
|
||||
"SELECT clientCode FROM client cl WHERE cl.clientCode = :cc AND ($clause)"
|
||||
);
|
||||
$chk->execute(array_merge([':cc' => $clientCode], $params));
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../lib/submissions.php';
|
||||
|
||||
$pdo->beginTransaction();
|
||||
$cleared = qdb_delete_client_response_data($pdo, [$clientCode]);
|
||||
$pdo->commit();
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'clientCode' => $clientCode,
|
||||
'reset' => true,
|
||||
'cleared' => $cleared,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$coachID = trim($body['coachID'] ?? '');
|
||||
$bulk = !empty($body['bulk']);
|
||||
|
||||
|
||||
@ -85,7 +85,7 @@ if ($allVersions) {
|
||||
$filename = $safeName . '_all_versions_' . date('Y-m-d') . '.csv';
|
||||
|
||||
qdb_http_download(
|
||||
qdb_export_rows_to_csv_string($rows, qdb_export_all_versions_csv_fallback_header($resultColumns)),
|
||||
qdb_export_rows_to_csv_string($rows, qdb_export_all_versions_csv_fallback_header($pdo, $resultColumns)),
|
||||
[
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
@ -100,7 +100,7 @@ $safeName = qdb_export_safe_basename($questionnaire['name']);
|
||||
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
|
||||
|
||||
qdb_http_download(
|
||||
qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($resultColumns)),
|
||||
qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($pdo, $resultColumns)),
|
||||
[
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../lib/submissions.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
if ($method !== 'GET') {
|
||||
@ -112,12 +114,19 @@ if (!empty($questionIDs) && !empty($clients)) {
|
||||
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);
|
||||
|
||||
json_success([
|
||||
'questionnaire' => $questionnaire,
|
||||
'questions' => $questions,
|
||||
'clients' => $clients,
|
||||
'scoringProfiles' => $scoringSummary['profiles'],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load results', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
|
||||
$validTypes = ['question', 'answer_option', 'string', 'app_string', 'language'];
|
||||
$validTypes = ['question', 'answer_option', 'string', 'app_string', 'language', 'language_enabled', 'questionnaire_language'];
|
||||
|
||||
switch ($method) {
|
||||
|
||||
@ -32,9 +32,13 @@ case 'GET':
|
||||
try {
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
$rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$rows = qdb_load_languages($pdo);
|
||||
$disables = qdb_questionnaire_language_disables($pdo);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success(['languages' => $rows]);
|
||||
json_success([
|
||||
'languages' => $rows,
|
||||
'questionnaireLanguageDisables' => $disables,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load languages', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
@ -45,9 +49,9 @@ case 'GET':
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
|
||||
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$languages = qdb_load_languages($pdo);
|
||||
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
|
||||
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']);
|
||||
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German', 'enabled' => 1]);
|
||||
}
|
||||
|
||||
$qnRows = $pdo->query("
|
||||
@ -87,6 +91,7 @@ case 'GET':
|
||||
'questionnaires' => $questionnaires,
|
||||
'entries' => $allEntries,
|
||||
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
|
||||
'questionnaireLanguageDisables' => qdb_questionnaire_language_disables($pdo),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
@ -99,9 +104,9 @@ case 'GET':
|
||||
$opened = qdb_open_read_or_fail();
|
||||
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||
|
||||
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$languages = qdb_load_languages($pdo);
|
||||
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
|
||||
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']);
|
||||
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German', 'enabled' => 1]);
|
||||
}
|
||||
|
||||
$qnRow = $pdo->prepare('SELECT name FROM questionnaire WHERE questionnaireID = :id');
|
||||
@ -123,6 +128,7 @@ case 'GET':
|
||||
'entries' => $entries,
|
||||
'questionnaire' => ['questionnaireID' => $qnID, 'name' => (string)$qnName],
|
||||
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
|
||||
'questionnaireLanguageDisables' => qdb_questionnaire_language_disables($pdo),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
@ -185,6 +191,28 @@ case 'POST':
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (($body['action'] ?? '') === 'deleteTranslationsScope') {
|
||||
$scope = trim((string)($body['scope'] ?? ''));
|
||||
$qnID = trim((string)($body['questionnaireID'] ?? ''));
|
||||
$lang = isset($body['languageCode']) ? trim((string)$body['languageCode']) : '';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
$deleted = qdb_delete_translations_for_scope(
|
||||
$pdo,
|
||||
$scope,
|
||||
$qnID !== '' ? $qnID : null,
|
||||
$lang !== '' ? $lang : null
|
||||
);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['deleted' => $deleted]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', $e->getMessage(), 400);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Delete translations', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
json_error('BAD_REQUEST', 'Unknown action', 400);
|
||||
break;
|
||||
|
||||
@ -203,6 +231,10 @@ case 'PUT':
|
||||
try {
|
||||
if ($lang === QDB_SOURCE_LANGUAGE) {
|
||||
qdb_ensure_source_language($pdo);
|
||||
} elseif (qdb_column_exists($pdo, 'language', 'enabled')) {
|
||||
$pdo->prepare("INSERT INTO language (languageCode, name, enabled) VALUES (:lc, :n, 1)
|
||||
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name")
|
||||
->execute([':lc' => $lang, ':n' => $name]);
|
||||
} else {
|
||||
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
|
||||
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name")
|
||||
@ -216,6 +248,47 @@ case 'PUT':
|
||||
break;
|
||||
}
|
||||
|
||||
if ($type === 'language_enabled') {
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
if (!$lang) {
|
||||
json_error('MISSING_FIELDS', 'languageCode required', 400);
|
||||
}
|
||||
$enabled = !empty($body['enabled']);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
qdb_set_language_enabled($pdo, $lang, $enabled);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', $e->getMessage(), 400);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Save language setting', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ($type === 'questionnaire_language') {
|
||||
$qnID = trim($body['questionnaireID'] ?? '');
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
if (!$qnID || !$lang) {
|
||||
json_error('MISSING_FIELDS', 'questionnaireID and languageCode required', 400);
|
||||
}
|
||||
$enabled = !empty($body['enabled']);
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
try {
|
||||
qdb_set_questionnaire_language_disabled($pdo, $qnID, $lang, !$enabled);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('INVALID_FIELD', $e->getMessage(), 400);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Save questionnaire language setting', null, $tmpDb, $lockFp);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$id = $body['id'] ?? '';
|
||||
$lang = trim($body['languageCode'] ?? '');
|
||||
$text = $body['text'] ?? '';
|
||||
|
||||
@ -281,7 +281,7 @@ function qdb_api_log_finish(): void
|
||||
}
|
||||
|
||||
$entry = [
|
||||
'ts' => gmdate('c'),
|
||||
'ts' => date('c'),
|
||||
'activity' => $activity,
|
||||
'method' => $ctx['method'] ?? '',
|
||||
'route' => $ctx['route'] ?? '',
|
||||
|
||||
106
lib/scoring.php
106
lib/scoring.php
@ -835,3 +835,109 @@ function qdb_set_coach_scoring_band(
|
||||
$row['profileID'] = $profileID;
|
||||
return qdb_scoring_review_row_to_api($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<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');
|
||||
}
|
||||
|
||||
@ -43,6 +43,9 @@ function qdb_display_context_from_manifest(PDO $pdo, array $manifest): array {
|
||||
),
|
||||
];
|
||||
}
|
||||
if (qdb_question_is_results_excluded($qRow)) {
|
||||
continue;
|
||||
}
|
||||
$questions[] = $qRow;
|
||||
|
||||
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
|
||||
@ -293,7 +296,10 @@ function qdb_export_all_versions_rows(
|
||||
array $optionTextMap,
|
||||
array $tokenRec
|
||||
): array {
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$scoringProfiles = qdb_active_scoring_profiles($pdo);
|
||||
$scoringColumnDefs = qdb_scoring_export_column_defs($scoringProfiles);
|
||||
|
||||
$sql = "
|
||||
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole,
|
||||
@ -315,6 +321,9 @@ function qdb_export_all_versions_rows(
|
||||
$stmt->execute($params);
|
||||
$submissions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$clientCodes = array_values(array_unique(array_column($submissions, 'clientCode')));
|
||||
$scoringByClient = qdb_scoring_export_results_map($pdo, $clientCodes, $tokenRec);
|
||||
|
||||
$defaultQuestionIDs = array_column($questions, 'questionID');
|
||||
|
||||
$userMap = [];
|
||||
@ -353,6 +362,14 @@ function qdb_export_all_versions_rows(
|
||||
'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '',
|
||||
];
|
||||
|
||||
$row = array_merge(
|
||||
$row,
|
||||
qdb_scoring_export_row_values(
|
||||
$scoringByClient[$s['clientCode']] ?? [],
|
||||
$scoringColumnDefs
|
||||
)
|
||||
);
|
||||
|
||||
$answerMap = qdb_load_client_answer_map(
|
||||
$pdo,
|
||||
$s['clientCode'],
|
||||
@ -413,8 +430,11 @@ function qdb_export_current_rows(
|
||||
array $optionTextMap,
|
||||
array $tokenRec
|
||||
): array {
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$questionIDs = array_column($questions, 'questionID');
|
||||
$scoringProfiles = qdb_active_scoring_profiles($pdo);
|
||||
$scoringColumnDefs = qdb_scoring_export_column_defs($scoringProfiles);
|
||||
|
||||
$sql = "
|
||||
SELECT cl.clientCode, cl.coachID,
|
||||
@ -433,6 +453,9 @@ function qdb_export_current_rows(
|
||||
$cStmt->execute($params);
|
||||
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$clientCodes = array_column($clients, 'clientCode');
|
||||
$scoringByClient = qdb_scoring_export_results_map($pdo, $clientCodes, $tokenRec);
|
||||
|
||||
$qPlaceholders = !empty($questionIDs) ? implode(',', array_fill(0, count($questionIDs), '?')) : "'__none__'";
|
||||
$answerStmt = $pdo->prepare("
|
||||
SELECT questionID, answerOptionID, freeTextValue, numericValue
|
||||
@ -452,6 +475,14 @@ function qdb_export_current_rows(
|
||||
'completedAt' => $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '',
|
||||
];
|
||||
|
||||
$row = array_merge(
|
||||
$row,
|
||||
qdb_scoring_export_row_values(
|
||||
$scoringByClient[$c['clientCode']] ?? [],
|
||||
$scoringColumnDefs
|
||||
)
|
||||
);
|
||||
|
||||
$bindParams = array_merge([$c['clientCode']], $questionIDs);
|
||||
$answerStmt->execute($bindParams);
|
||||
$answerMap = [];
|
||||
@ -514,19 +545,23 @@ function qdb_export_rows_to_csv_string(array $rows, array $fallbackHeader = []):
|
||||
return $csv !== false ? $csv : '';
|
||||
}
|
||||
|
||||
function qdb_export_current_csv_fallback_header(array $resultColumns): array {
|
||||
function qdb_export_current_csv_fallback_header(PDO $pdo, array $resultColumns): array {
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
$header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt'];
|
||||
$header = array_merge($header, qdb_scoring_export_csv_headers($pdo));
|
||||
foreach ($resultColumns as $col) {
|
||||
$header[] = $col['header'];
|
||||
}
|
||||
return $header;
|
||||
}
|
||||
|
||||
function qdb_export_all_versions_csv_fallback_header(array $resultColumns): array {
|
||||
function qdb_export_all_versions_csv_fallback_header(PDO $pdo, array $resultColumns): array {
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
$header = [
|
||||
'submissionID', 'version', 'submittedAt', 'submittedByRole', 'submittedBy',
|
||||
'clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt',
|
||||
];
|
||||
$header = array_merge($header, qdb_scoring_export_csv_headers($pdo));
|
||||
foreach ($resultColumns as $col) {
|
||||
$header[] = $col['header'];
|
||||
}
|
||||
@ -566,11 +601,11 @@ function qdb_build_server_export_zip(PDO $pdo, array $tokenRec, bool $allVersion
|
||||
|
||||
if ($allVersions) {
|
||||
$rows = qdb_export_all_versions_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
|
||||
$fallback = qdb_export_all_versions_csv_fallback_header($resultColumns);
|
||||
$fallback = qdb_export_all_versions_csv_fallback_header($pdo, $resultColumns);
|
||||
$base = qdb_export_safe_basename($qn['name']) . '_all_versions.csv';
|
||||
} else {
|
||||
$rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
|
||||
$fallback = qdb_export_current_csv_fallback_header($resultColumns);
|
||||
$fallback = qdb_export_current_csv_fallback_header($pdo, $resultColumns);
|
||||
$ver = preg_replace('/[^a-zA-Z0-9_.-]+/', '_', (string)($qn['version'] ?? ''));
|
||||
$base = qdb_export_safe_basename($qn['name']) . ($ver !== '' ? "_v{$ver}" : '') . '.csv';
|
||||
}
|
||||
@ -593,7 +628,7 @@ function qdb_build_server_export_zip(PDO $pdo, array $tokenRec, bool $allVersion
|
||||
* Delete uploads, answers, and completions for client codes (does not delete client rows).
|
||||
*
|
||||
* @param list<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 {
|
||||
$clientCodes = array_values(array_unique(array_filter(
|
||||
@ -605,6 +640,7 @@ function qdb_delete_client_response_data(PDO $pdo, array $clientCodes): array {
|
||||
'followup_notes' => 0,
|
||||
'client_answers' => 0,
|
||||
'completed_questionnaires' => 0,
|
||||
'scoring_results' => 0,
|
||||
];
|
||||
if ($clientCodes === []) {
|
||||
return $deleted;
|
||||
@ -628,6 +664,11 @@ function qdb_delete_client_response_data(PDO $pdo, array $clientCodes): array {
|
||||
$stmt = $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode IN ($ph)");
|
||||
$stmt->execute($chunk);
|
||||
$deleted['completed_questionnaires'] += $stmt->rowCount();
|
||||
if (qdb_table_exists($pdo, 'client_scoring_profile_result')) {
|
||||
$stmt = $pdo->prepare("DELETE FROM client_scoring_profile_result WHERE clientCode IN ($ph)");
|
||||
$stmt->execute($chunk);
|
||||
$deleted['scoring_results'] += $stmt->rowCount();
|
||||
}
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
@ -893,9 +934,11 @@ function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.profileID, r.weightedTotal, r.band, r.computedAt, r.questionnaireSnapshot,
|
||||
r.coachBand, r.coachReviewedAt, r.coachReviewedByUserID,
|
||||
u.username AS coachReviewedByUsername,
|
||||
sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
|
||||
FROM client_scoring_profile_result r
|
||||
JOIN scoring_profile sp ON sp.profileID = r.profileID
|
||||
LEFT JOIN users u ON u.userID = r.coachReviewedByUserID
|
||||
WHERE r.clientCode = :cc
|
||||
ORDER BY sp.name'
|
||||
);
|
||||
@ -904,6 +947,8 @@ function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$bands = qdb_normalize_scoring_bands($row);
|
||||
$coachBand = trim((string)($row['coachBand'] ?? ''));
|
||||
$calcBand = (string)$row['band'];
|
||||
$coachDiffers = $coachBand !== '' && $coachBand !== $calcBand;
|
||||
$out[] = [
|
||||
'profileID' => $row['profileID'],
|
||||
'name' => $row['name'],
|
||||
@ -913,6 +958,7 @@ function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array
|
||||
'coachBand' => $coachBand !== '' ? $coachBand : null,
|
||||
'effectiveBand' => qdb_effective_scoring_band($row),
|
||||
'pendingReview' => $coachBand === '',
|
||||
'coachOverride' => $coachDiffers,
|
||||
'greenMin' => $bands['greenMin'],
|
||||
'greenMax' => $bands['greenMax'],
|
||||
'yellowMin' => $bands['yellowMin'],
|
||||
@ -921,6 +967,8 @@ function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array
|
||||
'computedAt' => $row['computedAt'] ? date('Y-m-d H:i', (int)$row['computedAt']) : '',
|
||||
'coachReviewedAt' => !empty($row['coachReviewedAt'])
|
||||
? date('Y-m-d H:i', (int)$row['coachReviewedAt']) : '',
|
||||
'coachReviewedByUsername' => $coachDiffers
|
||||
? (string)($row['coachReviewedByUsername'] ?? '') : '',
|
||||
'snapshot' => json_decode($row['questionnaireSnapshot'] ?? '{}', true) ?: [],
|
||||
];
|
||||
}
|
||||
@ -945,10 +993,13 @@ function qdb_clients_scoring_summary_for_list(PDO $pdo, array $tokenRec): array
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.weightedTotal, sp.name
|
||||
"SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.weightedTotal,
|
||||
r.coachReviewedAt, r.coachReviewedByUserID, u.username AS coachReviewedByUsername,
|
||||
sp.name
|
||||
FROM client_scoring_profile_result r
|
||||
INNER JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
|
||||
INNER JOIN client cl ON cl.clientCode = r.clientCode
|
||||
LEFT JOIN users u ON u.userID = r.coachReviewedByUserID
|
||||
WHERE ($rbacClause)"
|
||||
);
|
||||
$stmt->execute($rbacParams);
|
||||
@ -957,14 +1008,21 @@ function qdb_clients_scoring_summary_for_list(PDO $pdo, array $tokenRec): array
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$cc = (string)$row['clientCode'];
|
||||
$coachBand = trim((string)($row['coachBand'] ?? ''));
|
||||
$calcBand = (string)$row['band'];
|
||||
$coachDiffers = $coachBand !== '' && $coachBand !== $calcBand;
|
||||
$byClient[$cc][$row['profileID']] = [
|
||||
'band' => (string)$row['band'],
|
||||
'calculatedBand' => (string)$row['band'],
|
||||
'band' => $calcBand,
|
||||
'calculatedBand' => $calcBand,
|
||||
'coachBand' => $coachBand !== '' ? $coachBand : null,
|
||||
'effectiveBand' => qdb_effective_scoring_band($row),
|
||||
'pendingReview' => $coachBand === '',
|
||||
'coachOverride' => $coachDiffers,
|
||||
'weightedTotal' => (float)$row['weightedTotal'],
|
||||
'name' => (string)$row['name'],
|
||||
'coachReviewedAt' => !empty($row['coachReviewedAt'])
|
||||
? date('Y-m-d H:i', (int)$row['coachReviewedAt']) : '',
|
||||
'coachReviewedByUsername' => $coachDiffers
|
||||
? (string)($row['coachReviewedByUsername'] ?? '') : '',
|
||||
];
|
||||
}
|
||||
|
||||
@ -992,6 +1050,9 @@ function qdb_client_scoring_dots_for_client(string $clientCode, array $summary):
|
||||
'coachBand' => $result['coachBand'] ?? null,
|
||||
'effectiveBand' => $result['effectiveBand'] ?? null,
|
||||
'pendingReview' => $result['pendingReview'] ?? false,
|
||||
'coachOverride' => $result['coachOverride'] ?? false,
|
||||
'coachReviewedAt' => $result['coachReviewedAt'] ?? '',
|
||||
'coachReviewedByUsername' => $result['coachReviewedByUsername'] ?? '',
|
||||
'weightedTotal' => $result !== null ? $result['weightedTotal'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
11
schema.sql
11
schema.sql
@ -142,7 +142,16 @@ CREATE TABLE IF NOT EXISTS string_translation (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS language (
|
||||
languageCode TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT ''
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questionnaire_language_disable (
|
||||
questionnaireID TEXT NOT NULL,
|
||||
languageCode TEXT NOT NULL,
|
||||
PRIMARY KEY (questionnaireID, languageCode),
|
||||
FOREIGN KEY(questionnaireID) REFERENCES questionnaire(questionnaireID) ON DELETE CASCADE,
|
||||
FOREIGN KEY(languageCode) REFERENCES language(languageCode) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
|
||||
@ -27,7 +27,7 @@ final class AssignmentsActivityLogTest extends QdbTestCase
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$today = gmdate('Y-m-d');
|
||||
$today = date('Y-m-d');
|
||||
$res = $this->api()->withToken($token, 'GET', 'activity-log', null, [
|
||||
'date' => $today,
|
||||
'limit' => '50',
|
||||
|
||||
@ -91,6 +91,43 @@ final class ClientsLifecycleTest extends QdbTestCase
|
||||
$this->assertContains($code, array_column($activeAgain['data']['clients'], 'clientCode'));
|
||||
}
|
||||
|
||||
public function testAdminResetsClient(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$code = $this->fixture()->clientCode;
|
||||
|
||||
$reset = $this->api()->withToken($token, 'POST', 'clients', [
|
||||
'action' => 'reset',
|
||||
'clientCode' => $code,
|
||||
]);
|
||||
$this->assertApiOk($reset);
|
||||
$this->assertTrue($reset['data']['reset'] ?? false);
|
||||
|
||||
$detail = $this->api()->withToken($token, 'GET', 'clients', null, [
|
||||
'clientCode' => $code,
|
||||
'detail' => '1',
|
||||
]);
|
||||
$this->assertApiOk($detail);
|
||||
$this->assertSame($code, $detail['data']['client']['clientCode'] ?? '');
|
||||
$this->assertSame([], $detail['data']['questionnaires'] ?? null);
|
||||
|
||||
$list = $this->api()->withToken($token, 'GET', 'clients');
|
||||
$this->assertApiOk($list);
|
||||
$row = null;
|
||||
foreach ($list['data']['clients'] as $client) {
|
||||
if (($client['clientCode'] ?? '') === $code) {
|
||||
$row = $client;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame(0, (int)($row['hasResponseData'] ?? 1));
|
||||
}
|
||||
|
||||
public function testAdminDeletesClient(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
|
||||
@ -300,8 +300,9 @@ body.logged-in .card > h3 {
|
||||
body.logged-in :is(
|
||||
.question-item,
|
||||
.inline-form-card,
|
||||
.trans-lang-panel,
|
||||
.trans-top-bar,
|
||||
.trans-sidebar-section,
|
||||
.trans-workspace-header,
|
||||
.trans-qn-availability,
|
||||
.trans-sheet,
|
||||
.lang-manager,
|
||||
.condition-editor,
|
||||
@ -335,6 +336,7 @@ body.logged-in :is(
|
||||
.trans-select,
|
||||
.lang-add-code,
|
||||
.lang-add-name,
|
||||
.trans-add-input,
|
||||
.trans-search-input,
|
||||
.followup-note-input,
|
||||
.condition-json-fallback,
|
||||
@ -2115,29 +2117,483 @@ body.modal-open {
|
||||
}
|
||||
|
||||
/* ── Translations page ── */
|
||||
.trans-page { padding: 20px 24px; }
|
||||
.trans-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 24px;
|
||||
margin-bottom: 16px;
|
||||
.trans-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 300px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
.trans-control {
|
||||
.trans-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.trans-sidebar-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow), var(--shadow-inset);
|
||||
}
|
||||
.trans-sidebar-advanced {
|
||||
margin-top: auto;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.trans-sidebar-head { margin-bottom: 10px; }
|
||||
.trans-sidebar-title {
|
||||
margin: 0;
|
||||
font-size: .92rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.trans-sidebar-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: .78rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.trans-lang-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
}
|
||||
.trans-lang-item {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 10px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-muted);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, box-shadow .15s;
|
||||
}
|
||||
button.trans-lang-item { appearance: none; }
|
||||
.trans-lang-item:hover {
|
||||
border-color: var(--primary-border);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.trans-lang-item.is-selected {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-subtle);
|
||||
box-shadow: 0 0 0 1px var(--primary-border);
|
||||
}
|
||||
.trans-lang-item.is-disabled { opacity: .68; }
|
||||
.trans-lang-item-source {
|
||||
cursor: default;
|
||||
background: var(--lang-chip-fixed-bg);
|
||||
border-color: var(--lang-chip-fixed-border);
|
||||
}
|
||||
.trans-lang-item-source:hover {
|
||||
border-color: var(--lang-chip-fixed-border);
|
||||
background: var(--lang-chip-fixed-bg);
|
||||
}
|
||||
.trans-lang-code {
|
||||
font-size: .78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
color: var(--primary);
|
||||
text-align: center;
|
||||
}
|
||||
.trans-lang-meta { min-width: 0; }
|
||||
.trans-lang-name {
|
||||
display: block;
|
||||
font-size: .88rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.trans-lang-sub {
|
||||
display: block;
|
||||
font-size: .72rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.trans-lang-badge {
|
||||
font-size: .65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.trans-lang-badge-source {
|
||||
background: var(--primary-subtle);
|
||||
color: var(--primary);
|
||||
border: 1px solid var(--primary-border);
|
||||
}
|
||||
.trans-lang-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.trans-lang-remove {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity .15s, color .15s, background .15s;
|
||||
}
|
||||
.trans-lang-item:hover .trans-lang-remove,
|
||||
.trans-lang-item.is-selected .trans-lang-remove { opacity: 1; }
|
||||
.trans-lang-remove:hover {
|
||||
color: var(--danger);
|
||||
background: var(--toast-error-bg);
|
||||
}
|
||||
.trans-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
.trans-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.trans-toggle-ui {
|
||||
display: block;
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
transition: background .2s;
|
||||
position: relative;
|
||||
}
|
||||
.trans-toggle-ui::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.15);
|
||||
transition: transform .2s;
|
||||
}
|
||||
.trans-toggle input:checked + .trans-toggle-ui { background: var(--success); }
|
||||
.trans-toggle input:checked + .trans-toggle-ui::after { transform: translateX(14px); }
|
||||
.trans-toggle input:focus-visible + .trans-toggle-ui {
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
.trans-add-lang {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.trans-add-lang-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.trans-add-lang-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.trans-add-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: .85rem;
|
||||
background: var(--surface);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.trans-add-lang-btn {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.trans-add-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
.trans-content-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.trans-content-item {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.trans-content-item:hover { background: var(--surface-muted); }
|
||||
.trans-content-item.is-active {
|
||||
background: var(--primary-subtle);
|
||||
border-color: var(--primary-border);
|
||||
font-weight: 600;
|
||||
}
|
||||
.trans-content-item-all { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.trans-content-icon {
|
||||
font-size: .75rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
.trans-content-label {
|
||||
font-size: .86rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.trans-nav-badge {
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.trans-nav-badge-warn {
|
||||
background: var(--badge-warn-bg);
|
||||
color: var(--badge-warn-fg);
|
||||
}
|
||||
.trans-nav-badge-ok {
|
||||
background: var(--success-subtle, #ecfdf5);
|
||||
color: var(--success);
|
||||
font-size: .8rem;
|
||||
}
|
||||
.trans-advanced-panel { border: none; }
|
||||
.trans-advanced-summary {
|
||||
cursor: pointer;
|
||||
font-size: .85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
.trans-advanced-body { margin-top: 12px; }
|
||||
.trans-advanced-note { font-size: .8rem; margin: 0 0 10px; }
|
||||
.trans-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 10px;
|
||||
font-size: .8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.trans-control span { font-weight: 500; }
|
||||
.trans-field span { font-weight: 500; }
|
||||
.trans-delete-btn { width: 100%; margin-top: 4px; }
|
||||
.trans-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.trans-workspace-header {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 18px;
|
||||
box-shadow: var(--shadow), var(--shadow-inset);
|
||||
}
|
||||
.trans-workspace-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.trans-workspace-title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.trans-workspace-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: .86rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.trans-workspace-subtitle strong { color: var(--text); }
|
||||
.trans-warn { color: var(--trans-missing-accent); font-weight: 600; }
|
||||
.trans-workspace-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.trans-workspace-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.trans-workspace-progress[hidden] { display: none !important; }
|
||||
.trans-progress-track {
|
||||
flex: 1;
|
||||
max-width: 280px;
|
||||
height: 8px;
|
||||
background: var(--progress-track);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.trans-progress-text {
|
||||
font-size: .82rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.trans-progress-pct {
|
||||
margin-left: 6px;
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
}
|
||||
.trans-workspace-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.trans-filter-pills {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
gap: 2px;
|
||||
}
|
||||
.trans-pill {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: .8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.trans-pill:hover { color: var(--text); }
|
||||
.trans-pill.is-active {
|
||||
background: var(--surface);
|
||||
color: var(--primary);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.06);
|
||||
}
|
||||
.trans-qn-availability {
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.trans-qn-availability-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 16px;
|
||||
}
|
||||
.trans-qn-availability-label {
|
||||
font-size: .82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.trans-qn-availability-toggles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.trans-qn-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.trans-qn-pill input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.trans-qn-pill span {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: .75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .03em;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
transition: background .15s, border-color .15s, color .15s;
|
||||
}
|
||||
.trans-qn-pill.is-on span,
|
||||
.trans-qn-pill input:checked + span {
|
||||
background: var(--primary-subtle);
|
||||
border-color: var(--primary-border);
|
||||
color: var(--primary);
|
||||
}
|
||||
.trans-qn-pill:not(.is-on) span { opacity: .55; text-decoration: line-through; }
|
||||
.trans-list-area { min-height: 200px; }
|
||||
.trans-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.trans-empty-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.trans-empty-sidebar {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-size: .82rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
.spinner-sm {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 12px auto;
|
||||
border-width: 2px;
|
||||
}
|
||||
.trans-select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: .9rem;
|
||||
border-radius: 8px;
|
||||
font-size: .88rem;
|
||||
font-family: var(--font);
|
||||
background: var(--surface);
|
||||
}
|
||||
@ -2146,23 +2602,22 @@ body.modal-open {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
.trans-type-select { width: auto; min-width: 140px; flex-shrink: 0; }
|
||||
.trans-lang-panel {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface-muted);
|
||||
.trans-type-select { min-width: 140px; flex-shrink: 0; }
|
||||
.trans-search-input {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
border-radius: 8px;
|
||||
font-size: .88rem;
|
||||
font-family: var(--font);
|
||||
background: var(--surface);
|
||||
}
|
||||
.trans-lang-panel summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: .9rem;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
.trans-search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
.trans-lang-panel[open] summary { margin-bottom: 12px; }
|
||||
.trans-lang-panel .lang-chips { margin-bottom: 10px; }
|
||||
.trans-hint { padding: 24px 0; text-align: center; font-size: .9rem; }
|
||||
.text-muted { color: var(--text-secondary); }
|
||||
.required-mark { color: var(--danger); font-weight: 600; }
|
||||
@ -2172,8 +2627,9 @@ body.modal-open {
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 420px);
|
||||
max-height: calc(100vh - 320px);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow), var(--shadow-inset);
|
||||
}
|
||||
.trans-sheet-body {
|
||||
overflow-y: auto;
|
||||
@ -2205,43 +2661,12 @@ body.modal-open {
|
||||
z-index: 2;
|
||||
box-shadow: 0 1px 0 var(--border);
|
||||
}
|
||||
.trans-top-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px 20px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.trans-top-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 20px;
|
||||
font-size: .88rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.trans-top-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.trans-save-status {
|
||||
font-size: .82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.trans-save-status[data-state="saving"] { color: var(--primary); }
|
||||
.trans-save-status[data-state="error"] { color: var(--danger); }
|
||||
.trans-toolbar-field {
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
margin: 0;
|
||||
}
|
||||
.trans-empty-hint { margin-top: 12px; }
|
||||
.trans-qn-block {
|
||||
margin-bottom: 28px;
|
||||
@ -2256,9 +2681,7 @@ body.modal-open {
|
||||
color: var(--text);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.trans-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.trans-group { margin-bottom: 18px; }
|
||||
.trans-group-hidden { display: none !important; }
|
||||
.trans-group-title {
|
||||
font-size: .8rem;
|
||||
@ -2298,9 +2721,7 @@ body.modal-open {
|
||||
content: '▸ ';
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.trans-group-details[open] > .trans-group-summary::before {
|
||||
content: '▾ ';
|
||||
}
|
||||
.trans-group-details[open] > .trans-group-summary::before { content: '▾ '; }
|
||||
.trans-qn-section {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
@ -2325,9 +2746,7 @@ body.modal-open {
|
||||
content: '▸ ';
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.trans-qn-section[open] > .trans-qn-summary::before {
|
||||
content: '▾ ';
|
||||
}
|
||||
.trans-qn-section[open] > .trans-qn-summary::before { content: '▾ '; }
|
||||
.trans-sheet-body-stack .trans-group-summary {
|
||||
font-size: .78rem;
|
||||
padding: 8px 14px 8px 22px;
|
||||
@ -2372,9 +2791,7 @@ body.modal-open {
|
||||
.trans-sheet-body > .trans-groups-flat .trans-group-details:last-child .trans-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.trans-list-item:nth-child(even) {
|
||||
background: var(--table-stripe-bg);
|
||||
}
|
||||
.trans-list-item:nth-child(even) { background: var(--table-stripe-bg); }
|
||||
.trans-list-item:hover { background: var(--row-hover-bg); }
|
||||
.trans-list-item-missing {
|
||||
background: var(--trans-missing-bg);
|
||||
@ -2394,9 +2811,7 @@ body.modal-open {
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
.trans-list-source {
|
||||
min-width: 0;
|
||||
}
|
||||
.trans-list-source { min-width: 0; }
|
||||
.trans-source-text {
|
||||
display: block;
|
||||
font-size: .9rem;
|
||||
@ -2424,6 +2839,19 @@ body.modal-open {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.trans-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.trans-sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
.trans-sheet {
|
||||
max-height: calc(100vh - 380px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.trans-list-header,
|
||||
.trans-list-item {
|
||||
@ -2449,12 +2877,13 @@ body.modal-open {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.trans-list-item .trans-cell-input[data-field="translation"] {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.trans-list-item .trans-cell-input[data-field="translation"] { margin-top: 4px; }
|
||||
.trans-workspace-filters { flex-direction: column; align-items: stretch; }
|
||||
.trans-filter-pills { align-self: flex-start; }
|
||||
.trans-add-lang-fields { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Language manager ── */
|
||||
/* ── Language manager (editor) ── */
|
||||
.lang-manager {
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border);
|
||||
@ -2490,6 +2919,16 @@ body.modal-open {
|
||||
line-height: 1;
|
||||
}
|
||||
.lang-chip-remove:hover { color: var(--danger); }
|
||||
.lang-chip-disabled { opacity: .72; }
|
||||
.lang-chip-badge {
|
||||
font-size: .7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.lang-add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@ -2510,35 +2949,7 @@ body.modal-open {
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
/* ── Translations toolbar ── */
|
||||
.trans-toolbar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.trans-search-wrap { flex: 1; min-width: 200px; }
|
||||
.trans-search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: .9rem;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.trans-search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
.trans-filter-types {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Translations table ── */
|
||||
/* ── Translations table (legacy/editor) ── */
|
||||
.trans-table-wrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
@ -2643,27 +3054,6 @@ body.modal-open {
|
||||
animation: saveFlash .4s ease;
|
||||
}
|
||||
|
||||
/* ── Stats bar ── */
|
||||
.trans-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
font-size: .82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.trans-stats-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.trans-progress-bar {
|
||||
width: 120px;
|
||||
height: 6px;
|
||||
background: var(--progress-track);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.trans-progress-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
@ -3087,6 +3477,15 @@ body.logged-in .insights-chart-card.card:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.table-row-actions .reset-client-btn {
|
||||
color: #b45309;
|
||||
border-color: color-mix(in srgb, #b45309 35%, transparent);
|
||||
}
|
||||
|
||||
.table-row-actions .reset-client-btn:hover {
|
||||
background: color-mix(in srgb, #b45309 10%, transparent);
|
||||
}
|
||||
|
||||
.table-row-actions .delete-client-btn {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
|
||||
@ -34,15 +34,19 @@ export async function confirmAndPatchClientArchive(clientCode, archived) {
|
||||
* @param {string} opts.clientCode Escaped client code for data-code attributes
|
||||
* @param {boolean} opts.archived
|
||||
* @param {boolean} [opts.showDelete]
|
||||
* @param {boolean} [opts.showReset]
|
||||
* @param {string} [opts.rawCode] Unescaped code for button labels (optional)
|
||||
*/
|
||||
export function clientTableActionsHTML({ clientCode, archived, showDelete = false }) {
|
||||
export function clientTableActionsHTML({ clientCode, archived, showDelete = false, showReset = false }) {
|
||||
const archiveBtn = archived
|
||||
? `<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>`;
|
||||
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
|
||||
? `<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
31
website/js/datetime.js
Normal 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) : '';
|
||||
}
|
||||
@ -197,6 +197,9 @@ function renderClientTableBody() {
|
||||
body.querySelectorAll('.delete-client-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteClient(btn.dataset.code));
|
||||
});
|
||||
body.querySelectorAll('.reset-client-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => resetClient(btn.dataset.code));
|
||||
});
|
||||
body.querySelectorAll('.archive-client-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => setClientArchived(btn.dataset.code, true));
|
||||
});
|
||||
@ -413,6 +416,9 @@ function profileScoreBlockHTML(p, clientCode = '') {
|
||||
<span class="client-profile-score-label">Counselor</span>
|
||||
${coachControl}
|
||||
${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>`;
|
||||
}
|
||||
@ -431,7 +437,10 @@ function renderClientScoringProfiles(profiles, clientCode = '') {
|
||||
const coachPending = p.pendingReview !== false && !p.coachBand;
|
||||
const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : '';
|
||||
const reviewedAt = p.coachReviewedAt ? `Counselor reviewed ${esc(p.coachReviewedAt)}` : '';
|
||||
const meta = [computedAt, reviewedAt].filter(Boolean).join(' · ');
|
||||
const overrideMeta = p.coachOverride && p.coachReviewedByUsername
|
||||
? `Overridden by ${esc(p.coachReviewedByUsername)}${p.coachReviewedAt ? ` · ${esc(p.coachReviewedAt)}` : ''}`
|
||||
: '';
|
||||
const meta = [computedAt, reviewedAt, overrideMeta].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<div class="scoring-profile-detail-card">
|
||||
${profileScoreBlockHTML(p, clientCode)}
|
||||
@ -732,6 +741,7 @@ function clientRowHTML(c) {
|
||||
clientCode: esc(c.clientCode),
|
||||
archived,
|
||||
showDelete: true,
|
||||
showReset: clientHasResponseData(c),
|
||||
});
|
||||
|
||||
return `
|
||||
@ -818,6 +828,44 @@ async function setClientArchived(clientCode, archived) {
|
||||
else renderClientTableBody();
|
||||
}
|
||||
|
||||
async function resetClient(clientCode) {
|
||||
if (!(await confirmAction({
|
||||
title: 'Reset client',
|
||||
message: `Reset client "${clientCode}"? This clears all questionnaire answers, upload history, and scoring results. The client code and counselor assignment are kept.`,
|
||||
confirmLabel: 'Reset',
|
||||
variant: 'danger',
|
||||
}))) return;
|
||||
try {
|
||||
await apiPost('clients.php', { action: 'reset', clientCode });
|
||||
const row = clientsList.find(c => c.clientCode === clientCode);
|
||||
if (row) {
|
||||
row.hasResponseData = 0;
|
||||
row.scoringProfiles = (row.scoringProfiles || []).map(p => ({
|
||||
...p,
|
||||
band: null,
|
||||
calculatedBand: null,
|
||||
coachBand: null,
|
||||
effectiveBand: null,
|
||||
pendingReview: false,
|
||||
coachOverride: false,
|
||||
coachReviewedAt: '',
|
||||
coachReviewedByUsername: '',
|
||||
weightedTotal: null,
|
||||
}));
|
||||
}
|
||||
delete detailByClient[clientCode];
|
||||
if (expandedClientCode === clientCode) {
|
||||
expandedClientCode = null;
|
||||
expandedQnKey = null;
|
||||
expandedVersionKey = null;
|
||||
}
|
||||
showToast(`Client "${clientCode}" reset`, 'success');
|
||||
renderClientTableBody();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClient(clientCode) {
|
||||
if (!(await confirmAction({
|
||||
title: 'Delete client',
|
||||
|
||||
@ -2,6 +2,7 @@ import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin, apiUrl } fr
|
||||
import { getRole, pageHeaderHTML, showToast } from '../app.js';
|
||||
import { confirmAction } from '../confirm-modal.js';
|
||||
import { navigate } from '../router.js';
|
||||
import { berlinDateInputValue, formatBerlinDateTime } from '../datetime.js';
|
||||
|
||||
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
|
||||
|
||||
@ -20,7 +21,7 @@ export function devPage() {
|
||||
}
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = berlinDateInputValue();
|
||||
app.innerHTML = `
|
||||
${pageHeaderHTML('Admin Settings')}
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time (UTC)</th>
|
||||
<th>Time</th>
|
||||
<th>Kind</th>
|
||||
<th>Subject</th>
|
||||
<th>Method</th>
|
||||
@ -326,7 +327,7 @@ async function loadActivityLog() {
|
||||
const meta = document.getElementById('activityLogMeta');
|
||||
if (!tbody) return;
|
||||
|
||||
const date = document.getElementById('activityLogDate')?.value || new Date().toISOString().slice(0, 10);
|
||||
const date = document.getElementById('activityLogDate')?.value || berlinDateInputValue();
|
||||
const activity = document.getElementById('activityLogKind')?.value || '';
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:20px">Loading…</td></tr>';
|
||||
|
||||
@ -450,13 +451,8 @@ function formatActivityChangeValue(value) {
|
||||
|
||||
function formatActivityTime(iso) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
const formatted = formatBerlinDateTime(iso);
|
||||
return formatted || iso;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { apiGet, apiDownloadFetch, apiUrl } from '../api.js';
|
||||
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
|
||||
import { berlinDateInputValue } from '../datetime.js';
|
||||
let questionnairesList = [];
|
||||
let filterSearch = '';
|
||||
|
||||
@ -162,7 +163,7 @@ function wireExportActions() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const stamp = berlinDateInputValue();
|
||||
a.download = `questionnaires_bundle_${stamp}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
@ -215,7 +216,7 @@ async function onExportAllVersionsClick(ev) {
|
||||
btn.textContent = 'Downloading…';
|
||||
try {
|
||||
const url = apiUrl(`export?questionnaireID=${encodeURIComponent(btn.dataset.id)}&allVersions=1`);
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const stamp = berlinDateInputValue();
|
||||
await downloadExportCsv(btn, url, `${btn.dataset.name}_all_versions_${stamp}.csv`);
|
||||
showToast('All versions download started', 'success');
|
||||
} catch (e) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { apiGet } from '../api.js';
|
||||
import { homeNavButton, showToast } from '../app.js';
|
||||
import { buildResultColumns, resultColumnCellValue } from '../test-data.js';
|
||||
import { formatBerlinDateTimeShort } from '../datetime.js';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
@ -9,6 +10,7 @@ let sortDir = 'asc';
|
||||
let allClients = [];
|
||||
let questionsDef = [];
|
||||
let resultColumns = [];
|
||||
let scoringProfileCatalog = [];
|
||||
let questionnaireMeta = null;
|
||||
let filterCoach = '';
|
||||
let filterSupervisor = '';
|
||||
@ -52,6 +54,7 @@ export async function resultsPage(params) {
|
||||
const data = await apiGet(`results.php?questionnaireID=${encodeURIComponent(params.id)}`);
|
||||
questionnaireMeta = data.questionnaire;
|
||||
questionsDef = data.questions || [];
|
||||
scoringProfileCatalog = data.scoringProfiles || [];
|
||||
allClients = data.clients || [];
|
||||
document.getElementById('resultsTitle').textContent = `Results: ${questionnaireMeta.name} v${questionnaireMeta.version}`;
|
||||
renderResults();
|
||||
@ -181,6 +184,40 @@ function renderResults() {
|
||||
renderTableRows();
|
||||
}
|
||||
|
||||
function scoringTableColumns() {
|
||||
const cols = [];
|
||||
for (const profile of scoringProfileCatalog) {
|
||||
const name = profile.name || profile.profileID;
|
||||
cols.push(
|
||||
{ key: `score_${profile.profileID}_calc`, label: `${name} calculated`, profileID: profile.profileID, field: 'calculated', title: `${name} — server-calculated category` },
|
||||
{ key: `score_${profile.profileID}_coach`, label: `${name} counselor`, profileID: profile.profileID, field: 'counselor', title: `${name} — counselor category` },
|
||||
{ key: `score_${profile.profileID}_by`, label: `${name} override by`, profileID: profile.profileID, field: 'override_by', title: `${name} — who overrode the calculated category` },
|
||||
{ key: `score_${profile.profileID}_at`, label: `${name} override at`, profileID: profile.profileID, field: 'override_at', title: `${name} — when the override was set` },
|
||||
);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
function scoringCellValue(client, profileID, field) {
|
||||
const p = (client.scoringProfiles || []).find(x => x.profileID === profileID);
|
||||
if (!p) return '';
|
||||
const calc = p.calculatedBand || p.band || '';
|
||||
const coach = p.coachBand || '';
|
||||
const differs = Boolean(p.coachOverride) || (coach && coach !== calc);
|
||||
switch (field) {
|
||||
case 'calculated':
|
||||
return calc;
|
||||
case 'counselor':
|
||||
return coach;
|
||||
case 'override_by':
|
||||
return differs ? (p.coachReviewedByUsername || '') : '';
|
||||
case 'override_at':
|
||||
return differs ? (p.coachReviewedAt || '') : '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTableHead() {
|
||||
const head = document.getElementById('resultsHead');
|
||||
const fixedCols = [
|
||||
@ -191,7 +228,8 @@ function renderTableHead() {
|
||||
{ key: 'sumPoints', label: 'Score' },
|
||||
{ key: 'completedAt', label: 'Completed' },
|
||||
];
|
||||
const allCols = [...fixedCols, ...resultColumns.map(col => ({
|
||||
const scoringCols = scoringTableColumns();
|
||||
const allCols = [...fixedCols, ...scoringCols, ...resultColumns.map(col => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
title: col.title,
|
||||
@ -381,6 +419,11 @@ function renderTableRows() {
|
||||
return `<td>${esc(String(val))}</td>`;
|
||||
}).join('');
|
||||
|
||||
const scoringCells = scoringTableColumns().map(col => {
|
||||
const val = scoringCellValue(c, col.profileID, col.field);
|
||||
return `<td>${val ? esc(String(val)) : '—'}</td>`;
|
||||
}).join('');
|
||||
|
||||
return `<tr>
|
||||
<td class="sticky-col">${esc(c.clientCode)}</td>
|
||||
<td>${esc(coachDisplayName(c))}</td>
|
||||
@ -388,6 +431,7 @@ function renderTableRows() {
|
||||
<td>${statusBadge}</td>
|
||||
<td>${c.sumPoints !== null ? c.sumPoints : '—'}</td>
|
||||
<td>${completedDate}</td>
|
||||
${scoringCells}
|
||||
${questionCells}
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@ -400,6 +444,10 @@ function getCellValue(client, key) {
|
||||
if (key === 'status') return client.status;
|
||||
if (key === 'sumPoints') return client.sumPoints;
|
||||
if (key === 'completedAt') return client.completedAt;
|
||||
const scoringCol = scoringTableColumns().find(c => c.key === key);
|
||||
if (scoringCol) {
|
||||
return scoringCellValue(client, scoringCol.profileID, scoringCol.field);
|
||||
}
|
||||
const col = resultColumns.find(c => c.key === key);
|
||||
if (col) {
|
||||
const ans = client.answers && client.answers[col.questionID];
|
||||
@ -424,19 +472,23 @@ function exportCSV() {
|
||||
});
|
||||
|
||||
const headers = ['clientCode', 'Counselor', 'supervisor', 'status', 'sumPoints', 'completedAt',
|
||||
...scoringTableColumns().map(col => col.label),
|
||||
...resultColumns.map(col => col.label)];
|
||||
|
||||
const rows = clients.map(c => {
|
||||
const base = [
|
||||
c.clientCode, coachDisplayName(c), c.supervisorUsername || '', c.status, c.sumPoints ?? '',
|
||||
c.completedAt ? new Date(c.completedAt * 1000).toISOString() : ''
|
||||
c.completedAt ? formatBerlinDateTimeShort(c.completedAt * 1000) : ''
|
||||
];
|
||||
const scoringCols = scoringTableColumns().map(col =>
|
||||
scoringCellValue(c, col.profileID, col.field)
|
||||
);
|
||||
const answerCols = resultColumns.map(col => {
|
||||
const ans = c.answers && c.answers[col.questionID];
|
||||
const val = resultColumnCellValue(col, ans, optionMap);
|
||||
return val != null ? val : '';
|
||||
});
|
||||
return [...base, ...answerCols];
|
||||
return [...base, ...scoringCols, ...answerCols];
|
||||
});
|
||||
|
||||
let csv = '\uFEFF'; // BOM
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
normalizeEntry,
|
||||
translationFor,
|
||||
targetLanguages,
|
||||
isLanguageGloballyEnabled,
|
||||
sourceLanguageLabel,
|
||||
translationListHeaderHTML,
|
||||
buildTranslationGroups,
|
||||
@ -32,15 +33,19 @@ let savesInFlight = 0;
|
||||
export async function translationsPage() {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `
|
||||
${pageHeaderHTML('Translations', '<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>
|
||||
`;
|
||||
|
||||
if (canEdit()) {
|
||||
document.getElementById('transHeaderActions').innerHTML = `
|
||||
<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 btn-primary" id="exportTransBundleBtn">Export translations</button>
|
||||
<button type="button" class="btn" id="importTransBundleBtn" title="Import JSON bundle">Import</button>
|
||||
<button type="button" class="btn btn-primary" id="exportTransBundleBtn" title="Download JSON bundle">Export</button>
|
||||
`;
|
||||
bindTranslationBundleActions();
|
||||
}
|
||||
@ -56,47 +61,49 @@ export async function translationsPage() {
|
||||
|
||||
function renderShell() {
|
||||
document.getElementById('transPageRoot').innerHTML = `
|
||||
<div class="card trans-page">
|
||||
<div class="trans-controls">
|
||||
<label class="trans-control">
|
||||
<span>Translate into</span>
|
||||
<select id="transLangSelect" class="trans-select" disabled>
|
||||
<option value="">—</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="trans-layout">
|
||||
<aside class="trans-sidebar" id="transSidebar">
|
||||
<section class="trans-sidebar-section">
|
||||
<div class="trans-sidebar-head">
|
||||
<h2 class="trans-sidebar-title">Languages</h2>
|
||||
<p class="trans-sidebar-hint">Select a language to translate into. Toggle to show or hide in the app.</p>
|
||||
</div>
|
||||
<div id="transLangSidebar" class="trans-lang-list">
|
||||
<div class="spinner spinner-sm"></div>
|
||||
</div>
|
||||
<div id="transAddLang" class="trans-add-lang"></div>
|
||||
</section>
|
||||
|
||||
<details class="trans-lang-panel">
|
||||
<summary>More languages</summary>
|
||||
<div id="transLangPanel"><span class="text-muted">Loading…</span></div>
|
||||
<section class="trans-sidebar-section">
|
||||
<div class="trans-sidebar-head">
|
||||
<h2 class="trans-sidebar-title">Content</h2>
|
||||
<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>
|
||||
|
||||
<section class="trans-sidebar-section trans-sidebar-advanced">
|
||||
<details class="trans-advanced-panel" id="transAdvancedPanel">
|
||||
<summary class="trans-advanced-summary">Advanced</summary>
|
||||
<div id="transAdvancedBody" class="trans-advanced-body">
|
||||
<p class="text-muted trans-advanced-note">
|
||||
Bulk-delete translated text. German source is never removed.
|
||||
</p>
|
||||
<div id="transDeleteForm"></div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div class="trans-toolbar">
|
||||
<label class="trans-control trans-toolbar-field">
|
||||
<span>Content</span>
|
||||
<select id="transScopeSelect" class="trans-select" disabled>
|
||||
<option value="">Loading…</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="trans-control trans-toolbar-field">
|
||||
<span>Show</span>
|
||||
<select id="transShowMode" class="trans-select trans-type-select" disabled>
|
||||
<option value="missing">Missing only</option>
|
||||
<option value="all">All</option>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div id="transListArea"><div class="spinner"></div></div>
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -105,72 +112,72 @@ function pickDefaultTargetLang(languages) {
|
||||
const targets = targetLanguages(languages);
|
||||
if (!targets.length) return '';
|
||||
const stored = localStorage.getItem(STORAGE_LANG);
|
||||
if (stored && targets.some(l => l.languageCode === stored)) {
|
||||
return stored;
|
||||
}
|
||||
if (selectedLang && targets.some(l => l.languageCode === selectedLang)) {
|
||||
return selectedLang;
|
||||
}
|
||||
if (stored && targets.some(l => l.languageCode === stored)) return stored;
|
||||
if (selectedLang && targets.some(l => l.languageCode === selectedLang)) return selectedLang;
|
||||
return targets[0].languageCode;
|
||||
}
|
||||
|
||||
function pickDefaultShowMode() {
|
||||
const stored = localStorage.getItem(STORAGE_SHOW);
|
||||
if (stored === 'missing' || stored === 'all' || stored === 'complete') {
|
||||
return stored;
|
||||
}
|
||||
if (stored === 'missing' || stored === 'all' || stored === 'complete') return stored;
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
function pickDefaultScope(sections, entries, lang) {
|
||||
const stored = localStorage.getItem(STORAGE_SCOPE);
|
||||
if (stored === '__all__' || sections.some(s => s.questionnaireID === stored)) {
|
||||
return stored;
|
||||
}
|
||||
if (stored === '__all__' || sections.some(s => s.questionnaireID === stored)) return stored;
|
||||
for (const sec of sections) {
|
||||
const slice = sectionEntries(entries, sec);
|
||||
if (countMissing(slice, lang) > 0) {
|
||||
return sec.questionnaireID;
|
||||
}
|
||||
if (countMissing(sectionEntries(entries, sec), lang) > 0) return sec.questionnaireID;
|
||||
}
|
||||
return sections[0]?.questionnaireID || '__app__';
|
||||
}
|
||||
|
||||
function scopeLabel(sec, entries, lang) {
|
||||
const slice = sectionEntries(entries, sec);
|
||||
const missing = countMissing(slice, lang);
|
||||
const suffix = missing ? ` (${missing} missing)` : ' (complete)';
|
||||
return `${sec.name}${suffix}`;
|
||||
const missing = countMissing(sectionEntries(entries, sec), lang);
|
||||
return { name: sec.name, missing, complete: missing === 0 };
|
||||
}
|
||||
|
||||
function scopedSections() {
|
||||
const sections = transData?.sections || [];
|
||||
if (!selectedScope || selectedScope === '__all__') {
|
||||
return sections;
|
||||
}
|
||||
if (!selectedScope || selectedScope === '__all__') return sections;
|
||||
return sections.filter(s => s.questionnaireID === selectedScope);
|
||||
}
|
||||
|
||||
function entriesInScope() {
|
||||
const sections = scopedSections();
|
||||
const out = [];
|
||||
for (const sec of sections) {
|
||||
out.push(...sectionEntries(allEntries, sec));
|
||||
}
|
||||
for (const sec of sections) out.push(...sectionEntries(allEntries, sec));
|
||||
return out;
|
||||
}
|
||||
|
||||
function currentScopeMeta() {
|
||||
const sections = transData?.sections || [];
|
||||
if (selectedScope === '__all__') return { id: '__all__', name: 'All content', isApp: false, isQuestionnaire: false };
|
||||
const sec = sections.find(s => s.questionnaireID === selectedScope);
|
||||
if (!sec) return { id: selectedScope, name: 'Content', isApp: false, isQuestionnaire: false };
|
||||
return {
|
||||
id: sec.questionnaireID,
|
||||
name: sec.name,
|
||||
isApp: !!sec.isApp,
|
||||
isQuestionnaire: !sec.isApp && sec.questionnaireID !== '__app__',
|
||||
};
|
||||
}
|
||||
|
||||
function currentLangMeta() {
|
||||
const languages = transData?.languages || [];
|
||||
const row = languages.find(l => l.languageCode === selectedLang);
|
||||
return {
|
||||
code: selectedLang,
|
||||
name: row?.name || selectedLang.toUpperCase(),
|
||||
enabled: row ? isLanguageGloballyEnabled(row) : true,
|
||||
};
|
||||
}
|
||||
|
||||
function setSaveStatus(state) {
|
||||
const el = document.getElementById('transSaveStatus');
|
||||
if (!el) return;
|
||||
el.dataset.state = state;
|
||||
if (state === 'saving') {
|
||||
el.textContent = 'Saving…';
|
||||
} else if (state === 'error') {
|
||||
el.textContent = 'Save failed';
|
||||
} else {
|
||||
el.textContent = 'All changes saved';
|
||||
}
|
||||
el.textContent = state === 'saving' ? 'Saving…' : state === 'error' ? 'Save failed' : 'All changes saved';
|
||||
}
|
||||
|
||||
async function ensureGermanLanguage() {
|
||||
@ -181,10 +188,6 @@ async function ensureGermanLanguage() {
|
||||
|
||||
async function loadTranslations() {
|
||||
const listArea = document.getElementById('transListArea');
|
||||
const langSelect = document.getElementById('transLangSelect');
|
||||
const filter = document.getElementById('transFilter');
|
||||
const typeFilter = document.getElementById('transTypeFilter');
|
||||
|
||||
if (listArea) listArea.innerHTML = '<div class="spinner"></div>';
|
||||
saveTimers = {};
|
||||
|
||||
@ -203,93 +206,137 @@ async function loadTranslations() {
|
||||
showMode = pickDefaultShowMode();
|
||||
selectedScope = pickDefaultScope(flat.sections, allEntries, selectedLang);
|
||||
|
||||
if (langSelect) {
|
||||
const targets = targetLanguages(languages);
|
||||
langSelect.disabled = !targets.length;
|
||||
langSelect.innerHTML = targets.length
|
||||
? targets.map(l => {
|
||||
const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode;
|
||||
return `<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);
|
||||
renderLanguageSidebar(languages);
|
||||
renderContentNav();
|
||||
renderDeleteForm();
|
||||
renderWorkspaceHeader();
|
||||
renderQnAvailabilityStrip();
|
||||
renderEntryList();
|
||||
bindFilters();
|
||||
setSaveStatus('saved');
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
|
||||
function renderLanguagePanel(languages) {
|
||||
const panel = document.getElementById('transLangPanel');
|
||||
if (!panel) return;
|
||||
function renderLanguageSidebar(languages) {
|
||||
const list = document.getElementById('transLangSidebar');
|
||||
const addBox = document.getElementById('transAddLang');
|
||||
if (!list || !addBox) return;
|
||||
|
||||
const others = targetLanguages(languages);
|
||||
const source = languages.find(l => l.languageCode === SOURCE_LANG);
|
||||
|
||||
panel.innerHTML = `
|
||||
<p class="text-muted" style="font-size:.85rem;margin:0 0 10px">
|
||||
German source text is edited in the questionnaire editor. Add other languages here.
|
||||
</p>
|
||||
<div class="lang-chips">
|
||||
<span class="lang-chip lang-chip-fixed">
|
||||
<strong>DE</strong>
|
||||
<span class="lang-chip-name">German (source)</span>
|
||||
list.innerHTML = `
|
||||
<div class="trans-lang-item trans-lang-item-source" aria-disabled="true">
|
||||
<span class="trans-lang-code">DE</span>
|
||||
<span class="trans-lang-meta">
|
||||
<span class="trans-lang-name">${esc(source?.name || 'German')}</span>
|
||||
<span class="trans-lang-sub">Source language</span>
|
||||
</span>
|
||||
${others.map(l => `
|
||||
<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">×</button>
|
||||
<span class="trans-lang-badge trans-lang-badge-source">Source</span>
|
||||
</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>
|
||||
`).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>
|
||||
<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">×</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 () => {
|
||||
const code = document.getElementById('newLangCode').value.trim().toLowerCase();
|
||||
const name = document.getElementById('newLangName').value.trim();
|
||||
if (!code) { showToast('Language code is required', 'error'); return; }
|
||||
if (code === SOURCE_LANG) {
|
||||
showToast('German (de) is always available as the source language', 'error');
|
||||
showToast('German (de) is always the source language', 'error');
|
||||
return;
|
||||
}
|
||||
if (languages.some(l => l.languageCode === code)) {
|
||||
@ -301,48 +348,285 @@ function renderLanguagePanel(languages) {
|
||||
showToast(`Language "${code}" added`, 'success');
|
||||
selectedLang = code;
|
||||
await loadTranslations();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
panel.querySelectorAll('.lang-chip-remove').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const lc = btn.dataset.lc;
|
||||
if (!(await confirmAction({
|
||||
title: 'Remove language',
|
||||
message: `Remove "${lc}" and all its translations?`,
|
||||
confirmLabel: 'Remove',
|
||||
variant: 'danger',
|
||||
}))) return;
|
||||
function renderContentNav() {
|
||||
const nav = document.getElementById('transContentNav');
|
||||
if (!nav || !transData) return;
|
||||
|
||||
const sections = transData.sections || [];
|
||||
const items = sections.map(sec => {
|
||||
const { name, missing, complete } = scopeLabel(sec, allEntries, selectedLang);
|
||||
const active = sec.questionnaireID === selectedScope;
|
||||
const badge = missing
|
||||
? `<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 {
|
||||
await apiDelete('translations.php', { type: 'language', languageCode: lc });
|
||||
showToast(`Language "${lc}" removed`, 'success');
|
||||
if (selectedLang === lc) selectedLang = '';
|
||||
await loadTranslations();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
await apiPut('translations.php', {
|
||||
type: 'questionnaire_language',
|
||||
questionnaireID: qid,
|
||||
languageCode: lc,
|
||||
enabled,
|
||||
});
|
||||
setQuestionnaireLanguageDisabled(qid, lc, !enabled);
|
||||
renderQnAvailabilityStrip();
|
||||
showToast(
|
||||
enabled ? `${lc} enabled for this questionnaire` : `${lc} hidden for this questionnaire`,
|
||||
'success'
|
||||
);
|
||||
} catch (err) {
|
||||
input.checked = !enabled;
|
||||
showToast(err.message, 'error');
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function populateScopeSelect() {
|
||||
const scopeSelect = document.getElementById('transScopeSelect');
|
||||
if (!scopeSelect || !transData) return;
|
||||
|
||||
const sections = transData.sections || [];
|
||||
const options = sections.map(sec =>
|
||||
`<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);
|
||||
function renderDeleteForm() {
|
||||
const form = document.getElementById('transDeleteForm');
|
||||
if (!form || !transData || !canEdit()) {
|
||||
if (form) form.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const languages = targetLanguages(transData.languages || []);
|
||||
const sections = (transData.sections || []).filter(sec => sec.questionnaireID !== '__app__');
|
||||
const defaultScope = selectedScope && selectedScope !== '__all__' ? selectedScope : '__app__';
|
||||
|
||||
form.innerHTML = `
|
||||
<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() {
|
||||
@ -355,30 +639,33 @@ function renderEntryList() {
|
||||
|
||||
if (!targets.length) {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (!transData.sections?.length) {
|
||||
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;
|
||||
}
|
||||
|
||||
const sourceLabel = sourceLanguageLabel(languages);
|
||||
const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase();
|
||||
const scopeEntries = entriesInScope();
|
||||
const missingCount = countMissing(scopeEntries, selectedLang);
|
||||
const pct = scopeEntries.length
|
||||
? Math.round(((scopeEntries.length - missingCount) / scopeEntries.length) * 100)
|
||||
: 0;
|
||||
|
||||
const headerOnce = translationListHeaderHTML(langLabel, sourceLabel);
|
||||
const useQnDetails = selectedScope === '__all__';
|
||||
|
||||
const sectionsHtml = sections.map(sec => {
|
||||
@ -393,9 +680,7 @@ function renderEntryList() {
|
||||
const open = secMissing > 0 && sections.filter(s =>
|
||||
countMissing(sectionEntries(allEntries, s), selectedLang) > 0
|
||||
)[0]?.questionnaireID === sec.questionnaireID;
|
||||
const summary = secMissing
|
||||
? `${sec.name} · ${secMissing} missing`
|
||||
: `${sec.name} · complete`;
|
||||
const summary = secMissing ? `${sec.name} · ${secMissing} missing` : `${sec.name} · complete`;
|
||||
return `
|
||||
<details class="trans-qn-section"${open ? ' open' : ''} data-qn="${esc(sec.questionnaireID)}">
|
||||
<summary class="trans-qn-summary">${esc(summary)}</summary>
|
||||
@ -407,27 +692,12 @@ function renderEntryList() {
|
||||
}).join('');
|
||||
|
||||
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">
|
||||
${headerOnce}
|
||||
${translationListHeaderHTML(langLabel, sourceLabel)}
|
||||
<div id="transGroupedRoot" class="trans-sheet-body${useQnDetails ? ' trans-sheet-body-stack' : ''}">${sectionsHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('transJumpMissingBtn')?.addEventListener('click', jumpToNextMissing);
|
||||
|
||||
bindEntryEditing(allEntries);
|
||||
applyFilters();
|
||||
}
|
||||
@ -458,8 +728,7 @@ function jumpToNextMissing() {
|
||||
}
|
||||
const target = rows[start] || rows[0];
|
||||
openAncestors(target);
|
||||
const inp = target.querySelector('[data-field="translation"]');
|
||||
inp?.focus({ preventScroll: false });
|
||||
target.querySelector('[data-field="translation"]')?.focus({ preventScroll: false });
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
@ -518,7 +787,8 @@ async function saveTranslation(entry, inp, isGerman = false) {
|
||||
savesInFlight = Math.max(0, savesInFlight - 1);
|
||||
if (savesInFlight === 0) {
|
||||
setSaveStatus('saved');
|
||||
populateScopeSelect();
|
||||
renderContentNav();
|
||||
renderLanguageSidebar(transData?.languages || []);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -531,8 +801,10 @@ function updateProgress() {
|
||||
: 0;
|
||||
const fill = document.querySelector('.trans-progress-fill');
|
||||
if (fill) fill.style.width = `${pct}%`;
|
||||
const label = document.querySelector('.trans-progress-label');
|
||||
if (label) label.textContent = `${pct}% in ${selectedLang.toUpperCase()}`;
|
||||
const track = document.querySelector('.trans-progress-track');
|
||||
if (track) track.setAttribute('aria-valuenow', String(pct));
|
||||
const pctEl = document.querySelector('.trans-progress-pct');
|
||||
if (pctEl) pctEl.textContent = `${pct}% complete`;
|
||||
if (!document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) {
|
||||
updateVisibleCountSummary();
|
||||
}
|
||||
@ -544,14 +816,20 @@ function updateVisibleCountSummary(visible = null, visibleMissing = null) {
|
||||
const scopeEntries = entriesInScope();
|
||||
const text = document.getElementById('transFilter')?.value || '';
|
||||
const type = document.getElementById('transTypeFilter')?.value || '';
|
||||
const missing = countMissing(scopeEntries, selectedLang);
|
||||
const pct = scopeEntries.length
|
||||
? Math.round(((scopeEntries.length - missing) / scopeEntries.length) * 100)
|
||||
: 0;
|
||||
|
||||
if (text || type || showMode !== 'missing') {
|
||||
if (visible != null) {
|
||||
countEl.textContent = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''}`;
|
||||
countEl.innerHTML = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''}
|
||||
<span class="trans-progress-pct">${pct}% complete</span>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const missing = countMissing(scopeEntries, selectedLang);
|
||||
countEl.textContent = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} in scope`;
|
||||
countEl.innerHTML = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} strings
|
||||
<span class="trans-progress-pct">${pct}% complete</span>`;
|
||||
}
|
||||
|
||||
let filtersBound = false;
|
||||
@ -562,7 +840,6 @@ function bindFilters() {
|
||||
return;
|
||||
}
|
||||
filtersBound = true;
|
||||
|
||||
document.getElementById('transFilter')?.addEventListener('input', applyFilters);
|
||||
document.getElementById('transTypeFilter')?.addEventListener('change', applyFilters);
|
||||
}
|
||||
@ -590,13 +867,12 @@ function applyFilters() {
|
||||
row.classList.toggle('trans-row-hidden', !show);
|
||||
if (show) {
|
||||
visible++;
|
||||
if (row.dataset.missing === '1') visibleMissing++;
|
||||
if (isMissing) visibleMissing++;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('#transGroupedRoot .trans-group-details').forEach(group => {
|
||||
const rows = group.querySelectorAll('.trans-list-item');
|
||||
const anyVisible = [...rows].some(r => !r.classList.contains('trans-row-hidden'));
|
||||
const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
|
||||
group.classList.toggle('trans-group-hidden', !anyVisible);
|
||||
});
|
||||
|
||||
@ -643,8 +919,7 @@ function bindTranslationBundleActions() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
a.download = `translations_bundle_${stamp}.json`;
|
||||
a.download = `translations_bundle_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Translations bundle downloaded', 'success');
|
||||
@ -676,10 +951,9 @@ function bindTranslationBundleActions() {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = bundle.entries.length;
|
||||
if (!(await confirmAction({
|
||||
title: 'Import translations',
|
||||
message: `Import translations for ${count} entries from this file?`,
|
||||
message: `Import translations for ${bundle.entries.length} entries from this file?`,
|
||||
confirmLabel: 'Import',
|
||||
}))) return;
|
||||
|
||||
@ -687,10 +961,7 @@ function bindTranslationBundleActions() {
|
||||
const prev = importBtn.textContent;
|
||||
importBtn.textContent = 'Importing…';
|
||||
try {
|
||||
const result = await apiPost('translations.php', {
|
||||
action: 'importBundle',
|
||||
bundle,
|
||||
});
|
||||
const result = await apiPost('translations.php', { action: 'importBundle', bundle });
|
||||
const imported = result.imported ?? 0;
|
||||
const skipped = result.skipped ?? 0;
|
||||
let msg = `Imported ${imported} translation(s)`;
|
||||
|
||||
@ -2,6 +2,7 @@ import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
|
||||
import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js';
|
||||
import { confirmAction } from '../confirm-modal.js';
|
||||
import { openAdminResetPasswordModal } from '../password-modal.js';
|
||||
import { berlinDateInputValue, formatBerlinDateTimeShort } from '../datetime.js';
|
||||
|
||||
// Roles an admin can create; supervisors can only create coaches
|
||||
const CREATEABLE_ROLES = {
|
||||
@ -529,7 +530,7 @@ function downloadUsersCSV() {
|
||||
u.role === 'coach' ? '' : (u.location || ''),
|
||||
u.role === 'coach' ? (u.supervisorUsername || u.supervisorID || '') : '',
|
||||
u.mustChangePassword == 1 ? 'yes' : 'no',
|
||||
u.createdAt ? new Date(parseInt(u.createdAt, 10) * 1000).toISOString() : '',
|
||||
u.createdAt ? formatBerlinDateTimeShort(parseInt(u.createdAt, 10) * 1000) : '',
|
||||
u.userID,
|
||||
]);
|
||||
|
||||
@ -541,7 +542,7 @@ function downloadUsersCSV() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `users_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.download = `users_${berlinDateInputValue()}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Users exported', 'success');
|
||||
|
||||
@ -1,4 +1,13 @@
|
||||
const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
||||
const RESULTS_EXCLUDED_KEYS = new Set(['data_final_warning']);
|
||||
const RESULTS_EXCLUDED_TYPES = new Set(['last_page']);
|
||||
|
||||
/** Questions hidden from results tables and CSV exports (e.g. last-page warnings). */
|
||||
export function isResultsExcludedQuestion(q) {
|
||||
if (!q) return false;
|
||||
if (RESULTS_EXCLUDED_TYPES.has(q.type)) return true;
|
||||
return RESULTS_EXCLUDED_KEYS.has(questionDisplayKey(q));
|
||||
}
|
||||
|
||||
/** Local question id from full questionID (hash__q5 → q5). */
|
||||
export function questionLocalKey(questionID, fallback = '') {
|
||||
@ -62,6 +71,7 @@ export function glassSymptomAnswerValue(raw, symptomKey) {
|
||||
export function buildResultColumns(questions) {
|
||||
const cols = [];
|
||||
for (const q of questions || []) {
|
||||
if (isResultsExcludedQuestion(q)) continue;
|
||||
const cfg = parseQuestionConfig(q);
|
||||
const symptoms = cfg.symptoms;
|
||||
if (q.type === 'glass_scale_question' && Array.isArray(symptoms) && symptoms.length > 0) {
|
||||
|
||||
@ -69,6 +69,16 @@ export function targetLanguages(languages) {
|
||||
return (languages || []).filter(l => l.languageCode !== SOURCE_LANG);
|
||||
}
|
||||
|
||||
export function isLanguageGloballyEnabled(lang) {
|
||||
if (!lang) return false;
|
||||
if (lang.languageCode === SOURCE_LANG) return true;
|
||||
return Number(lang.enabled ?? 1) === 1;
|
||||
}
|
||||
|
||||
export function globallyEnabledLanguages(languages) {
|
||||
return (languages || []).filter(isLanguageGloballyEnabled);
|
||||
}
|
||||
|
||||
/** Sort rows within a group: missing translations first, then questionnaire order */
|
||||
export function sortGroupItems(items, targetLang) {
|
||||
return [...items].sort((a, b) => {
|
||||
|
||||
Reference in New Issue
Block a user