adding process completion logic
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-07-01 12:07:41 +02:00
parent 9bd9d9653c
commit 339091e13f
10 changed files with 353 additions and 10 deletions

View File

@ -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),
]); ]);

View File

@ -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",

View File

@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) {
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
} }
define('QDB_SCHEMA', __DIR__ . '/schema.sql'); define('QDB_SCHEMA', __DIR__ . '/schema.sql');
define('QDB_VERSION', 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(

View File

@ -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,
]); ]);

View File

@ -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 012, yellow 1336, red 37+)', ':desc' => 'Legacy RHS thresholds (green 012, yellow 1336, 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.
*/ */

View File

@ -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
); );

View File

@ -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;

View File

@ -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,
} }

View File

@ -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(),

View File

@ -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,