From 9cf67e765f3c6239d73047bfb49d908b9e1c0def Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Thu, 28 May 2026 11:29:14 +0200 Subject: [PATCH] improved UX in dashboard --- common.php | 272 ++++++++++++++++++++- data/app_ui_strings.json | 381 +++++++++++++++--------------- docs/android-questionnaire-api.md | 2 +- handlers/questionnaires.php | 47 +++- handlers/translations.php | 2 +- schema.sql | 7 + website/css/style.css | 105 +++++++- website/js/pages/dashboard.js | 307 ++++++++++++++++++++---- 8 files changed, 876 insertions(+), 247 deletions(-) diff --git a/common.php b/common.php index 9b489ee..dc0e79c 100644 --- a/common.php +++ b/common.php @@ -247,12 +247,12 @@ function qdb_upsert_source_translation(PDO $pdo, string $type, string $id, strin } /** Catalog of global app UI string keys (LanguageManager). */ -function qdb_app_string_catalog(): array { +function qdb_app_string_catalog(bool $reload = false): array { static $catalog = null; - if ($catalog !== null) { + if (!$reload && $catalog !== null) { return $catalog; } - $path = __DIR__ . '/data/app_ui_strings.json'; + $path = qdb_app_string_catalog_path(); if (!is_readable($path)) { $catalog = ['keys' => [], 'germanDefaults' => []]; return $catalog; @@ -262,6 +262,49 @@ function qdb_app_string_catalog(): array { return $catalog; } +function qdb_app_string_catalog_path(): string { + return __DIR__ . '/data/app_ui_strings.json'; +} + +/** Remove questionnaire_group_* from catalog file (keys list + germanDefaults). */ +function qdb_remove_questionnaire_group_from_catalog(string $stringKey): void { + if (!qdb_is_questionnaire_group_string_key($stringKey)) { + return; + } + $path = qdb_app_string_catalog_path(); + if (!is_readable($path) || !is_writable($path)) { + return; + } + $data = json_decode(file_get_contents($path), true); + if (!is_array($data)) { + return; + } + $changed = false; + if (!empty($data['keys']) && is_array($data['keys'])) { + $next = array_values(array_filter( + $data['keys'], + fn($k) => (string)$k !== $stringKey + )); + if (count($next) !== count($data['keys'])) { + $data['keys'] = $next; + $changed = true; + } + } + if (!empty($data['germanDefaults']) && is_array($data['germanDefaults']) && array_key_exists($stringKey, $data['germanDefaults'])) { + unset($data['germanDefaults'][$stringKey]); + $changed = true; + } + if (!$changed) { + return; + } + file_put_contents( + $path, + json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", + LOCK_EX + ); + qdb_app_string_catalog(true); +} + function qdb_app_string_key_set(): array { $keys = qdb_app_string_catalog()['keys'] ?? []; return array_flip($keys); @@ -299,6 +342,204 @@ function qdb_dev_only_app_string_key_set(): array { return $set; } +/** Prefix for app UI strings that label questionnaire category groups on the opening screen. */ +function qdb_questionnaire_group_prefix(): string { + return 'questionnaire_group_'; +} + +function qdb_questionnaire_group_string_key(string $categoryKey): string { + $categoryKey = trim($categoryKey); + return $categoryKey === '' ? '' : qdb_questionnaire_group_prefix() . $categoryKey; +} + +function qdb_is_questionnaire_group_string_key(string $stringKey): bool { + return str_starts_with($stringKey, qdb_questionnaire_group_prefix()); +} + +function qdb_category_key_from_group_string_key(string $stringKey): string { + $prefix = qdb_questionnaire_group_prefix(); + return str_starts_with($stringKey, $prefix) ? substr($stringKey, strlen($prefix)) : ''; +} + +/** @return array categoryKey => questionnaire count */ +function qdb_category_keys_in_use(PDO $pdo): array { + $map = []; + $rows = $pdo->query( + "SELECT categoryKey, COUNT(*) AS c FROM questionnaire + WHERE trim(categoryKey) != '' GROUP BY categoryKey" + )->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $k = trim((string)($row['categoryKey'] ?? '')); + if ($k !== '') { + $map[$k] = (int)$row['c']; + } + } + return $map; +} + +/** + * App string entries for category keys in use (not already in the static JSON catalog keys list). + * @return list + */ +function qdb_category_group_string_entries(PDO $pdo): array { + $catalog = qdb_app_string_catalog(); + $defaults = $catalog['germanDefaults'] ?? []; + $staticKeys = qdb_app_string_key_set(); + $entries = []; + $order = 0; + foreach (qdb_category_keys_in_use($pdo) as $catKey => $_count) { + $stringKey = qdb_questionnaire_group_string_key($catKey); + if ($stringKey === '' || isset($staticKeys[$stringKey])) { + continue; + } + $entries[] = [ + 'key' => $stringKey, + 'displayKey' => $stringKey, + 'type' => 'app_string', + 'entityId' => $stringKey, + 'sortOrder' => $order++, + 'germanDefault' => $defaults[$stringKey] ?? $catKey, + 'categoryKey' => $catKey, + ]; + } + usort($entries, fn($a, $b) => strcmp($a['categoryKey'], $b['categoryKey'])); + return $entries; +} + +/** + * Category keys with translations in DB but no questionnaire using them. + * @return list + */ +function qdb_orphaned_category_keys(PDO $pdo): array { + $inUse = qdb_category_keys_in_use($pdo); + $prefix = qdb_questionnaire_group_prefix(); + $orphans = []; + $stmt = $pdo->query( + "SELECT DISTINCT stringKey FROM string_translation WHERE stringKey LIKE " . $pdo->quote($prefix . '%') + ); + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $stringKey) { + $catKey = qdb_category_key_from_group_string_key((string)$stringKey); + if ($catKey !== '' && !isset($inUse[$catKey])) { + $orphans[$catKey] = true; + } + } + ksort($orphans); + return array_keys($orphans); +} + +/** + * @return list + */ +function qdb_list_category_keys_for_dashboard(PDO $pdo): array { + $defaults = qdb_app_string_catalog()['germanDefaults'] ?? []; + $inUse = qdb_category_keys_in_use($pdo); + $list = []; + + $loadGerman = function (string $stringKey) use ($pdo, $defaults): string { + $stmt = $pdo->prepare( + 'SELECT text FROM string_translation WHERE stringKey = :k AND languageCode = :lang LIMIT 1' + ); + $stmt->execute([':k' => $stringKey, ':lang' => QDB_SOURCE_LANGUAGE]); + $text = $stmt->fetchColumn(); + if (is_string($text) && $text !== '') { + return $text; + } + return $defaults[$stringKey] ?? ''; + }; + + foreach ($inUse as $catKey => $count) { + $stringKey = qdb_questionnaire_group_string_key($catKey); + $list[] = [ + 'categoryKey' => $catKey, + 'stringKey' => $stringKey, + 'questionnaireCount' => $count, + 'orphaned' => false, + 'germanLabel' => $loadGerman($stringKey) ?: $catKey, + ]; + } + + foreach (qdb_orphaned_category_keys($pdo) as $catKey) { + $stringKey = qdb_questionnaire_group_string_key($catKey); + $list[] = [ + 'categoryKey' => $catKey, + 'stringKey' => $stringKey, + 'questionnaireCount' => 0, + 'orphaned' => true, + 'germanLabel' => $loadGerman($stringKey) ?: $catKey, + ]; + } + + usort($list, fn($a, $b) => strcmp($a['categoryKey'], $b['categoryKey'])); + return $list; +} + +/** Persist default German for a questionnaire_group_* key in app_ui_strings.json. */ +function qdb_update_questionnaire_group_german_default(string $stringKey, string $text): void { + if (!qdb_is_questionnaire_group_string_key($stringKey)) { + return; + } + $path = qdb_app_string_catalog_path(); + if (!is_readable($path) || !is_writable($path)) { + return; + } + $data = json_decode(file_get_contents($path), true); + if (!is_array($data)) { + return; + } + if (!isset($data['germanDefaults']) || !is_array($data['germanDefaults'])) { + $data['germanDefaults'] = []; + } + if (($data['germanDefaults'][$stringKey] ?? '') === $text) { + return; + } + $data['germanDefaults'][$stringKey] = $text; + file_put_contents( + $path, + json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", + LOCK_EX + ); + qdb_app_string_catalog(true); +} + +/** Set German label for a category (app opening screen group title). */ +function qdb_set_category_german_label(PDO $pdo, string $categoryKey, string $text): string { + $categoryKey = trim($categoryKey); + if ($categoryKey === '') { + json_error('INVALID_FIELD', 'categoryKey is required', 400); + } + $text = trim($text); + if ($text === '') { + json_error('INVALID_FIELD', 'German label is required', 400); + } + $stringKey = qdb_questionnaire_group_string_key($categoryKey); + qdb_put_translation($pdo, 'app_string', $stringKey, QDB_SOURCE_LANGUAGE, $text); + qdb_update_questionnaire_group_german_default($stringKey, $text); + return $stringKey; +} + +/** + * Remove a category key completely: clear questionnaires, translations, and catalog defaults. + * @return int questionnaires updated + */ +function qdb_delete_category_key(PDO $pdo, string $categoryKey): int { + $categoryKey = trim($categoryKey); + if ($categoryKey === '') { + json_error('INVALID_FIELD', 'categoryKey is required', 400); + } + $stringKey = qdb_questionnaire_group_string_key($categoryKey); + + $stmt = $pdo->prepare('UPDATE questionnaire SET categoryKey = \'\' WHERE categoryKey = :ck'); + $stmt->execute([':ck' => $categoryKey]); + $cleared = $stmt->rowCount(); + + $pdo->prepare('DELETE FROM string_translation WHERE stringKey = :k') + ->execute([':k' => $stringKey]); + + qdb_remove_questionnaire_group_from_catalog($stringKey); + + return $cleared; +} + /** * Global app UI strings (screens, toasts, auth, etc.) for the mobile app. * @return list @@ -309,6 +550,9 @@ function qdb_app_ui_string_entries(): array { $entries = []; $order = 0; foreach ($catalog['keys'] ?? [] as $key) { + if (qdb_is_questionnaire_group_string_key($key)) { + continue; + } $entries[] = [ 'key' => $key, 'displayKey' => $key, @@ -321,6 +565,20 @@ function qdb_app_ui_string_entries(): array { return $entries; } +/** + * Static catalog plus questionnaire_group_* keys for categoryKey values in use. + * @return list + */ +function qdb_app_ui_string_entries_merged(PDO $pdo): array { + $merged = qdb_app_ui_string_entries(); + $order = count($merged); + foreach (qdb_category_group_string_entries($pdo) as $e) { + $e['sortOrder'] = $order++; + $merged[] = $e; + } + return $merged; +} + /** * All string keys referenced by questionnaire content (questions, options, config). * @return array @@ -384,10 +642,10 @@ function qdb_all_questionnaire_translation_key_set(PDO $pdo): array { * App UI catalog for the website Translations page (coach-visible, no dev-only keys). * Catalog keys always appear here even when also used inside a questionnaire (e.g. "save"). */ -function qdb_app_ui_string_entries_for_website(): array { +function qdb_app_ui_string_entries_for_website(PDO $pdo): array { $dev = qdb_dev_only_app_string_key_set(); $entries = []; - foreach (qdb_app_ui_string_entries() as $e) { + foreach (qdb_app_ui_string_entries_merged($pdo) as $e) { if (!isset($dev[$e['key']])) { $entries[] = $e; } @@ -913,7 +1171,7 @@ function qdb_load_translations_for_entries(PDO $pdo, array $entries): array { * @return array> */ function qdb_build_app_translations_map(PDO $pdo): array { - $entries = qdb_app_ui_string_entries(); + $entries = qdb_app_ui_string_entries_merged($pdo); $translations = qdb_load_translations_for_entries($pdo, $entries); $defaults = qdb_app_string_catalog()['germanDefaults'] ?? []; $result = []; @@ -1397,7 +1655,7 @@ function qdb_export_translations_bundle(PDO $pdo): array { qdb_ensure_source_language($pdo); $exportEntries = []; - $appEntries = qdb_app_ui_string_entries_for_website(); + $appEntries = qdb_app_ui_string_entries_for_website($pdo); $appTranslations = qdb_load_translations_for_entries($pdo, $appEntries); qdb_attach_translation_texts($appEntries, $appTranslations); foreach ($appEntries as $e) { diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json index 9c34934..475ef0b 100644 --- a/data/app_ui_strings.json +++ b/data/app_ui_strings.json @@ -1,198 +1,187 @@ { - "version": 2, - "description": "Coach-visible app UI strings only (excludes dev settings / database tools).", - "keys": [ - "all_clients", - "answer", - "april", - "august", - "auth_footer_accounts", - "auth_subtitle_change_password", - "auth_subtitle_login", - "auth_title", - "cancel", - "choose_answer", - "choose_more_elements", - "client", - "client_code", - "client_code_exists", - "coach_code", - "confirm_password_hint", - "date_after", - "date_before", - "day", - "december", - "done", - "download", - "download_failed_use_offline", - "enter_coach_code", - "error", - "error_invalid_data", - "error_not_found", - "questionnaire_title", - "exit_btn", - "february", - "fill_all_fields", - "fill_both_fields", - "hours_short", - "january", - "july", - "june", - "lay", - "consultation_unlock_requires_rhs", - "locked", - "questionnaire_chip_edit", - "questionnaire_chip_edit_pending", - "questionnaire_group_closure", - "questionnaire_group_consultation", - "questionnaire_group_demographics", - "questionnaire_group_followup", - "questionnaire_group_health_interview", - "questionnaire_group_integration", - "login_btn", - "login_failed_with_reason", - "login_required", - "march", - "may", - "minutes_short", - "month", - "new_password_hint", - "no_clients_assigned", - "no_profile", - "no_questionnaires", - "no_questions_available", - "questionnaire_missing_options", - "none", - "not_done", - "november", - "october", - "offline", - "other_country", - "other_option", - "ok", - "online", - "password_hint", - "password_too_short", - "passwords_dont_match", - "please_client_code", - "please_username_password", - "points", - "previous", - "question", - "questions_filled", - "refresh", - "save", - "save_password_btn", - "select_one_answer", - "select_one_answer_per_row", - "september", - "session_dash", - "session_label", - "start", - "start_upload", - "upload", - "upload_failed_title", - "upload_nothing_pending", - "upload_prepare_failed", - "upload_success_message", - "username_hint", - "year" - ], - "germanDefaults": { - "all_clients": "Alle Klienten", - "answer": "Antwort", - "april": "April", - "august": "August", - "auth_footer_accounts": "Konten werden von Ihrer Organisation vergeben.", - "auth_subtitle_change_password": "Bitte legen Sie jetzt ein eigenes Passwort fest.", - "auth_subtitle_login": "Melden Sie sich mit Ihrem Coach-Konto an.", - "auth_title": "BW Schützt", - "cancel": "Abbrechen", - "choose_answer": "Antwort wählen", - "choose_more_elements": "Es müssen mehr Elemenete ausgewählt werden.", - "client": "Klient", - "client_code": "Klientencode", - "client_code_exists": "Dieser Client-Code existiert bereits.", - "coach_code": "Code Coach", - "confirm_password_hint": "Passwort bestätigen", - "date_after": "Das Datum muss nach", - "date_before": "Das Datum muss vor", - "day": "Tag", - "december": "Dezember", - "done": "Erledigt", - "download": "Herunterladen", - "download_failed_use_offline": "Download fehlgeschlagen – arbeite offline mit vorhandener Datenbank", - "enter_coach_code": "Bitte geben Sie Ihren Coach Code ein, um fortzufahren!", - "error": "Fehler", - "error_invalid_data": "Ungültige Daten", - "error_not_found": "Nicht gefunden", - "questionnaire_title": "Fragebögen", - "exit_btn": "Beenden", - "february": "Februar", - "fill_all_fields": "Bitte alle Felder ausfüllen!", - "fill_both_fields": "Bitte beide Felder ausfüllen!", - "hours_short": "h", - "january": "Januar", - "july": "Juli", - "june": "Juni", - "lay": "liegen.", - "consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS", - "locked": "Gesperrt", - "questionnaire_chip_edit": "Bearbeiten", - "questionnaire_chip_edit_pending": "Bearbeiten · Upload ausstehend", - "questionnaire_group_closure": "Abschluss", - "questionnaire_group_consultation": "Beratung", - "questionnaire_group_demographics": "Demografie", - "questionnaire_group_followup": "Nachbefragung", - "questionnaire_group_health_interview": "Gesundheitsinterview", - "questionnaire_group_integration": "Integration (IPL)", - "login_btn": "Login", - "login_failed_with_reason": "Login fehlgeschlagen: {reason}", - "login_required": "Bitte zuerst einloggen", - "march": "März", - "may": "Mai", - "minutes_short": "m", - "month": "Monat", - "new_password_hint": "Neues Passwort", - "no_clients_assigned": "Ihr Supervisor hat Ihnen noch keine Klienten zugewiesen.", - "no_profile": "Dieser Klient ist noch nicht Teil der Datenbank", - "no_questionnaires": "Keine Fragebögen vorhanden.", - "no_questions_available": "Keine Fragen vorhanden.", - "questionnaire_missing_options": "Dieser Fragebogen ist unvollständig (fehlende Antwortoptionen). Bitte wenden Sie sich an Ihren Administrator.", - "none": "Keine", - "not_done": "Nicht erledigt", - "november": "November", - "october": "Oktober", - "offline": "Offline", - "other_country": "anderes Land", - "other_option": "Sonstiges", - "ok": "OK", - "online": "Online", - "password_hint": "Passwort", - "password_too_short": "Mindestens 6 Zeichen", - "passwords_dont_match": "Passwörter stimmen nicht überein", - "please_client_code": "Bitte Klienten Code eingeben", - "please_username_password": "Bitte Benutzername und Passwort eingeben.", - "points": "Punkte", - "previous": "Zurück", - "question": "Frage", - "questions_filled": "Antworten", - "refresh": "Aktualisieren", - "save": "Speichern", - "save_password_btn": "Passwort speichern", - "select_one_answer": "Bitte wählen Sie eine Antwort aus!", - "select_one_answer_per_row": "Bitte wählen Sie eine Antwort pro Reihe aus!", - "september": "September", - "session_dash": "Sitzung: —", - "session_label": "Sitzung", - "start": "Start", - "start_upload": "Upload starten?", - "upload": "Hochladen", - "upload_failed_title": "Upload fehlgeschlagen", - "upload_nothing_pending": "Es wurde nichts hochgeladen. Es liegen keine abgeschlossenen Fragebögen zum Upload vor. Bitte zuerst einen Fragebogen abschließen.", - "upload_prepare_failed": "Es wurde nichts hochgeladen. Die Daten konnten nicht für den Upload vorbereitet werden. Bitte erneut synchronisieren oder den Support kontaktieren.", - "upload_success_message": "Hochladen: {count} erledigt.", - "username_hint": "Benutzername", - "year": "Jahr" - } + "version": 2, + "description": "Coach-visible app UI strings only (excludes dev settings \/ database tools).", + "keys": [ + "all_clients", + "answer", + "april", + "august", + "auth_footer_accounts", + "auth_subtitle_change_password", + "auth_subtitle_login", + "auth_title", + "cancel", + "choose_answer", + "choose_more_elements", + "client", + "client_code", + "client_code_exists", + "coach_code", + "confirm_password_hint", + "date_after", + "date_before", + "day", + "december", + "done", + "download", + "download_failed_use_offline", + "enter_coach_code", + "error", + "error_invalid_data", + "error_not_found", + "questionnaire_title", + "exit_btn", + "february", + "fill_all_fields", + "fill_both_fields", + "hours_short", + "january", + "july", + "june", + "lay", + "consultation_unlock_requires_rhs", + "locked", + "questionnaire_chip_edit", + "questionnaire_chip_edit_pending", + "login_btn", + "login_failed_with_reason", + "login_required", + "march", + "may", + "minutes_short", + "month", + "new_password_hint", + "no_clients_assigned", + "no_profile", + "no_questionnaires", + "no_questions_available", + "questionnaire_missing_options", + "none", + "not_done", + "november", + "october", + "offline", + "other_country", + "other_option", + "ok", + "online", + "password_hint", + "password_too_short", + "passwords_dont_match", + "please_client_code", + "please_username_password", + "points", + "previous", + "question", + "questions_filled", + "refresh", + "save", + "save_password_btn", + "select_one_answer", + "select_one_answer_per_row", + "september", + "session_dash", + "session_label", + "start", + "start_upload", + "upload", + "upload_failed_title", + "upload_nothing_pending", + "upload_prepare_failed", + "upload_success_message", + "username_hint", + "year" + ], + "germanDefaults": { + "all_clients": "Alle Klienten", + "answer": "Antwort", + "april": "April", + "august": "August", + "auth_footer_accounts": "Konten werden von Ihrer Organisation vergeben.", + "auth_subtitle_change_password": "Bitte legen Sie jetzt ein eigenes Passwort fest.", + "auth_subtitle_login": "Melden Sie sich mit Ihrem Coach-Konto an.", + "auth_title": "BW Schützt", + "cancel": "Abbrechen", + "choose_answer": "Antwort wählen", + "choose_more_elements": "Es müssen mehr Elemenete ausgewählt werden.", + "client": "Klient", + "client_code": "Klientencode", + "client_code_exists": "Dieser Client-Code existiert bereits.", + "coach_code": "Code Coach", + "confirm_password_hint": "Passwort bestätigen", + "date_after": "Das Datum muss nach", + "date_before": "Das Datum muss vor", + "day": "Tag", + "december": "Dezember", + "done": "Erledigt", + "download": "Herunterladen", + "download_failed_use_offline": "Download fehlgeschlagen – arbeite offline mit vorhandener Datenbank", + "enter_coach_code": "Bitte geben Sie Ihren Coach Code ein, um fortzufahren!", + "error": "Fehler", + "error_invalid_data": "Ungültige Daten", + "error_not_found": "Nicht gefunden", + "questionnaire_title": "Fragebögen", + "exit_btn": "Beenden", + "february": "Februar", + "fill_all_fields": "Bitte alle Felder ausfüllen!", + "fill_both_fields": "Bitte beide Felder ausfüllen!", + "hours_short": "h", + "january": "Januar", + "july": "Juli", + "june": "Juni", + "lay": "liegen.", + "consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS", + "locked": "Gesperrt", + "questionnaire_chip_edit": "Bearbeiten", + "questionnaire_chip_edit_pending": "Bearbeiten · Upload ausstehend", + "login_btn": "Login", + "login_failed_with_reason": "Login fehlgeschlagen: {reason}", + "login_required": "Bitte zuerst einloggen", + "march": "März", + "may": "Mai", + "minutes_short": "m", + "month": "Monat", + "new_password_hint": "Neues Passwort", + "no_clients_assigned": "Ihr Supervisor hat Ihnen noch keine Klienten zugewiesen.", + "no_profile": "Dieser Klient ist noch nicht Teil der Datenbank", + "no_questionnaires": "Keine Fragebögen vorhanden.", + "no_questions_available": "Keine Fragen vorhanden.", + "questionnaire_missing_options": "Dieser Fragebogen ist unvollständig (fehlende Antwortoptionen). Bitte wenden Sie sich an Ihren Administrator.", + "none": "Keine", + "not_done": "Nicht erledigt", + "november": "November", + "october": "Oktober", + "offline": "Offline", + "other_country": "anderes Land", + "other_option": "Sonstiges", + "ok": "OK", + "online": "Online", + "password_hint": "Passwort", + "password_too_short": "Mindestens 6 Zeichen", + "passwords_dont_match": "Passwörter stimmen nicht überein", + "please_client_code": "Bitte Klienten Code eingeben", + "please_username_password": "Bitte Benutzername und Passwort eingeben.", + "points": "Punkte", + "previous": "Zurück", + "question": "Frage", + "questions_filled": "Antworten", + "refresh": "Aktualisieren", + "save": "Speichern", + "save_password_btn": "Passwort speichern", + "select_one_answer": "Bitte wählen Sie eine Antwort aus!", + "select_one_answer_per_row": "Bitte wählen Sie eine Antwort pro Reihe aus!", + "september": "September", + "session_dash": "Sitzung: —", + "session_label": "Sitzung", + "start": "Start", + "start_upload": "Upload starten?", + "upload": "Hochladen", + "upload_failed_title": "Upload fehlgeschlagen", + "upload_nothing_pending": "Es wurde nichts hochgeladen. Es liegen keine abgeschlossenen Fragebögen zum Upload vor. Bitte zuerst einen Fragebogen abschließen.", + "upload_prepare_failed": "Es wurde nichts hochgeladen. Die Daten konnten nicht für den Upload vorbereitet werden. Bitte erneut synchronisieren oder den Support kontaktieren.", + "upload_success_message": "Hochladen: {count} erledigt.", + "username_hint": "Benutzername", + "year": "Jahr", + "questionnaire_group_demographics": "Demografie" + } } diff --git a/docs/android-questionnaire-api.md b/docs/android-questionnaire-api.md index 004d15e..9d7751d 100644 --- a/docs/android-questionnaire-api.md +++ b/docs/android-questionnaire-api.md @@ -149,7 +149,7 @@ Fields: - `name`: display/admin name. - `showPoints`: whether the app may show the points result. - `condition`: JSON condition object. Empty conditions are returned as `{}`. -- `categoryKey` (optional): grouping key for the opening screen; labels come from app UI strings (e.g. `questionnaire_group_health_interview`). +- `categoryKey` (optional): grouping key for the opening screen. The app resolves the section title as `questionnaire_group_` (global app UI strings). Keys in use appear automatically on the website **Translations** page; default German text may come from `data/app_ui_strings.json` (`germanDefaults`). Unused translation rows can be removed from the questionnaires dashboard. Only questionnaires with `state = "active"` are returned, ordered by server `orderIndex`. diff --git a/handlers/questionnaires.php b/handlers/questionnaires.php index 861ef46..ad56d35 100644 --- a/handlers/questionnaires.php +++ b/handlers/questionnaires.php @@ -35,8 +35,9 @@ case 'GET': $r['conditionJson'] = $r['conditionJson'] ?: '{}'; } unset($r); + $categoryKeys = qdb_list_category_keys_for_dashboard($pdo); qdb_discard($tmpDb, $lockFp); - json_success(['questionnaires' => $rows]); + json_success(['questionnaires' => $rows, 'categoryKeys' => $categoryKeys]); } catch (Throwable $e) { qdb_discard($tmpDb, $lockFp); error_log('questionnaires GET: ' . $e->getMessage()); @@ -48,6 +49,44 @@ case 'POST': require_role(['admin', 'supervisor'], $tokenRec); $body = read_json_body(); + if (($body['action'] ?? '') === 'updateCategoryLabel') { + $categoryKey = trim((string)($body['categoryKey'] ?? '')); + $germanLabel = (string)($body['germanLabel'] ?? ''); + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + try { + $stringKey = qdb_set_category_german_label($pdo, $categoryKey, $germanLabel); + qdb_save($tmpDb, $lockFp); + json_success([ + 'categoryKey' => $categoryKey, + 'stringKey' => $stringKey, + 'germanLabel' => trim($germanLabel), + ]); + } catch (Throwable $e) { + qdb_discard($tmpDb, $lockFp); + error_log('updateCategoryLabel: ' . $e->getMessage()); + json_error('SERVER_ERROR', 'Server error', 500); + } + break; + } + + if (($body['action'] ?? '') === 'deleteCategoryKey') { + $categoryKey = trim((string)($body['categoryKey'] ?? '')); + if ($categoryKey === '') { + json_error('INVALID_FIELD', 'categoryKey is required', 400); + } + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + try { + $cleared = qdb_delete_category_key($pdo, $categoryKey); + qdb_save($tmpDb, $lockFp); + json_success(['deleted' => $categoryKey, 'questionnairesCleared' => $cleared]); + } catch (Throwable $e) { + qdb_discard($tmpDb, $lockFp); + error_log('deleteCategoryKey: ' . $e->getMessage()); + json_error('SERVER_ERROR', 'Server error', 500); + } + break; + } + if (($body['action'] ?? '') === 'importBundle') { $bundle = $body['bundle'] ?? null; if (!is_array($bundle)) { @@ -86,6 +125,9 @@ case 'POST': $order = (int)$pdo->query("SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire")->fetchColumn(); } $catKey = trim($body['categoryKey'] ?? ''); + if ($catKey !== '') { + $catKey = qdb_normalize_stable_key($catKey); + } $pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey) VALUES (:id, :n, :v, :s, :o, :sp, :cj, :ck)") ->execute([':id' => $id, ':n' => $name, ':v' => $version, ':s' => $state, @@ -129,6 +171,9 @@ case 'PUT': $catKey = array_key_exists('categoryKey', $body) ? trim((string)$body['categoryKey']) : trim($row['categoryKey'] ?? ''); + if ($catKey !== '') { + $catKey = qdb_normalize_stable_key($catKey); + } $pdo->prepare("UPDATE questionnaire SET name = :n, version = :v, state = :s, orderIndex = :o, showPoints = :sp, conditionJson = :cj, categoryKey = :ck diff --git a/handlers/translations.php b/handlers/translations.php index 17dc195..cb69047 100644 --- a/handlers/translations.php +++ b/handlers/translations.php @@ -39,7 +39,7 @@ case 'GET': SELECT questionnaireID, name, orderIndex FROM questionnaire ORDER BY orderIndex ")->fetchAll(PDO::FETCH_ASSOC); - $appEntries = qdb_app_ui_string_entries_for_website(); + $appEntries = qdb_app_ui_string_entries_for_website($pdo); $appTranslations = qdb_load_translations_for_entries($pdo, $appEntries); qdb_attach_translation_texts($appEntries, $appTranslations); diff --git a/schema.sql b/schema.sql index 090b012..d5f2a94 100644 --- a/schema.sql +++ b/schema.sql @@ -35,6 +35,13 @@ CREATE TABLE IF NOT EXISTS client ( FOREIGN KEY(coachID) REFERENCES coach(coachID) ); +CREATE TABLE IF NOT EXISTS questionnaire_category ( + categoryKey TEXT NOT NULL PRIMARY KEY, + label TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '#64748b', + orderIndex INTEGER NOT NULL DEFAULT 0 +); + CREATE TABLE IF NOT EXISTS questionnaire ( questionnaireID TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL DEFAULT '', diff --git a/website/css/style.css b/website/css/style.css index 126e29e..d22f201 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -342,12 +342,100 @@ code { gap: 8px; } -/* Questionnaire card grid */ +/* Questionnaire dashboard root (not .q-grid — avoids grid layout on nested cards) */ +.q-dashboard-root { + display: block; + width: 100%; +} + +/* Questionnaire dashboard — category sections with horizontal card rows */ +.q-dashboard-groups { + display: flex; + flex-direction: column; + gap: 28px; + width: 100%; +} + +.q-category-group { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + min-width: 0; +} + +.q-category-title { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: .02em; + padding-bottom: 4px; + border-bottom: 1px solid var(--border); +} + +.q-category-title .category-title-input { + font: inherit; + color: inherit; + letter-spacing: inherit; + width: 100%; + max-width: 480px; +} + +.category-german-input { + width: 100%; + min-width: 140px; + max-width: 320px; + padding: 6px 10px; + font-size: .9rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: inherit; +} + +.category-german-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-subtle); +} + +.category-german-input:disabled { + opacity: .6; +} + +/* Category rows: horizontal flow, wrap when the line is full */ +.q-category-grid { + display: flex; + flex-flow: row wrap; + align-items: stretch; + align-content: flex-start; + gap: 16px; + width: 100%; + min-width: 0; + margin: 0; +} + +.q-category-grid > .q-card { + flex: 0 0 300px; + width: 300px; + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} + +/* Questionnaire card grid (legacy / other pages) */ .q-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; } + +.q-card .drag-handle { + margin-right: 6px; + cursor: grab; + color: var(--text-secondary); +} .q-card { background: var(--surface); border: 1px solid var(--border); @@ -751,12 +839,27 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; } .q-grid { grid-template-columns: 1fr; } } +@media (max-width: 520px) { + .q-category-grid > .q-card { + flex: 1 1 100%; + width: 100%; + } +} + /* Role badges */ .badge-admin { background: var(--badge-admin-bg); color: var(--badge-admin-fg); } .badge-supervisor { background: var(--badge-supervisor-bg); color: var(--badge-supervisor-fg); } .badge-coach { background: var(--badge-coach-bg); color: var(--badge-coach-fg); } .badge-you { background: var(--badge-you-bg); color: var(--badge-you-fg); font-size: .7rem; } +/* Dashboard — category keys panel */ +.category-keys-table code { + font-size: .8rem; +} +table.data-table tbody tr.row-orphan-category td { + background: color-mix(in srgb, var(--surface-hover) 60%, transparent); +} + /* Assignment page — client table sized to rows (max 50); coach panel matches */ .assign-page { display: flex; diff --git a/website/js/pages/dashboard.js b/website/js/pages/dashboard.js index f306760..d38b514 100644 --- a/website/js/pages/dashboard.js +++ b/website/js/pages/dashboard.js @@ -9,7 +9,8 @@ export async function dashboardPage() {

Questionnaires

-
+
+
`; if (canEdit()) { @@ -23,35 +24,216 @@ export async function dashboardPage() { try { const data = await apiGet('questionnaires.php'); - renderGrid(data.questionnaires || []); + const categoryKeys = data.categoryKeys || []; + renderGrid(data.questionnaires || [], categoryKeys); + renderCategoryKeysPanel(categoryKeys); + bindCategoryLabelEditors(); } catch (e) { showToast(e.message, 'error'); document.getElementById('qGrid').innerHTML = `

${e.message}

`; } } -function renderGrid(questionnaires) { - const grid = document.getElementById('qGrid'); +function renderCategoryKeysPanel(categoryKeys) { + const panel = document.getElementById('categoryKeysPanel'); + if (!panel) return; - if (!questionnaires.length) { - grid.innerHTML = ` -
- -

No questionnaires yet

-

Create your first questionnaire to get started.

-
- `; + if (!canEdit()) { + panel.innerHTML = ''; return; } - grid.innerHTML = questionnaires.map(q => { - const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`; - const showPts = parseInt(q.showPoints || 0, 10); - return ` + if (!categoryKeys.length) { + panel.innerHTML = ` +
+

Category keys

+

+ Set a category key on a questionnaire (editor → settings) to group it on the app opening screen. + German group titles can be edited below once a key is in use. +

+
`; + return; + } + + panel.innerHTML = ` +
+

Category keys

+

+ German labels for the app opening screen. Other languages: Translations. + Delete removes the key from questionnaires, all translations, and catalog defaults. +

+
+ + + + + + + + + + + + ${categoryKeys.map(row => ` + + + + + + + + `).join('')} + +
Category keyApp string keyGerman labelQuestionnaires
${esc(row.categoryKey)}${esc(row.stringKey)} + + ${row.orphaned + ? 'unused' + : esc(String(row.questionnaireCount))} + +
+
+
`; + + panel.querySelectorAll('.delete-cat-key-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const key = btn.dataset.key; + const count = parseInt(btn.dataset.count || '0', 10); + let msg = `Delete category key "${key}"?\n\nThis removes all translations and catalog defaults for questionnaire_group_${key}.`; + if (count > 0) { + msg += `\n\n${count} questionnaire(s) will have their category key cleared.`; + } + if (!confirm(msg)) return; + btn.disabled = true; + try { + const result = await apiPost('questionnaires.php', { action: 'deleteCategoryKey', categoryKey: key }); + const cleared = result.questionnairesCleared ?? count; + showToast( + cleared + ? `Deleted "${key}" and cleared ${cleared} questionnaire(s)` + : `Deleted "${key}"`, + 'success' + ); + await dashboardPage(); + } catch (err) { + showToast(err.message, 'error'); + btn.disabled = false; + } + }); + }); +} + +function categoryGermanInputHTML(categoryKey, label) { + if (!canEdit() || !categoryKey) { + return esc(label); + } + return ``; +} + +function bindCategoryLabelEditors() { + if (!canEdit()) return; + + document.querySelectorAll('.category-german-input').forEach(input => { + input.dataset.lastSaved = input.value.trim(); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + input.blur(); + } + }); + + input.addEventListener('blur', () => saveCategoryGermanLabel(input)); + }); +} + +async function saveCategoryGermanLabel(input) { + const categoryKey = input.dataset.categoryKey?.trim(); + const label = input.value.trim(); + if (!categoryKey) return; + + const prev = input.dataset.lastSaved ?? ''; + if (label === prev) return; + + if (!label) { + showToast('German label cannot be empty', 'error'); + input.value = prev; + return; + } + + input.disabled = true; + try { + await apiPost('questionnaires.php', { + action: 'updateCategoryLabel', + categoryKey, + germanLabel: label, + }); + input.dataset.lastSaved = label; + syncCategoryGermanInputs(categoryKey, label); + showToast('Category label saved', 'success'); + } catch (err) { + showToast(err.message, 'error'); + input.value = prev; + } finally { + input.disabled = false; + } +} + +function syncCategoryGermanInputs(categoryKey, label) { + document.querySelectorAll('.category-german-input').forEach(el => { + if (el.dataset.categoryKey === categoryKey) { + el.value = label; + el.dataset.lastSaved = label; + } + }); +} + +const UNCATEGORIZED = '__uncategorized__'; + +function groupQuestionnairesByCategory(questionnaires, categoryKeys) { + const labelByKey = Object.fromEntries( + (categoryKeys || []) + .filter(r => !r.orphaned) + .map(r => [r.categoryKey, r.germanLabel || r.categoryKey]) + ); + + const sorted = [...questionnaires].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)); + const buckets = new Map(); + + for (const q of sorted) { + const key = (q.categoryKey || '').trim() || UNCATEGORIZED; + if (!buckets.has(key)) buckets.set(key, []); + buckets.get(key).push(q); + } + + const sections = [...buckets.entries()].map(([key, items]) => ({ + categoryKey: key === UNCATEGORIZED ? '' : key, + title: key === UNCATEGORIZED ? 'Uncategorized' : (labelByKey[key] || key), + items, + sortKey: Math.min(...items.map(q => q.orderIndex ?? 0)), + })); + + sections.sort((a, b) => a.sortKey - b.sortKey); + return sections; +} + +function renderQuestionnaireCard(q) { + const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`; + const showPts = parseInt(q.showPoints || 0, 10); + return `
- ${canEdit() ? '' : ''} + ${canEdit() ? '' : ''} ${esc(q.name)}
${esc(q.state || 'draft')} @@ -67,9 +249,36 @@ function renderGrid(questionnaires) { Results ${getRole() === 'admin' ? `` : ''}
-
+ `; +} + +function renderGrid(questionnaires, categoryKeys = []) { + const grid = document.getElementById('qGrid'); + + if (!questionnaires.length) { + grid.innerHTML = ` +
+ +

No questionnaires yet

+

Create your first questionnaire to get started.

+
`; - }).join(''); + return; + } + + const sections = groupQuestionnairesByCategory(questionnaires, categoryKeys); + + grid.innerHTML = ` +
+ ${sections.map(section => ` +
+

${categoryGermanInputHTML(section.categoryKey, section.title)}

+
+ ${section.items.map(q => renderQuestionnaireCard(q)).join('')} +
+
+ `).join('')} +
`; grid.querySelectorAll('.q-card').forEach(card => { card.addEventListener('click', (e) => { @@ -95,49 +304,67 @@ function renderGrid(questionnaires) { if (canEdit()) initGridDrag(questionnaires); } -function initGridDrag(questionnaires) { - const grid = document.getElementById('qGrid'); - if (!grid) return; - let dragItem = null; +function collectDashboardCardOrder() { + const ids = []; + document.querySelectorAll('.q-category-group').forEach(section => { + section.querySelectorAll('.q-category-grid .q-card').forEach(card => { + ids.push(card.dataset.id); + }); + }); + return ids; +} - grid.addEventListener('dragstart', (e) => { +function initGridDrag(questionnaires) { + const root = document.getElementById('qGrid'); + if (!root) return; + let dragItem = null; + let dragRow = null; + + root.addEventListener('dragstart', (e) => { const card = e.target.closest('.q-card'); if (!card || !e.target.closest('.drag-handle')) { e.preventDefault(); return; } dragItem = card; + dragRow = card.closest('.q-category-grid'); card.style.opacity = '0.5'; e.dataTransfer.effectAllowed = 'move'; }); - grid.addEventListener('dragover', (e) => { + root.addEventListener('dragover', (e) => { e.preventDefault(); - if (!dragItem) return; - const cards = [...grid.querySelectorAll('.q-card:not([style*="opacity"])')]; - const afterCard = cards.reduce((closest, child) => { - const box = child.getBoundingClientRect(); - const offset = e.clientY - box.top - box.height / 2; - if (offset < 0 && offset > closest.offset) return { offset, element: child }; - return closest; - }, { offset: Number.NEGATIVE_INFINITY }).element; + if (!dragItem || !dragRow) return; - if (afterCard) grid.insertBefore(dragItem, afterCard); - else grid.appendChild(dragItem); + const row = e.target.closest('.q-category-grid'); + if (!row || row !== dragRow) return; + + const cards = [...row.querySelectorAll('.q-card')].filter(c => c !== dragItem); + let insertBefore = null; + for (const child of cards) { + const box = child.getBoundingClientRect(); + if (e.clientX < box.left + box.width / 2) { + insertBefore = child; + break; + } + } + if (insertBefore) row.insertBefore(dragItem, insertBefore); + else row.appendChild(dragItem); }); - grid.addEventListener('dragend', async () => { + root.addEventListener('dragend', async () => { if (!dragItem) return; dragItem.style.opacity = ''; - const newOrder = [...grid.querySelectorAll('.q-card')].map(el => el.dataset.id); + const newOrder = collectDashboardCardOrder(); dragItem = null; + dragRow = null; for (let i = 0; i < newOrder.length; i++) { const id = newOrder[i]; - const q = questionnaires.find(q => q.questionnaireID === id); + const q = questionnaires.find(x => x.questionnaireID === id); if (q && parseInt(q.orderIndex, 10) !== i) { try { await apiPut('questionnaires.php', { questionnaireID: id, orderIndex: i }); q.orderIndex = i; - } catch (e) { - showToast(e.message, 'error'); + } catch (err) { + showToast(err.message, 'error'); return; } }