This commit is contained in:
@ -2321,8 +2321,8 @@ function qdb_import_scoring_profiles_bundle(PDO $pdo, array $profiles, bool $rep
|
|||||||
$now = time();
|
$now = time();
|
||||||
$pdo->prepare(
|
$pdo->prepare(
|
||||||
'INSERT INTO scoring_profile (profileID, name, description, isActive,
|
'INSERT INTO scoring_profile (profileID, name, description, isActive,
|
||||||
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
|
greenMin, greenMax, yellowMin, yellowMax, redMin, processCompleteBands, createdAt, updatedAt)
|
||||||
VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :ca, :ua)'
|
VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :pcb, :ca, :ua)'
|
||||||
)->execute([
|
)->execute([
|
||||||
':id' => $profileID,
|
':id' => $profileID,
|
||||||
':name' => $name,
|
':name' => $name,
|
||||||
@ -2333,6 +2333,7 @@ function qdb_import_scoring_profiles_bundle(PDO $pdo, array $profiles, bool $rep
|
|||||||
':ymin' => (int)($profile['yellowMin'] ?? ((int)($profile['greenMax'] ?? 12) + 1)),
|
':ymin' => (int)($profile['yellowMin'] ?? ((int)($profile['greenMax'] ?? 12) + 1)),
|
||||||
':ymax' => (int)($profile['yellowMax'] ?? 36),
|
':ymax' => (int)($profile['yellowMax'] ?? 36),
|
||||||
':rmin' => (int)($profile['redMin'] ?? ((int)($profile['yellowMax'] ?? 36) + 1)),
|
':rmin' => (int)($profile['redMin'] ?? ((int)($profile['yellowMax'] ?? 36) + 1)),
|
||||||
|
':pcb' => qdb_encode_process_complete_bands($profile['processCompleteBands'] ?? []),
|
||||||
':ca' => (int)($profile['createdAt'] ?? $now),
|
':ca' => (int)($profile['createdAt'] ?? $now),
|
||||||
':ua' => (int)($profile['updatedAt'] ?? $now),
|
':ua' => (int)($profile['updatedAt'] ?? $now),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -105,11 +105,16 @@
|
|||||||
"passwords_dont_match",
|
"passwords_dont_match",
|
||||||
"please_client_code",
|
"please_client_code",
|
||||||
"please_username_password",
|
"please_username_password",
|
||||||
|
"process_complete_body",
|
||||||
|
"process_complete_ok",
|
||||||
|
"process_complete_set_category",
|
||||||
|
"process_complete_title",
|
||||||
"points",
|
"points",
|
||||||
"previous",
|
"previous",
|
||||||
"question",
|
"question",
|
||||||
"questions_filled",
|
"questions_filled",
|
||||||
"refresh",
|
"refresh",
|
||||||
|
"review_required_before_continue",
|
||||||
"review_scores",
|
"review_scores",
|
||||||
"review_scores_agree",
|
"review_scores_agree",
|
||||||
"review_scores_calculated_category",
|
"review_scores_calculated_category",
|
||||||
@ -252,11 +257,16 @@
|
|||||||
"passwords_dont_match": "Passwörter stimmen nicht überein",
|
"passwords_dont_match": "Passwörter stimmen nicht überein",
|
||||||
"please_client_code": "Bitte Klienten Code eingeben",
|
"please_client_code": "Bitte Klienten Code eingeben",
|
||||||
"please_username_password": "Bitte Benutzername und Passwort eingeben.",
|
"please_username_password": "Bitte Benutzername und Passwort eingeben.",
|
||||||
|
"process_complete_body": "Dieser Klient hat den Prozess abgeschlossen — alle verfügbaren Fragebögen sind erledigt oder eine als abgeschlossen markierte Kategorie wurde gesetzt. Sie können die Kategorie ändern, um weitere Fragebögen freizuschalten.",
|
||||||
|
"process_complete_ok": "OK",
|
||||||
|
"process_complete_set_category": "Andere Kategorie setzen",
|
||||||
|
"process_complete_title": "Prozess abgeschlossen",
|
||||||
"points": "Punkte",
|
"points": "Punkte",
|
||||||
"previous": "Zurück",
|
"previous": "Zurück",
|
||||||
"question": "Frage",
|
"question": "Frage",
|
||||||
"questions_filled": "Antworten",
|
"questions_filled": "Antworten",
|
||||||
"refresh": "Aktualisieren",
|
"refresh": "Aktualisieren",
|
||||||
|
"review_required_before_continue": "Bitte schließen Sie zuerst die Punkteprüfung ab, bevor Sie weitere Fragebögen ausfüllen.",
|
||||||
"review_scores": "Punkte prüfen",
|
"review_scores": "Punkte prüfen",
|
||||||
"review_scores_agree": "Berechnete Kategorie bestätigen",
|
"review_scores_agree": "Berechnete Kategorie bestätigen",
|
||||||
"review_scores_calculated_category": "Berechnete Kategorie",
|
"review_scores_calculated_category": "Berechnete Kategorie",
|
||||||
|
|||||||
11
db_init.php
11
db_init.php
@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) {
|
|||||||
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
|
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
|
||||||
}
|
}
|
||||||
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
||||||
define('QDB_VERSION', 12);
|
define('QDB_VERSION', 13);
|
||||||
|
|
||||||
function qdb_table_exists(PDO $pdo, string $table): bool {
|
function qdb_table_exists(PDO $pdo, string $table): bool {
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
@ -227,6 +227,7 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
|
|||||||
yellowMin INTEGER NOT NULL DEFAULT 13,
|
yellowMin INTEGER NOT NULL DEFAULT 13,
|
||||||
yellowMax INTEGER NOT NULL DEFAULT 36,
|
yellowMax INTEGER NOT NULL DEFAULT 36,
|
||||||
redMin INTEGER NOT NULL DEFAULT 37,
|
redMin INTEGER NOT NULL DEFAULT 37,
|
||||||
|
processCompleteBands TEXT NOT NULL DEFAULT '[]',
|
||||||
createdAt INTEGER NOT NULL DEFAULT 0,
|
createdAt INTEGER NOT NULL DEFAULT 0,
|
||||||
updatedAt INTEGER NOT NULL DEFAULT 0
|
updatedAt INTEGER NOT NULL DEFAULT 0
|
||||||
)
|
)
|
||||||
@ -339,6 +340,14 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
|
|||||||
$changed = true;
|
$changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
||||||
|
if ($currentVersion < 13 && qdb_table_exists($pdo, 'scoring_profile')) {
|
||||||
|
if (!qdb_column_exists($pdo, 'scoring_profile', 'processCompleteBands')) {
|
||||||
|
$pdo->exec("ALTER TABLE scoring_profile ADD COLUMN processCompleteBands TEXT NOT NULL DEFAULT '[]'");
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
$currentVersion = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
|
||||||
if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) {
|
if ($currentVersion < 6 && qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
$pdo->exec(
|
$pdo->exec(
|
||||||
|
|||||||
@ -43,14 +43,15 @@ case 'POST':
|
|||||||
if ($members === []) {
|
if ($members === []) {
|
||||||
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
|
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
|
||||||
}
|
}
|
||||||
|
$processCompleteBands = qdb_parse_process_complete_bands($body['processCompleteBands'] ?? []);
|
||||||
try {
|
try {
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||||
$profileID = bin2hex(random_bytes(16));
|
$profileID = bin2hex(random_bytes(16));
|
||||||
$now = time();
|
$now = time();
|
||||||
$pdo->prepare(
|
$pdo->prepare(
|
||||||
'INSERT INTO scoring_profile (profileID, name, description, isActive,
|
'INSERT INTO scoring_profile (profileID, name, description, isActive,
|
||||||
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
|
greenMin, greenMax, yellowMin, yellowMax, redMin, processCompleteBands, createdAt, updatedAt)
|
||||||
VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :ca, :ua)'
|
VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :pcb, :ca, :ua)'
|
||||||
)->execute([
|
)->execute([
|
||||||
':id' => $profileID,
|
':id' => $profileID,
|
||||||
':name' => $name,
|
':name' => $name,
|
||||||
@ -61,6 +62,7 @@ case 'POST':
|
|||||||
':ymin' => $bands['yellowMin'],
|
':ymin' => $bands['yellowMin'],
|
||||||
':ymax' => $bands['yellowMax'],
|
':ymax' => $bands['yellowMax'],
|
||||||
':rmin' => $bands['redMin'],
|
':rmin' => $bands['redMin'],
|
||||||
|
':pcb' => qdb_encode_process_complete_bands($processCompleteBands),
|
||||||
':ca' => $now,
|
':ca' => $now,
|
||||||
':ua' => $now,
|
':ua' => $now,
|
||||||
]);
|
]);
|
||||||
@ -101,10 +103,13 @@ case 'PUT':
|
|||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
|
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
|
||||||
}
|
}
|
||||||
|
$processCompleteBands = array_key_exists('processCompleteBands', $body)
|
||||||
|
? qdb_parse_process_complete_bands($body['processCompleteBands'])
|
||||||
|
: qdb_row_process_complete_bands($existing);
|
||||||
$pdo->prepare(
|
$pdo->prepare(
|
||||||
'UPDATE scoring_profile SET name = :name, description = :desc, isActive = :act,
|
'UPDATE scoring_profile SET name = :name, description = :desc, isActive = :act,
|
||||||
greenMin = :gmin, greenMax = :gmax, yellowMin = :ymin, yellowMax = :ymax, redMin = :rmin,
|
greenMin = :gmin, greenMax = :gmax, yellowMin = :ymin, yellowMax = :ymax, redMin = :rmin,
|
||||||
updatedAt = :ua WHERE profileID = :id'
|
processCompleteBands = :pcb, updatedAt = :ua WHERE profileID = :id'
|
||||||
)->execute([
|
)->execute([
|
||||||
':name' => $name,
|
':name' => $name,
|
||||||
':desc' => trim($body['description'] ?? $existing['description']),
|
':desc' => trim($body['description'] ?? $existing['description']),
|
||||||
@ -114,6 +119,7 @@ case 'PUT':
|
|||||||
':ymin' => $bands['yellowMin'],
|
':ymin' => $bands['yellowMin'],
|
||||||
':ymax' => $bands['yellowMax'],
|
':ymax' => $bands['yellowMax'],
|
||||||
':rmin' => $bands['redMin'],
|
':rmin' => $bands['redMin'],
|
||||||
|
':pcb' => qdb_encode_process_complete_bands($processCompleteBands),
|
||||||
':ua' => time(),
|
':ua' => time(),
|
||||||
':id' => $profileID,
|
':id' => $profileID,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -473,7 +473,7 @@ function qdb_list_scoring_profiles(PDO $pdo): array {
|
|||||||
$stmt = $pdo->query(
|
$stmt = $pdo->query(
|
||||||
'SELECT profileID, name, description, isActive,
|
'SELECT profileID, name, description, isActive,
|
||||||
greenMin, greenMax, yellowMin, yellowMax, redMin,
|
greenMin, greenMax, yellowMin, yellowMax, redMin,
|
||||||
createdAt, updatedAt
|
processCompleteBands, createdAt, updatedAt
|
||||||
FROM scoring_profile ORDER BY name'
|
FROM scoring_profile ORDER BY name'
|
||||||
);
|
);
|
||||||
$profiles = [];
|
$profiles = [];
|
||||||
@ -481,6 +481,7 @@ function qdb_list_scoring_profiles(PDO $pdo): array {
|
|||||||
$row['isActive'] = (int)$row['isActive'];
|
$row['isActive'] = (int)$row['isActive'];
|
||||||
$bands = qdb_normalize_scoring_bands($row);
|
$bands = qdb_normalize_scoring_bands($row);
|
||||||
$row = array_merge($row, $bands);
|
$row = array_merge($row, $bands);
|
||||||
|
$row['processCompleteBands'] = qdb_row_process_complete_bands($row);
|
||||||
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $row['profileID']);
|
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $row['profileID']);
|
||||||
$profiles[] = $row;
|
$profiles[] = $row;
|
||||||
}
|
}
|
||||||
@ -491,7 +492,7 @@ function qdb_get_scoring_profile(PDO $pdo, string $profileID): ?array {
|
|||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
'SELECT profileID, name, description, isActive,
|
'SELECT profileID, name, description, isActive,
|
||||||
greenMin, greenMax, yellowMin, yellowMax, redMin,
|
greenMin, greenMax, yellowMin, yellowMax, redMin,
|
||||||
createdAt, updatedAt
|
processCompleteBands, createdAt, updatedAt
|
||||||
FROM scoring_profile WHERE profileID = :pid'
|
FROM scoring_profile WHERE profileID = :pid'
|
||||||
);
|
);
|
||||||
$stmt->execute([':pid' => $profileID]);
|
$stmt->execute([':pid' => $profileID]);
|
||||||
@ -501,6 +502,7 @@ function qdb_get_scoring_profile(PDO $pdo, string $profileID): ?array {
|
|||||||
}
|
}
|
||||||
$row['isActive'] = (int)$row['isActive'];
|
$row['isActive'] = (int)$row['isActive'];
|
||||||
$row = array_merge($row, qdb_normalize_scoring_bands($row));
|
$row = array_merge($row, qdb_normalize_scoring_bands($row));
|
||||||
|
$row['processCompleteBands'] = qdb_row_process_complete_bands($row);
|
||||||
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $profileID);
|
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $profileID);
|
||||||
return $row;
|
return $row;
|
||||||
}
|
}
|
||||||
@ -598,12 +600,13 @@ function qdb_seed_default_rhs_scoring_profile(PDO $pdo): ?string {
|
|||||||
$now = time();
|
$now = time();
|
||||||
$pdo->prepare(
|
$pdo->prepare(
|
||||||
'INSERT INTO scoring_profile (profileID, name, description, isActive,
|
'INSERT INTO scoring_profile (profileID, name, description, isActive,
|
||||||
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
|
greenMin, greenMax, yellowMin, yellowMax, redMin, processCompleteBands, createdAt, updatedAt)
|
||||||
VALUES (:id, :name, :desc, 1, 0, 12, 13, 36, 37, :ca, :ua)'
|
VALUES (:id, :name, :desc, 1, 0, 12, 13, 36, 37, :pcb, :ca, :ua)'
|
||||||
)->execute([
|
)->execute([
|
||||||
':id' => $profileID,
|
':id' => $profileID,
|
||||||
':name' => 'RHS Index',
|
':name' => 'RHS Index',
|
||||||
':desc' => 'Legacy RHS thresholds (green 0–12, yellow 13–36, red 37+)',
|
':desc' => 'Legacy RHS thresholds (green 0–12, yellow 13–36, red 37+)',
|
||||||
|
':pcb' => '[]',
|
||||||
':ca' => $now,
|
':ca' => $now,
|
||||||
':ua' => $now,
|
':ua' => $now,
|
||||||
]);
|
]);
|
||||||
@ -618,6 +621,56 @@ function qdb_is_valid_scoring_band(?string $band): bool {
|
|||||||
return in_array($band, ['green', 'yellow', 'red'], true);
|
return in_array($band, ['green', 'yellow', 'red'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $raw
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function qdb_parse_process_complete_bands($raw): array {
|
||||||
|
if (is_string($raw)) {
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
$raw = is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
if (!is_array($raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($raw as $band) {
|
||||||
|
$b = strtolower(trim((string)$band));
|
||||||
|
if (qdb_is_valid_scoring_band($b)) {
|
||||||
|
$out[] = $b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values(array_unique($out));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function qdb_decode_process_complete_bands(?string $json): array {
|
||||||
|
if ($json === null || trim($json) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return qdb_parse_process_complete_bands(json_decode($json, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $bands
|
||||||
|
*/
|
||||||
|
function qdb_encode_process_complete_bands(array $bands): string {
|
||||||
|
return json_encode(qdb_parse_process_complete_bands($bands), JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function qdb_row_process_complete_bands(array $row): array {
|
||||||
|
if (array_key_exists('processCompleteBands', $row) && is_array($row['processCompleteBands'])) {
|
||||||
|
return qdb_parse_process_complete_bands($row['processCompleteBands']);
|
||||||
|
}
|
||||||
|
return qdb_decode_process_complete_bands((string)($row['processCompleteBands'] ?? '[]'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coach decision when set; otherwise the server-computed band.
|
* Coach decision when set; otherwise the server-computed band.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -225,6 +225,7 @@ CREATE TABLE IF NOT EXISTS scoring_profile (
|
|||||||
yellowMin INTEGER NOT NULL DEFAULT 13,
|
yellowMin INTEGER NOT NULL DEFAULT 13,
|
||||||
yellowMax INTEGER NOT NULL DEFAULT 36,
|
yellowMax INTEGER NOT NULL DEFAULT 36,
|
||||||
redMin INTEGER NOT NULL DEFAULT 37,
|
redMin INTEGER NOT NULL DEFAULT 37,
|
||||||
|
processCompleteBands TEXT NOT NULL DEFAULT '[]',
|
||||||
createdAt INTEGER NOT NULL DEFAULT 0,
|
createdAt INTEGER NOT NULL DEFAULT 0,
|
||||||
updatedAt INTEGER NOT NULL DEFAULT 0
|
updatedAt INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3548,6 +3548,105 @@ select:disabled {
|
|||||||
color: var(--primary-text);
|
color: var(--primary-text);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
.scoring-profile-complete-bands {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.scoring-profile-complete-label {
|
||||||
|
font-size: .8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.scoring-profile-complete-chips {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.scoring-process-complete-panel {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: linear-gradient(180deg, var(--surface) 0%, var(--surface-muted) 100%);
|
||||||
|
box-shadow: var(--shadow-inset);
|
||||||
|
}
|
||||||
|
.scoring-process-complete-hint {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: .875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.scoring-coach-band-panel .scoring-coach-band-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.scoring-coach-band-panel .scoring-profile-select,
|
||||||
|
.scoring-coach-band-panel .condition-qn-select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.scoring-band-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
.scoring-band-picker-item {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.scoring-band-picker-item input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.scoring-band-picker-item .band-badge {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: .45;
|
||||||
|
filter: grayscale(.25);
|
||||||
|
transition: opacity .15s ease, transform .15s ease, box-shadow .15s ease, filter .15s ease;
|
||||||
|
}
|
||||||
|
.scoring-band-picker-item:hover .band-badge {
|
||||||
|
opacity: .72;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
.scoring-band-picker-item input:checked + .band-badge {
|
||||||
|
opacity: 1;
|
||||||
|
filter: none;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--primary);
|
||||||
|
}
|
||||||
|
.scoring-band-picker-item input:focus-visible + .band-badge {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.scoring-band-picker-item input:disabled + .band-badge {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: .35;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.scoring-coach-band-panel .scoring-coach-band-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
.scoring-profile-member-count {
|
.scoring-profile-member-count {
|
||||||
margin: 12px 0 8px;
|
margin: 12px 0 8px;
|
||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
|
|||||||
@ -6,10 +6,17 @@ const CONDITION_MODES = [
|
|||||||
{ value: 'always', label: 'Always available' },
|
{ value: 'always', label: 'Always available' },
|
||||||
{ value: 'requires', label: 'Requires other questionnaires completed' },
|
{ value: 'requires', label: 'Requires other questionnaires completed' },
|
||||||
{ value: 'requires_answer', label: 'Requires completions + answer check' },
|
{ value: 'requires_answer', label: 'Requires completions + answer check' },
|
||||||
|
{ value: 'requires_coach_band', label: 'Requires coach category (band)' },
|
||||||
{ value: 'any_of', label: 'Any of these rules (OR)' },
|
{ value: 'any_of', label: 'Any of these rules (OR)' },
|
||||||
{ value: 'custom', label: 'Custom JSON (advanced)' },
|
{ value: 'custom', label: 'Custom JSON (advanced)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SCORING_BANDS = [
|
||||||
|
{ value: 'green', label: 'Green' },
|
||||||
|
{ value: 'yellow', label: 'Yellow' },
|
||||||
|
{ value: 'red', label: 'Red' },
|
||||||
|
];
|
||||||
|
|
||||||
const CHOICE_LAYOUT_TYPES = new Set([
|
const CHOICE_LAYOUT_TYPES = new Set([
|
||||||
'radio_question',
|
'radio_question',
|
||||||
'multi_check_box_question',
|
'multi_check_box_question',
|
||||||
@ -156,6 +163,21 @@ export function parseConditionForm(conditionJson, questionnaireId) {
|
|||||||
if (obj.alwaysAvailable === true) {
|
if (obj.alwaysAvailable === true) {
|
||||||
return { mode: 'always', messageKey: '', germanLabel: '', customJson: '' };
|
return { mode: 'always', messageKey: '', germanLabel: '', customJson: '' };
|
||||||
}
|
}
|
||||||
|
if (obj.requiresCoachBand && typeof obj.requiresCoachBand === 'object') {
|
||||||
|
const band = obj.requiresCoachBand;
|
||||||
|
const allowed = Array.isArray(band.allowed)
|
||||||
|
? band.allowed.filter(Boolean).map(v => String(v).toLowerCase())
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
mode: 'requires_coach_band',
|
||||||
|
requires: asRequiresList(obj),
|
||||||
|
coachBandProfileID: (band.profileID || '').trim(),
|
||||||
|
coachBandAllowed: allowed,
|
||||||
|
messageKey,
|
||||||
|
germanLabel: '',
|
||||||
|
customJson: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
if (Array.isArray(obj.anyOf) && obj.anyOf.length) {
|
if (Array.isArray(obj.anyOf) && obj.anyOf.length) {
|
||||||
return {
|
return {
|
||||||
mode: 'any_of',
|
mode: 'any_of',
|
||||||
@ -219,6 +241,20 @@ export function buildConditionJson(form, selfQuestionnaireId = '') {
|
|||||||
throw new Error('Add at least one rule for "Any of these rules"');
|
throw new Error('Add at least one rule for "Any of these rules"');
|
||||||
}
|
}
|
||||||
obj = { anyOf: rules };
|
obj = { anyOf: rules };
|
||||||
|
} else if (form.mode === 'requires_coach_band') {
|
||||||
|
obj = {};
|
||||||
|
if (form.requires?.length) {
|
||||||
|
obj.requiresCompleted = [...form.requires];
|
||||||
|
}
|
||||||
|
const profileID = (form.coachBandProfileID || '').trim();
|
||||||
|
const allowed = (form.coachBandAllowed || []).filter(Boolean);
|
||||||
|
if (!profileID) {
|
||||||
|
throw new Error('Select a scoring profile for the coach category rule');
|
||||||
|
}
|
||||||
|
if (!allowed.length) {
|
||||||
|
throw new Error('Select at least one allowed coach category (band)');
|
||||||
|
}
|
||||||
|
obj.requiresCoachBand = { profileID, allowed: [...allowed] };
|
||||||
} else {
|
} else {
|
||||||
obj = buildRule({
|
obj = buildRule({
|
||||||
type: form.mode,
|
type: form.mode,
|
||||||
@ -251,6 +287,61 @@ export function buildConditionJson(form, selfQuestionnaireId = '') {
|
|||||||
return JSON.stringify(obj);
|
return JSON.stringify(obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bandCheckboxesHTML(selected, editable) {
|
||||||
|
const dis = editable ? '' : ' disabled';
|
||||||
|
const sel = new Set((selected || []).map(b => String(b).toLowerCase()));
|
||||||
|
return `<div class="scoring-band-picker" id="condCoachBands">
|
||||||
|
${SCORING_BANDS.map(b => {
|
||||||
|
const checked = sel.has(b.value) ? ' checked' : '';
|
||||||
|
return `<label class="scoring-band-picker-item">
|
||||||
|
<input type="checkbox" value="${b.value}"${checked}${dis}>
|
||||||
|
<span class="band-badge band-${b.value}">${esc(b.label)}</span>
|
||||||
|
</label>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoringProfileSelectHTML(selectedId, scoringProfiles, editable) {
|
||||||
|
const dis = editable ? '' : ' disabled';
|
||||||
|
const list = (scoringProfiles || []).filter(p => p.profileID);
|
||||||
|
if (!list.length) {
|
||||||
|
return '<p class="data-toolbar-hint">No active scoring profiles. Create one under Questionnaires → Scoring profiles.</p>';
|
||||||
|
}
|
||||||
|
const options = ['<option value="">— Select scoring profile —</option>']
|
||||||
|
.concat(list.map(p => {
|
||||||
|
const id = p.profileID;
|
||||||
|
const sel = selectedId === id ? ' selected' : '';
|
||||||
|
const label = (p.name || '').trim() || 'Unnamed profile';
|
||||||
|
return `<option value="${esc(id)}"${sel}>${esc(label)}</option>`;
|
||||||
|
}));
|
||||||
|
return `<select id="condCoachBandProfile" class="condition-qn-select scoring-profile-select"${dis}>${options.join('')}</select>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coachBandRuleHTML(form, allQuestionnaires, scoringProfiles, selfId, editable) {
|
||||||
|
return `
|
||||||
|
<div class="scoring-coach-band-panel condition-rule-block">
|
||||||
|
<label class="form-label">Optional: must also be completed first</label>
|
||||||
|
${requiresCheckboxes('condRequires', form.requires || [], allQuestionnaires, selfId, editable)}
|
||||||
|
<div class="scoring-coach-band-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Scoring profile</label>
|
||||||
|
${scoringProfileSelectHTML(form.coachBandProfileID || '', scoringProfiles, editable)}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Allowed coach categories</label>
|
||||||
|
${bandCheckboxesHTML(form.coachBandAllowed || [], editable)}
|
||||||
|
<p class="field-hint">Unlocks only after the coach sets one of these categories during score review.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCoachBandAllowed() {
|
||||||
|
const root = document.getElementById('condCoachBands');
|
||||||
|
if (!root) return [];
|
||||||
|
return [...root.querySelectorAll('input[type=checkbox]:checked')].map(cb => cb.value);
|
||||||
|
}
|
||||||
|
|
||||||
function requiresCheckboxes(id, selected, allQuestionnaires, selfId, editable) {
|
function requiresCheckboxes(id, selected, allQuestionnaires, selfId, editable) {
|
||||||
const list = (allQuestionnaires || []).filter(q => q.questionnaireID !== selfId);
|
const list = (allQuestionnaires || []).filter(q => q.questionnaireID !== selfId);
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
@ -377,6 +468,7 @@ export function renderConditionEditorHTML(form, {
|
|||||||
questionnaireId,
|
questionnaireId,
|
||||||
allQuestionnaires,
|
allQuestionnaires,
|
||||||
questionsByQuestionnaire,
|
questionsByQuestionnaire,
|
||||||
|
scoringProfiles = [],
|
||||||
germanLabel,
|
germanLabel,
|
||||||
editable,
|
editable,
|
||||||
}) {
|
}) {
|
||||||
@ -412,6 +504,10 @@ export function renderConditionEditorHTML(form, {
|
|||||||
}, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
|
}, allQuestionnaires, questionsByQuestionnaire, questionnaireId, editable)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="conditionPanelRequiresCoachBand" class="condition-mode-panel" style="display:${form.mode === 'requires_coach_band' ? '' : 'none'}">
|
||||||
|
${coachBandRuleHTML(form, allQuestionnaires, scoringProfiles, questionnaireId, editable)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="conditionPanelAnyOf" class="condition-mode-panel" style="display:${form.mode === 'any_of' ? '' : 'none'}">
|
<div id="conditionPanelAnyOf" class="condition-mode-panel" style="display:${form.mode === 'any_of' ? '' : 'none'}">
|
||||||
<p class="data-toolbar-hint" style="margin:0 0 10px">Unlock if any one rule matches.</p>
|
<p class="data-toolbar-hint" style="margin:0 0 10px">Unlock if any one rule matches.</p>
|
||||||
<div id="conditionAnyOfRules">
|
<div id="conditionAnyOfRules">
|
||||||
@ -502,6 +598,13 @@ export function readConditionFormFromDOM() {
|
|||||||
return form;
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === 'requires_coach_band') {
|
||||||
|
form.requires = readRequires('condRequires');
|
||||||
|
form.coachBandProfileID = document.getElementById('condCoachBandProfile')?.value?.trim() || '';
|
||||||
|
form.coachBandAllowed = readCoachBandAllowed();
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === 'any_of') {
|
if (mode === 'any_of') {
|
||||||
form.rules = [];
|
form.rules = [];
|
||||||
document.querySelectorAll('#conditionAnyOfRules .condition-anyof-rule').forEach((el, i) => {
|
document.querySelectorAll('#conditionAnyOfRules .condition-anyof-rule').forEach((el, i) => {
|
||||||
@ -572,6 +675,7 @@ export function mountConditionEditor(container, {
|
|||||||
questionnaireId,
|
questionnaireId,
|
||||||
allQuestionnaires,
|
allQuestionnaires,
|
||||||
questionsByQuestionnaire = {},
|
questionsByQuestionnaire = {},
|
||||||
|
scoringProfiles = [],
|
||||||
conditionJson,
|
conditionJson,
|
||||||
germanLabel,
|
germanLabel,
|
||||||
editable,
|
editable,
|
||||||
@ -586,6 +690,7 @@ export function mountConditionEditor(container, {
|
|||||||
questionnaireId,
|
questionnaireId,
|
||||||
allQuestionnaires,
|
allQuestionnaires,
|
||||||
questionsByQuestionnaire,
|
questionsByQuestionnaire,
|
||||||
|
scoringProfiles,
|
||||||
germanLabel: label,
|
germanLabel: label,
|
||||||
editable,
|
editable,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -938,6 +938,13 @@ async function initConditionEditor(condJson) {
|
|||||||
if (!mount) return;
|
if (!mount) return;
|
||||||
mount.innerHTML = '<div class="spinner"></div>';
|
mount.innerHTML = '<div class="spinner"></div>';
|
||||||
await loadQuestionsByQuestionnaire();
|
await loadQuestionsByQuestionnaire();
|
||||||
|
let scoringProfiles = [];
|
||||||
|
try {
|
||||||
|
const data = await apiGet('scoring_profiles.php');
|
||||||
|
scoringProfiles = (data.profiles || []).filter(p => p.isActive !== 0 && p.isActive !== false);
|
||||||
|
} catch {
|
||||||
|
scoringProfiles = [];
|
||||||
|
}
|
||||||
const qid = questionnaire?.questionnaireID || 'new';
|
const qid = questionnaire?.questionnaireID || 'new';
|
||||||
const parsed = parseConditionForm(condJson, qid);
|
const parsed = parseConditionForm(condJson, qid);
|
||||||
const germanLabel = parsed.messageKey
|
const germanLabel = parsed.messageKey
|
||||||
@ -947,6 +954,7 @@ async function initConditionEditor(condJson) {
|
|||||||
questionnaireId: qid,
|
questionnaireId: qid,
|
||||||
allQuestionnaires,
|
allQuestionnaires,
|
||||||
questionsByQuestionnaire,
|
questionsByQuestionnaire,
|
||||||
|
scoringProfiles,
|
||||||
conditionJson: condJson,
|
conditionJson: condJson,
|
||||||
germanLabel,
|
germanLabel,
|
||||||
editable: canEdit(),
|
editable: canEdit(),
|
||||||
|
|||||||
@ -463,6 +463,54 @@ function bandRangesEditorHTML(bands) {
|
|||||||
<div id="spBandPreview" class="scoring-band-labels"></div>`;
|
<div id="spBandPreview" class="scoring-band-labels"></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCORING_BAND_OPTIONS = [
|
||||||
|
{ value: 'green', label: 'Green' },
|
||||||
|
{ value: 'yellow', label: 'Yellow' },
|
||||||
|
{ value: 'red', label: 'Red' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function processCompleteBandsSummaryHTML(profile) {
|
||||||
|
const bands = (profile?.processCompleteBands || [])
|
||||||
|
.map(b => String(b).toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!bands.length) return '';
|
||||||
|
const chips = bands.map(b => {
|
||||||
|
const label = SCORING_BAND_OPTIONS.find(o => o.value === b)?.label || b;
|
||||||
|
return `<span class="band-badge band-${esc(b)}">${esc(label)}</span>`;
|
||||||
|
}).join('');
|
||||||
|
return `
|
||||||
|
<div class="scoring-profile-complete-bands">
|
||||||
|
<span class="scoring-profile-complete-label">Process complete when coach sets</span>
|
||||||
|
<span class="scoring-profile-complete-chips">${chips}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processCompleteBandsEditorHTML(selected, editable) {
|
||||||
|
const dis = editable ? '' : ' disabled';
|
||||||
|
const sel = new Set((selected || []).map(b => String(b).toLowerCase()));
|
||||||
|
return `
|
||||||
|
<div class="scoring-process-complete-panel">
|
||||||
|
<h4 class="scoring-modal-section-title">Process complete</h4>
|
||||||
|
<p class="scoring-process-complete-hint">
|
||||||
|
When the coach sets one of these categories during score review, the app shows the process-complete dialog for that client.
|
||||||
|
</p>
|
||||||
|
<div class="scoring-band-picker" id="spProcessCompleteBands">
|
||||||
|
${SCORING_BAND_OPTIONS.map(b => {
|
||||||
|
const checked = sel.has(b.value) ? ' checked' : '';
|
||||||
|
return `<label class="scoring-band-picker-item">
|
||||||
|
<input type="checkbox" value="${b.value}"${checked}${dis}>
|
||||||
|
<span class="band-badge band-${b.value}">${esc(b.label)}</span>
|
||||||
|
</label>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProcessCompleteBandsFromModal(overlay) {
|
||||||
|
return [...(overlay.querySelectorAll('#spProcessCompleteBands input[type=checkbox]:checked') || [])]
|
||||||
|
.map(cb => cb.value);
|
||||||
|
}
|
||||||
|
|
||||||
function renderScoringProfilesSection(profiles, questionnaires) {
|
function renderScoringProfilesSection(profiles, questionnaires) {
|
||||||
const panel = document.getElementById('scoringProfilesPanel');
|
const panel = document.getElementById('scoringProfilesPanel');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
@ -542,6 +590,7 @@ function scoringProfileCardHTML(profile, questionnaires) {
|
|||||||
</div>
|
</div>
|
||||||
${profile.description ? `<p class="scoring-profile-desc">${esc(profile.description)}</p>` : ''}
|
${profile.description ? `<p class="scoring-profile-desc">${esc(profile.description)}</p>` : ''}
|
||||||
<p class="scoring-profile-bands">${bandSummary(profile)}</p>
|
<p class="scoring-profile-bands">${bandSummary(profile)}</p>
|
||||||
|
${processCompleteBandsSummaryHTML(profile)}
|
||||||
</div>
|
</div>
|
||||||
${canEdit() ? `
|
${canEdit() ? `
|
||||||
<div class="scoring-profile-card-actions">
|
<div class="scoring-profile-card-actions">
|
||||||
@ -584,6 +633,7 @@ function openScoringProfileModal(profile, questionnaires, allProfiles) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
${bandRangesEditorHTML(initialBands)}
|
${bandRangesEditorHTML(initialBands)}
|
||||||
|
${processCompleteBandsEditorHTML(profile?.processCompleteBands || [], canEdit())}
|
||||||
<h4 class="scoring-modal-section-title">Questionnaires</h4>
|
<h4 class="scoring-modal-section-title">Questionnaires</h4>
|
||||||
<p class="data-toolbar-hint" style="margin:0 0 10px">Add questionnaires and set weight. List order is the display order.</p>
|
<p class="data-toolbar-hint" style="margin:0 0 10px">Add questionnaires and set weight. List order is the display order.</p>
|
||||||
<div id="spQnPicker" class="scoring-profile-picker"></div>
|
<div id="spQnPicker" class="scoring-profile-picker"></div>
|
||||||
@ -724,6 +774,7 @@ function openScoringProfileModal(profile, questionnaires, allProfiles) {
|
|||||||
description,
|
description,
|
||||||
isActive,
|
isActive,
|
||||||
...bands,
|
...bands,
|
||||||
|
processCompleteBands: readProcessCompleteBandsFromModal(overlay),
|
||||||
questionnaires: members.map((m, idx) => ({
|
questionnaires: members.map((m, idx) => ({
|
||||||
questionnaireID: m.questionnaireID,
|
questionnaireID: m.questionnaireID,
|
||||||
weight: parseFloat(picker.querySelector(`[data-qn="${m.questionnaireID}"] .sp-weight`)?.value) || m.weight || 1,
|
weight: parseFloat(picker.querySelector(`[data-qn="${m.questionnaireID}"] .sp-weight`)?.value) || m.weight || 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user