improved UX in dashboard
This commit is contained in:
272
common.php
272
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). */
|
/** 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;
|
static $catalog = null;
|
||||||
if ($catalog !== null) {
|
if (!$reload && $catalog !== null) {
|
||||||
return $catalog;
|
return $catalog;
|
||||||
}
|
}
|
||||||
$path = __DIR__ . '/data/app_ui_strings.json';
|
$path = qdb_app_string_catalog_path();
|
||||||
if (!is_readable($path)) {
|
if (!is_readable($path)) {
|
||||||
$catalog = ['keys' => [], 'germanDefaults' => []];
|
$catalog = ['keys' => [], 'germanDefaults' => []];
|
||||||
return $catalog;
|
return $catalog;
|
||||||
@ -262,6 +262,49 @@ function qdb_app_string_catalog(): array {
|
|||||||
return $catalog;
|
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 {
|
function qdb_app_string_key_set(): array {
|
||||||
$keys = qdb_app_string_catalog()['keys'] ?? [];
|
$keys = qdb_app_string_catalog()['keys'] ?? [];
|
||||||
return array_flip($keys);
|
return array_flip($keys);
|
||||||
@ -299,6 +342,204 @@ function qdb_dev_only_app_string_key_set(): array {
|
|||||||
return $set;
|
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<string, int> 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<array{key: string, displayKey: string, type: string, entityId: string, sortOrder: int, germanDefault: string, categoryKey: string}>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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<array{categoryKey: string, stringKey: string, questionnaireCount: int, orphaned: bool, germanLabel: string}>
|
||||||
|
*/
|
||||||
|
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.
|
* Global app UI strings (screens, toasts, auth, etc.) for the mobile app.
|
||||||
* @return list<array{key: string, type: string, entityId: string, sortOrder: int}>
|
* @return list<array{key: string, type: string, entityId: string, sortOrder: int}>
|
||||||
@ -309,6 +550,9 @@ function qdb_app_ui_string_entries(): array {
|
|||||||
$entries = [];
|
$entries = [];
|
||||||
$order = 0;
|
$order = 0;
|
||||||
foreach ($catalog['keys'] ?? [] as $key) {
|
foreach ($catalog['keys'] ?? [] as $key) {
|
||||||
|
if (qdb_is_questionnaire_group_string_key($key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$entries[] = [
|
$entries[] = [
|
||||||
'key' => $key,
|
'key' => $key,
|
||||||
'displayKey' => $key,
|
'displayKey' => $key,
|
||||||
@ -321,6 +565,20 @@ function qdb_app_ui_string_entries(): array {
|
|||||||
return $entries;
|
return $entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static catalog plus questionnaire_group_* keys for categoryKey values in use.
|
||||||
|
* @return list<array{key: string, type: string, entityId: string, sortOrder: int}>
|
||||||
|
*/
|
||||||
|
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).
|
* All string keys referenced by questionnaire content (questions, options, config).
|
||||||
* @return array<string, true>
|
* @return array<string, true>
|
||||||
@ -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).
|
* 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").
|
* 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();
|
$dev = qdb_dev_only_app_string_key_set();
|
||||||
$entries = [];
|
$entries = [];
|
||||||
foreach (qdb_app_ui_string_entries() as $e) {
|
foreach (qdb_app_ui_string_entries_merged($pdo) as $e) {
|
||||||
if (!isset($dev[$e['key']])) {
|
if (!isset($dev[$e['key']])) {
|
||||||
$entries[] = $e;
|
$entries[] = $e;
|
||||||
}
|
}
|
||||||
@ -913,7 +1171,7 @@ function qdb_load_translations_for_entries(PDO $pdo, array $entries): array {
|
|||||||
* @return array<string, array<string, string>>
|
* @return array<string, array<string, string>>
|
||||||
*/
|
*/
|
||||||
function qdb_build_app_translations_map(PDO $pdo): 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);
|
$translations = qdb_load_translations_for_entries($pdo, $entries);
|
||||||
$defaults = qdb_app_string_catalog()['germanDefaults'] ?? [];
|
$defaults = qdb_app_string_catalog()['germanDefaults'] ?? [];
|
||||||
$result = [];
|
$result = [];
|
||||||
@ -1397,7 +1655,7 @@ function qdb_export_translations_bundle(PDO $pdo): array {
|
|||||||
qdb_ensure_source_language($pdo);
|
qdb_ensure_source_language($pdo);
|
||||||
|
|
||||||
$exportEntries = [];
|
$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);
|
$appTranslations = qdb_load_translations_for_entries($pdo, $appEntries);
|
||||||
qdb_attach_translation_texts($appEntries, $appTranslations);
|
qdb_attach_translation_texts($appEntries, $appTranslations);
|
||||||
foreach ($appEntries as $e) {
|
foreach ($appEntries as $e) {
|
||||||
|
|||||||
@ -1,198 +1,187 @@
|
|||||||
{
|
{
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"description": "Coach-visible app UI strings only (excludes dev settings / database tools).",
|
"description": "Coach-visible app UI strings only (excludes dev settings \/ database tools).",
|
||||||
"keys": [
|
"keys": [
|
||||||
"all_clients",
|
"all_clients",
|
||||||
"answer",
|
"answer",
|
||||||
"april",
|
"april",
|
||||||
"august",
|
"august",
|
||||||
"auth_footer_accounts",
|
"auth_footer_accounts",
|
||||||
"auth_subtitle_change_password",
|
"auth_subtitle_change_password",
|
||||||
"auth_subtitle_login",
|
"auth_subtitle_login",
|
||||||
"auth_title",
|
"auth_title",
|
||||||
"cancel",
|
"cancel",
|
||||||
"choose_answer",
|
"choose_answer",
|
||||||
"choose_more_elements",
|
"choose_more_elements",
|
||||||
"client",
|
"client",
|
||||||
"client_code",
|
"client_code",
|
||||||
"client_code_exists",
|
"client_code_exists",
|
||||||
"coach_code",
|
"coach_code",
|
||||||
"confirm_password_hint",
|
"confirm_password_hint",
|
||||||
"date_after",
|
"date_after",
|
||||||
"date_before",
|
"date_before",
|
||||||
"day",
|
"day",
|
||||||
"december",
|
"december",
|
||||||
"done",
|
"done",
|
||||||
"download",
|
"download",
|
||||||
"download_failed_use_offline",
|
"download_failed_use_offline",
|
||||||
"enter_coach_code",
|
"enter_coach_code",
|
||||||
"error",
|
"error",
|
||||||
"error_invalid_data",
|
"error_invalid_data",
|
||||||
"error_not_found",
|
"error_not_found",
|
||||||
"questionnaire_title",
|
"questionnaire_title",
|
||||||
"exit_btn",
|
"exit_btn",
|
||||||
"february",
|
"february",
|
||||||
"fill_all_fields",
|
"fill_all_fields",
|
||||||
"fill_both_fields",
|
"fill_both_fields",
|
||||||
"hours_short",
|
"hours_short",
|
||||||
"january",
|
"january",
|
||||||
"july",
|
"july",
|
||||||
"june",
|
"june",
|
||||||
"lay",
|
"lay",
|
||||||
"consultation_unlock_requires_rhs",
|
"consultation_unlock_requires_rhs",
|
||||||
"locked",
|
"locked",
|
||||||
"questionnaire_chip_edit",
|
"questionnaire_chip_edit",
|
||||||
"questionnaire_chip_edit_pending",
|
"questionnaire_chip_edit_pending",
|
||||||
"questionnaire_group_closure",
|
"login_btn",
|
||||||
"questionnaire_group_consultation",
|
"login_failed_with_reason",
|
||||||
"questionnaire_group_demographics",
|
"login_required",
|
||||||
"questionnaire_group_followup",
|
"march",
|
||||||
"questionnaire_group_health_interview",
|
"may",
|
||||||
"questionnaire_group_integration",
|
"minutes_short",
|
||||||
"login_btn",
|
"month",
|
||||||
"login_failed_with_reason",
|
"new_password_hint",
|
||||||
"login_required",
|
"no_clients_assigned",
|
||||||
"march",
|
"no_profile",
|
||||||
"may",
|
"no_questionnaires",
|
||||||
"minutes_short",
|
"no_questions_available",
|
||||||
"month",
|
"questionnaire_missing_options",
|
||||||
"new_password_hint",
|
"none",
|
||||||
"no_clients_assigned",
|
"not_done",
|
||||||
"no_profile",
|
"november",
|
||||||
"no_questionnaires",
|
"october",
|
||||||
"no_questions_available",
|
"offline",
|
||||||
"questionnaire_missing_options",
|
"other_country",
|
||||||
"none",
|
"other_option",
|
||||||
"not_done",
|
"ok",
|
||||||
"november",
|
"online",
|
||||||
"october",
|
"password_hint",
|
||||||
"offline",
|
"password_too_short",
|
||||||
"other_country",
|
"passwords_dont_match",
|
||||||
"other_option",
|
"please_client_code",
|
||||||
"ok",
|
"please_username_password",
|
||||||
"online",
|
"points",
|
||||||
"password_hint",
|
"previous",
|
||||||
"password_too_short",
|
"question",
|
||||||
"passwords_dont_match",
|
"questions_filled",
|
||||||
"please_client_code",
|
"refresh",
|
||||||
"please_username_password",
|
"save",
|
||||||
"points",
|
"save_password_btn",
|
||||||
"previous",
|
"select_one_answer",
|
||||||
"question",
|
"select_one_answer_per_row",
|
||||||
"questions_filled",
|
"september",
|
||||||
"refresh",
|
"session_dash",
|
||||||
"save",
|
"session_label",
|
||||||
"save_password_btn",
|
"start",
|
||||||
"select_one_answer",
|
"start_upload",
|
||||||
"select_one_answer_per_row",
|
"upload",
|
||||||
"september",
|
"upload_failed_title",
|
||||||
"session_dash",
|
"upload_nothing_pending",
|
||||||
"session_label",
|
"upload_prepare_failed",
|
||||||
"start",
|
"upload_success_message",
|
||||||
"start_upload",
|
"username_hint",
|
||||||
"upload",
|
"year"
|
||||||
"upload_failed_title",
|
],
|
||||||
"upload_nothing_pending",
|
"germanDefaults": {
|
||||||
"upload_prepare_failed",
|
"all_clients": "Alle Klienten",
|
||||||
"upload_success_message",
|
"answer": "Antwort",
|
||||||
"username_hint",
|
"april": "April",
|
||||||
"year"
|
"august": "August",
|
||||||
],
|
"auth_footer_accounts": "Konten werden von Ihrer Organisation vergeben.",
|
||||||
"germanDefaults": {
|
"auth_subtitle_change_password": "Bitte legen Sie jetzt ein eigenes Passwort fest.",
|
||||||
"all_clients": "Alle Klienten",
|
"auth_subtitle_login": "Melden Sie sich mit Ihrem Coach-Konto an.",
|
||||||
"answer": "Antwort",
|
"auth_title": "BW Schützt",
|
||||||
"april": "April",
|
"cancel": "Abbrechen",
|
||||||
"august": "August",
|
"choose_answer": "Antwort wählen",
|
||||||
"auth_footer_accounts": "Konten werden von Ihrer Organisation vergeben.",
|
"choose_more_elements": "Es müssen mehr Elemenete ausgewählt werden.",
|
||||||
"auth_subtitle_change_password": "Bitte legen Sie jetzt ein eigenes Passwort fest.",
|
"client": "Klient",
|
||||||
"auth_subtitle_login": "Melden Sie sich mit Ihrem Coach-Konto an.",
|
"client_code": "Klientencode",
|
||||||
"auth_title": "BW Schützt",
|
"client_code_exists": "Dieser Client-Code existiert bereits.",
|
||||||
"cancel": "Abbrechen",
|
"coach_code": "Code Coach",
|
||||||
"choose_answer": "Antwort wählen",
|
"confirm_password_hint": "Passwort bestätigen",
|
||||||
"choose_more_elements": "Es müssen mehr Elemenete ausgewählt werden.",
|
"date_after": "Das Datum muss nach",
|
||||||
"client": "Klient",
|
"date_before": "Das Datum muss vor",
|
||||||
"client_code": "Klientencode",
|
"day": "Tag",
|
||||||
"client_code_exists": "Dieser Client-Code existiert bereits.",
|
"december": "Dezember",
|
||||||
"coach_code": "Code Coach",
|
"done": "Erledigt",
|
||||||
"confirm_password_hint": "Passwort bestätigen",
|
"download": "Herunterladen",
|
||||||
"date_after": "Das Datum muss nach",
|
"download_failed_use_offline": "Download fehlgeschlagen – arbeite offline mit vorhandener Datenbank",
|
||||||
"date_before": "Das Datum muss vor",
|
"enter_coach_code": "Bitte geben Sie Ihren Coach Code ein, um fortzufahren!",
|
||||||
"day": "Tag",
|
"error": "Fehler",
|
||||||
"december": "Dezember",
|
"error_invalid_data": "Ungültige Daten",
|
||||||
"done": "Erledigt",
|
"error_not_found": "Nicht gefunden",
|
||||||
"download": "Herunterladen",
|
"questionnaire_title": "Fragebögen",
|
||||||
"download_failed_use_offline": "Download fehlgeschlagen – arbeite offline mit vorhandener Datenbank",
|
"exit_btn": "Beenden",
|
||||||
"enter_coach_code": "Bitte geben Sie Ihren Coach Code ein, um fortzufahren!",
|
"february": "Februar",
|
||||||
"error": "Fehler",
|
"fill_all_fields": "Bitte alle Felder ausfüllen!",
|
||||||
"error_invalid_data": "Ungültige Daten",
|
"fill_both_fields": "Bitte beide Felder ausfüllen!",
|
||||||
"error_not_found": "Nicht gefunden",
|
"hours_short": "h",
|
||||||
"questionnaire_title": "Fragebögen",
|
"january": "Januar",
|
||||||
"exit_btn": "Beenden",
|
"july": "Juli",
|
||||||
"february": "Februar",
|
"june": "Juni",
|
||||||
"fill_all_fields": "Bitte alle Felder ausfüllen!",
|
"lay": "liegen.",
|
||||||
"fill_both_fields": "Bitte beide Felder ausfüllen!",
|
"consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS",
|
||||||
"hours_short": "h",
|
"locked": "Gesperrt",
|
||||||
"january": "Januar",
|
"questionnaire_chip_edit": "Bearbeiten",
|
||||||
"july": "Juli",
|
"questionnaire_chip_edit_pending": "Bearbeiten · Upload ausstehend",
|
||||||
"june": "Juni",
|
"login_btn": "Login",
|
||||||
"lay": "liegen.",
|
"login_failed_with_reason": "Login fehlgeschlagen: {reason}",
|
||||||
"consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS",
|
"login_required": "Bitte zuerst einloggen",
|
||||||
"locked": "Gesperrt",
|
"march": "März",
|
||||||
"questionnaire_chip_edit": "Bearbeiten",
|
"may": "Mai",
|
||||||
"questionnaire_chip_edit_pending": "Bearbeiten · Upload ausstehend",
|
"minutes_short": "m",
|
||||||
"questionnaire_group_closure": "Abschluss",
|
"month": "Monat",
|
||||||
"questionnaire_group_consultation": "Beratung",
|
"new_password_hint": "Neues Passwort",
|
||||||
"questionnaire_group_demographics": "Demografie",
|
"no_clients_assigned": "Ihr Supervisor hat Ihnen noch keine Klienten zugewiesen.",
|
||||||
"questionnaire_group_followup": "Nachbefragung",
|
"no_profile": "Dieser Klient ist noch nicht Teil der Datenbank",
|
||||||
"questionnaire_group_health_interview": "Gesundheitsinterview",
|
"no_questionnaires": "Keine Fragebögen vorhanden.",
|
||||||
"questionnaire_group_integration": "Integration (IPL)",
|
"no_questions_available": "Keine Fragen vorhanden.",
|
||||||
"login_btn": "Login",
|
"questionnaire_missing_options": "Dieser Fragebogen ist unvollständig (fehlende Antwortoptionen). Bitte wenden Sie sich an Ihren Administrator.",
|
||||||
"login_failed_with_reason": "Login fehlgeschlagen: {reason}",
|
"none": "Keine",
|
||||||
"login_required": "Bitte zuerst einloggen",
|
"not_done": "Nicht erledigt",
|
||||||
"march": "März",
|
"november": "November",
|
||||||
"may": "Mai",
|
"october": "Oktober",
|
||||||
"minutes_short": "m",
|
"offline": "Offline",
|
||||||
"month": "Monat",
|
"other_country": "anderes Land",
|
||||||
"new_password_hint": "Neues Passwort",
|
"other_option": "Sonstiges",
|
||||||
"no_clients_assigned": "Ihr Supervisor hat Ihnen noch keine Klienten zugewiesen.",
|
"ok": "OK",
|
||||||
"no_profile": "Dieser Klient ist noch nicht Teil der Datenbank",
|
"online": "Online",
|
||||||
"no_questionnaires": "Keine Fragebögen vorhanden.",
|
"password_hint": "Passwort",
|
||||||
"no_questions_available": "Keine Fragen vorhanden.",
|
"password_too_short": "Mindestens 6 Zeichen",
|
||||||
"questionnaire_missing_options": "Dieser Fragebogen ist unvollständig (fehlende Antwortoptionen). Bitte wenden Sie sich an Ihren Administrator.",
|
"passwords_dont_match": "Passwörter stimmen nicht überein",
|
||||||
"none": "Keine",
|
"please_client_code": "Bitte Klienten Code eingeben",
|
||||||
"not_done": "Nicht erledigt",
|
"please_username_password": "Bitte Benutzername und Passwort eingeben.",
|
||||||
"november": "November",
|
"points": "Punkte",
|
||||||
"october": "Oktober",
|
"previous": "Zurück",
|
||||||
"offline": "Offline",
|
"question": "Frage",
|
||||||
"other_country": "anderes Land",
|
"questions_filled": "Antworten",
|
||||||
"other_option": "Sonstiges",
|
"refresh": "Aktualisieren",
|
||||||
"ok": "OK",
|
"save": "Speichern",
|
||||||
"online": "Online",
|
"save_password_btn": "Passwort speichern",
|
||||||
"password_hint": "Passwort",
|
"select_one_answer": "Bitte wählen Sie eine Antwort aus!",
|
||||||
"password_too_short": "Mindestens 6 Zeichen",
|
"select_one_answer_per_row": "Bitte wählen Sie eine Antwort pro Reihe aus!",
|
||||||
"passwords_dont_match": "Passwörter stimmen nicht überein",
|
"september": "September",
|
||||||
"please_client_code": "Bitte Klienten Code eingeben",
|
"session_dash": "Sitzung: —",
|
||||||
"please_username_password": "Bitte Benutzername und Passwort eingeben.",
|
"session_label": "Sitzung",
|
||||||
"points": "Punkte",
|
"start": "Start",
|
||||||
"previous": "Zurück",
|
"start_upload": "Upload starten?",
|
||||||
"question": "Frage",
|
"upload": "Hochladen",
|
||||||
"questions_filled": "Antworten",
|
"upload_failed_title": "Upload fehlgeschlagen",
|
||||||
"refresh": "Aktualisieren",
|
"upload_nothing_pending": "Es wurde nichts hochgeladen. Es liegen keine abgeschlossenen Fragebögen zum Upload vor. Bitte zuerst einen Fragebogen abschließen.",
|
||||||
"save": "Speichern",
|
"upload_prepare_failed": "Es wurde nichts hochgeladen. Die Daten konnten nicht für den Upload vorbereitet werden. Bitte erneut synchronisieren oder den Support kontaktieren.",
|
||||||
"save_password_btn": "Passwort speichern",
|
"upload_success_message": "Hochladen: {count} erledigt.",
|
||||||
"select_one_answer": "Bitte wählen Sie eine Antwort aus!",
|
"username_hint": "Benutzername",
|
||||||
"select_one_answer_per_row": "Bitte wählen Sie eine Antwort pro Reihe aus!",
|
"year": "Jahr",
|
||||||
"september": "September",
|
"questionnaire_group_demographics": "Demografie"
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -149,7 +149,7 @@ Fields:
|
|||||||
- `name`: display/admin name.
|
- `name`: display/admin name.
|
||||||
- `showPoints`: whether the app may show the points result.
|
- `showPoints`: whether the app may show the points result.
|
||||||
- `condition`: JSON condition object. Empty conditions are returned as `{}`.
|
- `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_<categoryKey>` (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`.
|
Only questionnaires with `state = "active"` are returned, ordered by server `orderIndex`.
|
||||||
|
|
||||||
|
|||||||
@ -35,8 +35,9 @@ case 'GET':
|
|||||||
$r['conditionJson'] = $r['conditionJson'] ?: '{}';
|
$r['conditionJson'] = $r['conditionJson'] ?: '{}';
|
||||||
}
|
}
|
||||||
unset($r);
|
unset($r);
|
||||||
|
$categoryKeys = qdb_list_category_keys_for_dashboard($pdo);
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
json_success(['questionnaires' => $rows]);
|
json_success(['questionnaires' => $rows, 'categoryKeys' => $categoryKeys]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log('questionnaires GET: ' . $e->getMessage());
|
error_log('questionnaires GET: ' . $e->getMessage());
|
||||||
@ -48,6 +49,44 @@ case 'POST':
|
|||||||
require_role(['admin', 'supervisor'], $tokenRec);
|
require_role(['admin', 'supervisor'], $tokenRec);
|
||||||
$body = read_json_body();
|
$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') {
|
if (($body['action'] ?? '') === 'importBundle') {
|
||||||
$bundle = $body['bundle'] ?? null;
|
$bundle = $body['bundle'] ?? null;
|
||||||
if (!is_array($bundle)) {
|
if (!is_array($bundle)) {
|
||||||
@ -86,6 +125,9 @@ case 'POST':
|
|||||||
$order = (int)$pdo->query("SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire")->fetchColumn();
|
$order = (int)$pdo->query("SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire")->fetchColumn();
|
||||||
}
|
}
|
||||||
$catKey = trim($body['categoryKey'] ?? '');
|
$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)
|
$pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
|
||||||
VALUES (:id, :n, :v, :s, :o, :sp, :cj, :ck)")
|
VALUES (:id, :n, :v, :s, :o, :sp, :cj, :ck)")
|
||||||
->execute([':id' => $id, ':n' => $name, ':v' => $version, ':s' => $state,
|
->execute([':id' => $id, ':n' => $name, ':v' => $version, ':s' => $state,
|
||||||
@ -129,6 +171,9 @@ case 'PUT':
|
|||||||
$catKey = array_key_exists('categoryKey', $body)
|
$catKey = array_key_exists('categoryKey', $body)
|
||||||
? trim((string)$body['categoryKey'])
|
? trim((string)$body['categoryKey'])
|
||||||
: trim($row['categoryKey'] ?? '');
|
: trim($row['categoryKey'] ?? '');
|
||||||
|
if ($catKey !== '') {
|
||||||
|
$catKey = qdb_normalize_stable_key($catKey);
|
||||||
|
}
|
||||||
|
|
||||||
$pdo->prepare("UPDATE questionnaire SET name = :n, version = :v, state = :s,
|
$pdo->prepare("UPDATE questionnaire SET name = :n, version = :v, state = :s,
|
||||||
orderIndex = :o, showPoints = :sp, conditionJson = :cj, categoryKey = :ck
|
orderIndex = :o, showPoints = :sp, conditionJson = :cj, categoryKey = :ck
|
||||||
|
|||||||
@ -39,7 +39,7 @@ case 'GET':
|
|||||||
SELECT questionnaireID, name, orderIndex FROM questionnaire ORDER BY orderIndex
|
SELECT questionnaireID, name, orderIndex FROM questionnaire ORDER BY orderIndex
|
||||||
")->fetchAll(PDO::FETCH_ASSOC);
|
")->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);
|
$appTranslations = qdb_load_translations_for_entries($pdo, $appEntries);
|
||||||
qdb_attach_translation_texts($appEntries, $appTranslations);
|
qdb_attach_translation_texts($appEntries, $appTranslations);
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,13 @@ CREATE TABLE IF NOT EXISTS client (
|
|||||||
FOREIGN KEY(coachID) REFERENCES coach(coachID)
|
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 (
|
CREATE TABLE IF NOT EXISTS questionnaire (
|
||||||
questionnaireID TEXT NOT NULL PRIMARY KEY,
|
questionnaireID TEXT NOT NULL PRIMARY KEY,
|
||||||
name TEXT NOT NULL DEFAULT '',
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
|||||||
@ -342,12 +342,100 @@ code {
|
|||||||
gap: 8px;
|
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 {
|
.q-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.q-card .drag-handle {
|
||||||
|
margin-right: 6px;
|
||||||
|
cursor: grab;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
.q-card {
|
.q-card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@ -751,12 +839,27 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
|
|||||||
.q-grid { grid-template-columns: 1fr; }
|
.q-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.q-category-grid > .q-card {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Role badges */
|
/* Role badges */
|
||||||
.badge-admin { background: var(--badge-admin-bg); color: var(--badge-admin-fg); }
|
.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-supervisor { background: var(--badge-supervisor-bg); color: var(--badge-supervisor-fg); }
|
||||||
.badge-coach { background: var(--badge-coach-bg); color: var(--badge-coach-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; }
|
.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 */
|
/* Assignment page — client table sized to rows (max 50); coach panel matches */
|
||||||
.assign-page {
|
.assign-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -9,7 +9,8 @@ export async function dashboardPage() {
|
|||||||
<h1>Questionnaires</h1>
|
<h1>Questionnaires</h1>
|
||||||
<div class="actions" id="headerActions"></div>
|
<div class="actions" id="headerActions"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="qGrid" class="q-grid"><div class="spinner"></div></div>
|
<div id="qGrid" class="q-dashboard-root"><div class="spinner"></div></div>
|
||||||
|
<div id="categoryKeysPanel"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (canEdit()) {
|
if (canEdit()) {
|
||||||
@ -23,35 +24,216 @@ export async function dashboardPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiGet('questionnaires.php');
|
const data = await apiGet('questionnaires.php');
|
||||||
renderGrid(data.questionnaires || []);
|
const categoryKeys = data.categoryKeys || [];
|
||||||
|
renderGrid(data.questionnaires || [], categoryKeys);
|
||||||
|
renderCategoryKeysPanel(categoryKeys);
|
||||||
|
bindCategoryLabelEditors();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
document.getElementById('qGrid').innerHTML = `<p class="error-text">${e.message}</p>`;
|
document.getElementById('qGrid').innerHTML = `<p class="error-text">${e.message}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGrid(questionnaires) {
|
function renderCategoryKeysPanel(categoryKeys) {
|
||||||
const grid = document.getElementById('qGrid');
|
const panel = document.getElementById('categoryKeysPanel');
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
if (!questionnaires.length) {
|
if (!canEdit()) {
|
||||||
grid.innerHTML = `
|
panel.innerHTML = '';
|
||||||
<div class="empty-state" style="grid-column:1/-1">
|
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 12h6m-3-3v6m-7 4h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
|
||||||
<h3>No questionnaires yet</h3>
|
|
||||||
<p>Create your first questionnaire to get started.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.innerHTML = questionnaires.map(q => {
|
if (!categoryKeys.length) {
|
||||||
const stateClass = `badge-${(q.state || 'draft').toLowerCase().replace(/\s+/g, '_')}`;
|
panel.innerHTML = `
|
||||||
const showPts = parseInt(q.showPoints || 0, 10);
|
<div class="card category-keys-card" style="margin-top:16px">
|
||||||
return `
|
<h3 style="margin:0 0 8px;font-size:1rem">Category keys</h3>
|
||||||
|
<p class="data-toolbar-hint" style="margin:0">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="card category-keys-card" style="margin-top:16px">
|
||||||
|
<h3 style="margin:0 0 8px;font-size:1rem">Category keys</h3>
|
||||||
|
<p class="data-toolbar-hint" style="margin:0 0 12px">
|
||||||
|
German labels for the app opening screen. Other languages: <a href="#/translations">Translations</a>.
|
||||||
|
Delete removes the key from questionnaires, all translations, and catalog defaults.
|
||||||
|
</p>
|
||||||
|
<div class="table-wrapper" style="max-height:none">
|
||||||
|
<table class="data-table category-keys-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category key</th>
|
||||||
|
<th>App string key</th>
|
||||||
|
<th>German label</th>
|
||||||
|
<th>Questionnaires</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${categoryKeys.map(row => `
|
||||||
|
<tr class="${row.orphaned ? 'row-orphan-category' : ''}">
|
||||||
|
<td><code>${esc(row.categoryKey)}</code></td>
|
||||||
|
<td><code class="data-toolbar-hint">${esc(row.stringKey)}</code></td>
|
||||||
|
<td class="category-label-cell">
|
||||||
|
<input type="text" class="category-german-input"
|
||||||
|
data-category-key="${esc(row.categoryKey)}"
|
||||||
|
value="${esc(row.germanLabel || '')}"
|
||||||
|
placeholder="German label"
|
||||||
|
aria-label="German label for ${esc(row.categoryKey)}">
|
||||||
|
</td>
|
||||||
|
<td>${row.orphaned
|
||||||
|
? '<span class="data-toolbar-hint">unused</span>'
|
||||||
|
: esc(String(row.questionnaireCount))}</td>
|
||||||
|
<td style="text-align:right;white-space:nowrap">
|
||||||
|
<button type="button" class="btn btn-sm btn-danger delete-cat-key-btn"
|
||||||
|
data-key="${esc(row.categoryKey)}"
|
||||||
|
data-count="${row.questionnaireCount}">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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 `<input type="text" class="category-german-input category-title-input"
|
||||||
|
data-category-key="${esc(categoryKey)}"
|
||||||
|
value="${esc(label)}"
|
||||||
|
aria-label="German category title">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
<div class="q-card" data-id="${q.questionnaireID}" draggable="${canEdit()}">
|
<div class="q-card" data-id="${q.questionnaireID}" draggable="${canEdit()}">
|
||||||
<div class="q-card-header">
|
<div class="q-card-header">
|
||||||
<div class="q-card-title">
|
<div class="q-card-title">
|
||||||
${canEdit() ? '<span class="drag-handle" title="Drag to reorder" style="margin-right:6px;cursor:grab;color:var(--text-secondary)">☰</span>' : ''}
|
${canEdit() ? '<span class="drag-handle" title="Drag to reorder" aria-label="Drag to reorder">☰</span>' : ''}
|
||||||
${esc(q.name)}
|
${esc(q.name)}
|
||||||
</div>
|
</div>
|
||||||
<span class="badge ${stateClass}">${esc(q.state || 'draft')}</span>
|
<span class="badge ${stateClass}">${esc(q.state || 'draft')}</span>
|
||||||
@ -67,9 +249,36 @@ function renderGrid(questionnaires) {
|
|||||||
<a href="#/questionnaire/${q.questionnaireID}/results" class="btn btn-sm">Results</a>
|
<a href="#/questionnaire/${q.questionnaireID}/results" class="btn btn-sm">Results</a>
|
||||||
${getRole() === 'admin' ? `<button class="btn btn-sm btn-danger delete-q-btn" data-id="${q.questionnaireID}">Delete</button>` : ''}
|
${getRole() === 'admin' ? `<button class="btn btn-sm btn-danger delete-q-btn" data-id="${q.questionnaireID}">Delete</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid(questionnaires, categoryKeys = []) {
|
||||||
|
const grid = document.getElementById('qGrid');
|
||||||
|
|
||||||
|
if (!questionnaires.length) {
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 12h6m-3-3v6m-7 4h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||||
|
<h3>No questionnaires yet</h3>
|
||||||
|
<p>Create your first questionnaire to get started.</p>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = groupQuestionnairesByCategory(questionnaires, categoryKeys);
|
||||||
|
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div class="q-dashboard-groups">
|
||||||
|
${sections.map(section => `
|
||||||
|
<section class="q-category-group" data-category="${esc(section.categoryKey)}">
|
||||||
|
<h2 class="q-category-title">${categoryGermanInputHTML(section.categoryKey, section.title)}</h2>
|
||||||
|
<div class="q-category-grid">
|
||||||
|
${section.items.map(q => renderQuestionnaireCard(q)).join('')}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
grid.querySelectorAll('.q-card').forEach(card => {
|
grid.querySelectorAll('.q-card').forEach(card => {
|
||||||
card.addEventListener('click', (e) => {
|
card.addEventListener('click', (e) => {
|
||||||
@ -95,49 +304,67 @@ function renderGrid(questionnaires) {
|
|||||||
if (canEdit()) initGridDrag(questionnaires);
|
if (canEdit()) initGridDrag(questionnaires);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initGridDrag(questionnaires) {
|
function collectDashboardCardOrder() {
|
||||||
const grid = document.getElementById('qGrid');
|
const ids = [];
|
||||||
if (!grid) return;
|
document.querySelectorAll('.q-category-group').forEach(section => {
|
||||||
let dragItem = null;
|
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');
|
const card = e.target.closest('.q-card');
|
||||||
if (!card || !e.target.closest('.drag-handle')) { e.preventDefault(); return; }
|
if (!card || !e.target.closest('.drag-handle')) { e.preventDefault(); return; }
|
||||||
dragItem = card;
|
dragItem = card;
|
||||||
|
dragRow = card.closest('.q-category-grid');
|
||||||
card.style.opacity = '0.5';
|
card.style.opacity = '0.5';
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
});
|
});
|
||||||
|
|
||||||
grid.addEventListener('dragover', (e) => {
|
root.addEventListener('dragover', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!dragItem) return;
|
if (!dragItem || !dragRow) 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 (afterCard) grid.insertBefore(dragItem, afterCard);
|
const row = e.target.closest('.q-category-grid');
|
||||||
else grid.appendChild(dragItem);
|
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;
|
if (!dragItem) return;
|
||||||
dragItem.style.opacity = '';
|
dragItem.style.opacity = '';
|
||||||
const newOrder = [...grid.querySelectorAll('.q-card')].map(el => el.dataset.id);
|
const newOrder = collectDashboardCardOrder();
|
||||||
dragItem = null;
|
dragItem = null;
|
||||||
|
dragRow = null;
|
||||||
|
|
||||||
for (let i = 0; i < newOrder.length; i++) {
|
for (let i = 0; i < newOrder.length; i++) {
|
||||||
const id = newOrder[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) {
|
if (q && parseInt(q.orderIndex, 10) !== i) {
|
||||||
try {
|
try {
|
||||||
await apiPut('questionnaires.php', { questionnaireID: id, orderIndex: i });
|
await apiPut('questionnaires.php', { questionnaireID: id, orderIndex: i });
|
||||||
q.orderIndex = i;
|
q.orderIndex = i;
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
showToast(e.message, 'error');
|
showToast(err.message, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user