This commit is contained in:
@ -2635,3 +2635,423 @@ select:disabled {
|
||||
font-size: .85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.glass-score-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.glass-score-cell label {
|
||||
display: block;
|
||||
font-size: .7rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.glass-score-cell input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.value-score-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.value-score-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: .85rem;
|
||||
}
|
||||
.value-score-label {
|
||||
min-width: 2rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.value-score-pts {
|
||||
width: 4rem;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal-dialog-wide {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
/* Scoring profiles on questionnaire dashboard */
|
||||
#scoringProfilesPanel {
|
||||
width: 100%;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.scoring-profiles-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
.scoring-profiles-card {
|
||||
padding: 24px;
|
||||
}
|
||||
.scoring-section-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.scoring-section-desc,
|
||||
.scoring-empty-hint {
|
||||
margin: 0;
|
||||
font-size: .875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.scoring-empty-hint {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.scoring-profiles-list {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.scoring-profile-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
.scoring-profile-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.scoring-profile-card-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.scoring-profile-name-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.scoring-profile-name-row strong {
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.scoring-profile-desc {
|
||||
margin: 0 0 8px;
|
||||
font-size: .875rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.scoring-profile-bands {
|
||||
margin: 0;
|
||||
font-size: .85rem;
|
||||
color: var(--primary-text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
.scoring-profile-member-count {
|
||||
margin: 12px 0 8px;
|
||||
font-size: .8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.scoring-profile-members {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 4px;
|
||||
}
|
||||
.scoring-profile-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scoring-profile-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.scoring-modal-section-title {
|
||||
margin: 18px 0 0;
|
||||
font-size: .95rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.scoring-profile-active-row {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.checkbox-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: .9rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scoring-profile-picker {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.scoring-profile-picker input,
|
||||
.scoring-profile-picker select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: .875rem;
|
||||
font-family: var(--font);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.scoring-profile-picker input:focus,
|
||||
.scoring-profile-picker select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
.scoring-profile-add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.scoring-profile-add-row select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.scoring-band-labels {
|
||||
margin: 4px 0 8px;
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
.scoring-band-ranges-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.scoring-band-range-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.scoring-band-range-row.scoring-band-range-header {
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: var(--text-secondary);
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.scoring-band-range-row input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: .875rem;
|
||||
font-family: var(--font);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.scoring-band-range-row input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
.scoring-band-max-placeholder {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.scoring-band-strips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.scoring-profile-q-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.scoring-profile-q-row:last-child { border-bottom: none; }
|
||||
.scoring-profile-q-name {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
.scoring-profile-q-weight {
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.scoring-profile-q-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.sp-weight-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: .8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.sp-weight-wrap input {
|
||||
width: 72px;
|
||||
}
|
||||
.band-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.band-badge.band-green { background: #14532d; color: #bbf7d0; border: 1px solid #166534; }
|
||||
.band-badge.band-yellow { background: #713f12; color: #fef08a; border: 1px solid #854d0e; }
|
||||
.band-badge.band-red { background: #7f1d1d; color: #fecaca; border: 1px solid #991b1b; }
|
||||
.band-badge.band-none { background: #334155; color: #cbd5e1; border: 1px solid #475569; }
|
||||
|
||||
/* Client list — scoring profiles */
|
||||
.client-scoring-legend {
|
||||
margin: 0 0 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-elevated, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
.client-scoring-legend-title {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.client-scoring-legend-body {
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.client-scoring-legend-body .band-badge {
|
||||
margin: 0 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.client-scoring-legend-dl {
|
||||
display: grid;
|
||||
gap: 8px 16px;
|
||||
margin: 0;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.client-scoring-legend-dl {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.client-scoring-legend-dl dt {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
.client-scoring-legend-dl dd {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.client-profile-scores {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
.client-profile-score-block {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-elevated, rgba(0, 0, 0, 0.02));
|
||||
font-size: .8125rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.client-profile-score-block.is-incomplete {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.client-profile-score-block.has-coach-override {
|
||||
border-color: rgba(251, 191, 36, 0.45);
|
||||
}
|
||||
.client-profile-score-block-title {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.client-profile-score-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.client-profile-score-row:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
.client-profile-score-label {
|
||||
flex: 0 0 4.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
.client-profile-score-total {
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: .75rem;
|
||||
}
|
||||
.client-profile-score-total::before {
|
||||
content: "total ";
|
||||
opacity: 0.75;
|
||||
}
|
||||
.client-profile-override-hint {
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: #ca8a04;
|
||||
}
|
||||
.client-profile-score-hint,
|
||||
.client-profile-score-meta {
|
||||
margin: 6px 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.client-scoring-profiles-detail {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.scoring-profile-detail-card {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.client-profile-dots-cell {
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.client-profile-dots-empty {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ const PAGE_SIZE = 50;
|
||||
|
||||
let clientsList = [];
|
||||
let coachesList = [];
|
||||
let scoringProfileCatalog = [];
|
||||
let filterSearch = '';
|
||||
let filterTestData = '';
|
||||
let page = 0;
|
||||
@ -42,6 +43,7 @@ async function loadClients() {
|
||||
apiGet('assignments.php'),
|
||||
]);
|
||||
clientsList = clientsData.clients || [];
|
||||
scoringProfileCatalog = clientsData.scoringProfiles || [];
|
||||
coachesList = assignData.coaches || [];
|
||||
renderClients();
|
||||
} catch (e) {
|
||||
@ -65,6 +67,9 @@ function renderClients() {
|
||||
return;
|
||||
}
|
||||
|
||||
const showProfileColumn = scoringProfileCatalog.length > 0;
|
||||
const profileLegend = showProfileColumn ? scoringLegendHTML() : '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="filter-bar">
|
||||
@ -76,6 +81,7 @@ function renderClients() {
|
||||
</select>
|
||||
<span class="data-toolbar-hint" id="clientListCount"></span>
|
||||
</div>
|
||||
${profileLegend}
|
||||
<p class="data-toolbar-hint" style="margin:0 0 12px">
|
||||
Expand a row to view questionnaire responses and upload version history.
|
||||
</p>
|
||||
@ -85,6 +91,7 @@ function renderClients() {
|
||||
<tr>
|
||||
<th>Client Code</th>
|
||||
<th>Current Coach</th>
|
||||
${showProfileColumn ? '<th>Profile scores</th>' : ''}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -132,6 +139,7 @@ function renderClientTableBody() {
|
||||
const countEl = document.getElementById('clientListCount');
|
||||
const filtered = getFilteredClientsList();
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
|
||||
if (page >= totalPages) page = totalPages - 1;
|
||||
const slice = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
|
||||
@ -140,7 +148,7 @@ function renderClientTableBody() {
|
||||
}
|
||||
|
||||
if (!slice.length) {
|
||||
body.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match</td></tr>`;
|
||||
body.innerHTML = `<tr><td colspan="${colCount}" style="text-align:center;color:var(--text-secondary);padding:24px">No clients match</td></tr>`;
|
||||
} else {
|
||||
body.innerHTML = slice.map(c => clientRowHTML(c)).join('');
|
||||
body.querySelectorAll('.client-expand-btn').forEach(btn => {
|
||||
@ -243,9 +251,103 @@ function clientDetailPanelHTML(clientCode) {
|
||||
Coach: ${esc(cl.coachUsername || '—')}
|
||||
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
|
||||
</p>
|
||||
${renderClientScoringProfiles(detail.scoringProfiles || [])}
|
||||
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`;
|
||||
}
|
||||
|
||||
function scoringLegendHTML() {
|
||||
return `
|
||||
<div class="client-scoring-legend">
|
||||
<p class="client-scoring-legend-title"><strong>How profile scores work</strong></p>
|
||||
<p class="data-toolbar-hint client-scoring-legend-body">
|
||||
Each profile adds up questionnaire points (× weight) into a <strong>weighted total</strong>,
|
||||
then maps that total to <span class="band-badge band-green">green</span>
|
||||
<span class="band-badge band-yellow">yellow</span>
|
||||
<span class="band-badge band-red">red</span> using the thresholds on the Questionnaires page.
|
||||
</p>
|
||||
<dl class="client-scoring-legend-dl">
|
||||
<div>
|
||||
<dt>Calculated</dt>
|
||||
<dd>Automatic category from answers and profile rules (shown before coach review).</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Coach</dt>
|
||||
<dd>Category confirmed or changed by the coach in the app (“Review scores”).</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Incomplete</dt>
|
||||
<dd>Not all questionnaires in that profile are completed yet.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function bandBadgeHTML(band, { pending = false, incomplete = false, label = '' } = {}) {
|
||||
const text = label || band || '';
|
||||
if (incomplete) {
|
||||
return '<span class="band-badge band-none">incomplete</span>';
|
||||
}
|
||||
if (pending) {
|
||||
return '<span class="band-badge band-none">awaiting review</span>';
|
||||
}
|
||||
if (!band) {
|
||||
return '<span class="band-badge band-none">—</span>';
|
||||
}
|
||||
return `<span class="band-badge band-${esc(band)}">${esc(text || band)}</span>`;
|
||||
}
|
||||
|
||||
function profileScoreBlockHTML(p) {
|
||||
const calcBand = p.calculatedBand || p.band;
|
||||
if (!calcBand) {
|
||||
return `
|
||||
<div class="client-profile-score-block is-incomplete">
|
||||
<div class="client-profile-score-block-title">${esc(p.name)}</div>
|
||||
<p class="client-profile-score-hint">Profile incomplete — not all member questionnaires are done.</p>
|
||||
</div>`;
|
||||
}
|
||||
const coachPending = p.pendingReview !== false && !p.coachBand;
|
||||
const coachDiffers = p.coachBand && p.coachBand !== calcBand;
|
||||
return `
|
||||
<div class="client-profile-score-block${coachDiffers ? ' has-coach-override' : ''}">
|
||||
<div class="client-profile-score-block-title">${esc(p.name)}</div>
|
||||
<div class="client-profile-score-row">
|
||||
<span class="client-profile-score-label">Calculated</span>
|
||||
${bandBadgeHTML(calcBand)}
|
||||
<span class="client-profile-score-total" title="Weighted total">${esc(String(p.weightedTotal))}</span>
|
||||
</div>
|
||||
<div class="client-profile-score-row">
|
||||
<span class="client-profile-score-label">Coach</span>
|
||||
${coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand)}
|
||||
${coachDiffers ? '<span class="client-profile-override-hint">override</span>' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderClientScoringProfiles(profiles) {
|
||||
if (!profiles.length) return '';
|
||||
return `
|
||||
<div class="client-scoring-profiles-detail">
|
||||
<strong>Scoring profiles</strong>
|
||||
<p class="data-toolbar-hint" style="margin:4px 0 10px">
|
||||
Weighted totals and calculated vs coach categories for this client.
|
||||
</p>
|
||||
<div class="client-profile-scores">${profiles.map(p => {
|
||||
const calc = p.calculatedBand || p.band;
|
||||
if (!calc) return profileScoreBlockHTML(p);
|
||||
const coachPending = p.pendingReview !== false && !p.coachBand;
|
||||
const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : '';
|
||||
const reviewedAt = p.coachReviewedAt ? `Coach reviewed ${esc(p.coachReviewedAt)}` : '';
|
||||
const meta = [computedAt, reviewedAt].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<div class="scoring-profile-detail-card">
|
||||
${profileScoreBlockHTML(p)}
|
||||
${meta ? `<p class="client-profile-score-meta">${meta}</p>` : ''}
|
||||
${coachPending ? '<p class="client-profile-score-hint">Coach has not reviewed this profile in the app yet.</p>' : ''}
|
||||
</div>`;
|
||||
}).join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function clientQnBlockHTML(clientCode, q) {
|
||||
const key = qnKey(clientCode, q.questionnaireID);
|
||||
const qnExpanded = expandedQnKey === key;
|
||||
@ -311,6 +413,17 @@ function clientQnBlockHTML(clientCode, q) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function profileDotsHTML(profiles) {
|
||||
if (!profiles?.length) {
|
||||
return '<span class="client-profile-dots-empty">—</span>';
|
||||
}
|
||||
const hasAny = profiles.some(p => p.calculatedBand || p.band);
|
||||
if (!hasAny) {
|
||||
return `<span class="client-profile-dots-empty" title="No complete scoring profiles yet">—</span>`;
|
||||
}
|
||||
return `<div class="client-profile-scores">${profiles.map(p => profileScoreBlockHTML(p)).join('')}</div>`;
|
||||
}
|
||||
|
||||
function clientHasResponseData(c) {
|
||||
return Number(c.hasResponseData) === 1;
|
||||
}
|
||||
@ -324,6 +437,10 @@ function clientRowHTML(c) {
|
||||
const expandControl = canExpand
|
||||
? `<button type="button" class="btn btn-sm btn-link client-expand-btn" data-code="${esc(c.clientCode)}">${expanded ? '▼' : '▶'}</button> `
|
||||
: '';
|
||||
const profileCol = scoringProfileCatalog.length > 0
|
||||
? `<td class="client-profile-dots-cell">${profileDotsHTML(c.scoringProfiles || [])}</td>`
|
||||
: '';
|
||||
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
|
||||
|
||||
return `
|
||||
<tr class="${testDataRowClassForClient(c.clientCode).trim()}">
|
||||
@ -331,13 +448,14 @@ function clientRowHTML(c) {
|
||||
${expandControl}<strong>${esc(c.clientCode)}</strong>
|
||||
</td>
|
||||
<td>${coach}</td>
|
||||
${profileCol}
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger delete-client-btn" data-code="${esc(c.clientCode)}">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
${expanded ? `
|
||||
<tr class="client-detail-row">
|
||||
<td colspan="3">
|
||||
<td colspan="${colCount}">
|
||||
<div class="client-detail-panel">${clientDetailPanelHTML(c.clientCode)}</div>
|
||||
</td>
|
||||
</tr>` : ''}`;
|
||||
|
||||
@ -33,6 +33,14 @@ const LAYOUT_TYPES = [
|
||||
|
||||
const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
|
||||
|
||||
const GLASS_SCORE_LEVELS = [
|
||||
{ key: 'never_glass', label: 'Never' },
|
||||
{ key: 'little_glass', label: 'Little' },
|
||||
{ key: 'moderate_glass', label: 'Moderate' },
|
||||
{ key: 'much_glass', label: 'Much' },
|
||||
{ key: 'extreme_glass', label: 'Extreme' },
|
||||
];
|
||||
|
||||
let questionnaire = null;
|
||||
let questions = [];
|
||||
let isNew = false;
|
||||
@ -194,10 +202,79 @@ function glassSymptomsRowsFromQuestion(q) {
|
||||
return q.glassSymptoms.map(r => ({
|
||||
key: (r.key || '').trim(),
|
||||
labelDe: (r.labelDe || '').trim(),
|
||||
scoreLevels: r.scoreLevels || defaultGlassScoreLevels(),
|
||||
}));
|
||||
}
|
||||
const config = parseConfig(q);
|
||||
return (config.symptoms || []).map(key => ({ key: String(key).trim(), labelDe: '' }));
|
||||
return (config.symptoms || []).map(key => ({
|
||||
key: String(key).trim(),
|
||||
labelDe: '',
|
||||
scoreLevels: defaultGlassScoreLevels(),
|
||||
}));
|
||||
}
|
||||
|
||||
function defaultGlassScoreLevels() {
|
||||
return { never_glass: 0, little_glass: 1, moderate_glass: 2, much_glass: 3, extreme_glass: 4 };
|
||||
}
|
||||
|
||||
function valueScoreRulesSectionHTML(prefix, config, scoreRules = []) {
|
||||
const min = Number(config.range?.min ?? 0);
|
||||
const max = Number(config.range?.max ?? 100);
|
||||
const step = Math.max(1, Number(config.step ?? 1));
|
||||
const lo = Math.min(min, max);
|
||||
const hi = Math.max(min, max);
|
||||
const ruleMap = {};
|
||||
for (const r of scoreRules || []) {
|
||||
ruleMap[r.levelKey] = r.points;
|
||||
}
|
||||
const values = [];
|
||||
for (let v = lo; v <= hi; v += step) values.push(v);
|
||||
if (!values.length) values.push(lo);
|
||||
const rows = values.map(v => `
|
||||
<div class="value-score-row">
|
||||
<span class="value-score-label">${v}</span>
|
||||
<input type="number" class="value-score-pts" data-value="${v}" value="${ruleMap[String(v)] ?? 0}" min="0" step="1">
|
||||
</div>`).join('');
|
||||
return `
|
||||
<div class="value-score-rules" id="${prefix}_value_scores">
|
||||
<label style="font-size:.85rem;font-weight:600;color:var(--text-secondary)">Points per value</label>
|
||||
<p class="field-hint">Set points for each selectable value (used when the questionnaire is scored).</p>
|
||||
<div class="value-score-grid">${rows}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function readValueScoreRulesFromForm(prefix) {
|
||||
const container = document.getElementById(`${prefix}_value_scores`);
|
||||
if (!container) return [];
|
||||
const rules = [];
|
||||
container.querySelectorAll('.value-score-pts').forEach(inp => {
|
||||
rules.push({
|
||||
scopeKey: '',
|
||||
levelKey: String(inp.dataset.value),
|
||||
points: parseInt(inp.value || '0', 10) || 0,
|
||||
});
|
||||
});
|
||||
return rules;
|
||||
}
|
||||
|
||||
function wireValueScoreRules(prefix) {
|
||||
const minInp = document.getElementById(`${prefix}_rangeMin`);
|
||||
const maxInp = document.getElementById(`${prefix}_rangeMax`);
|
||||
const stepInp = document.getElementById(`${prefix}_step`);
|
||||
const rerender = () => {
|
||||
const section = document.getElementById(`${prefix}_value_scores`);
|
||||
if (!section) return;
|
||||
const config = {
|
||||
range: {
|
||||
min: parseInt(minInp?.value || '0', 10),
|
||||
max: parseInt(maxInp?.value || '100', 10),
|
||||
},
|
||||
step: parseInt(stepInp?.value || '1', 10) || 1,
|
||||
};
|
||||
const existing = readValueScoreRulesFromForm(prefix);
|
||||
section.outerHTML = valueScoreRulesSectionHTML(prefix, config, existing);
|
||||
};
|
||||
[minInp, maxInp, stepInp].forEach(el => el?.addEventListener('change', rerender));
|
||||
}
|
||||
|
||||
function suggestSymptomKey(label) {
|
||||
@ -227,6 +304,13 @@ function glassSymptomsSectionHTML(rows, prefix) {
|
||||
}
|
||||
|
||||
function glassSymptomRowHTML(prefix, row, idx) {
|
||||
const levels = row.scoreLevels || defaultGlassScoreLevels();
|
||||
const ptsRow = GLASS_SCORE_LEVELS.map(l => `
|
||||
<div class="glass-score-cell">
|
||||
<label>${esc(l.label)}</label>
|
||||
<input type="number" class="glass-score-pts" data-level="${esc(l.key)}"
|
||||
value="${Number(levels[l.key] ?? 0)}" min="0" step="1">
|
||||
</div>`).join('');
|
||||
return `
|
||||
<li class="glass-symptom-row" data-idx="${idx}">
|
||||
<div class="form-row" style="align-items:flex-end;margin-bottom:0">
|
||||
@ -242,6 +326,7 @@ function glassSymptomRowHTML(prefix, row, idx) {
|
||||
</div>
|
||||
<button type="button" class="btn-icon btn-icon-danger glass-symptom-remove" title="Remove">✖</button>
|
||||
</div>
|
||||
<div class="glass-score-grid">${ptsRow}</div>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
@ -252,7 +337,11 @@ function readGlassSymptomsFromForm(prefix) {
|
||||
container.querySelectorAll('.glass-symptom-row').forEach(rowEl => {
|
||||
const key = rowEl.querySelector('.glass-symptom-key')?.value?.trim() || '';
|
||||
const labelDe = rowEl.querySelector('.glass-symptom-label')?.value?.trim() || '';
|
||||
if (key || labelDe) rows.push({ key, labelDe });
|
||||
const scoreLevels = {};
|
||||
rowEl.querySelectorAll('.glass-score-pts').forEach(inp => {
|
||||
scoreLevels[inp.dataset.level] = parseInt(inp.value || '0', 10) || 0;
|
||||
});
|
||||
if (key || labelDe) rows.push({ key, labelDe, scoreLevels });
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
@ -319,7 +408,7 @@ function wireGlassSymptomsSection(prefix) {
|
||||
|
||||
container.querySelector('[data-action=add-glass-symptom]')?.addEventListener('click', () => {
|
||||
const rows = collectRows();
|
||||
rows.push({ key: '', labelDe: '' });
|
||||
rows.push({ key: '', labelDe: '', scoreLevels: defaultGlassScoreLevels() });
|
||||
renderList(rows);
|
||||
const lastKey = container.querySelector('.glass-symptom-row:last-child .glass-symptom-label');
|
||||
lastKey?.focus();
|
||||
@ -477,7 +566,8 @@ function configFormHTML(layout, config, prefix, opts = {}) {
|
||||
break;
|
||||
}
|
||||
case 'value_spinner':
|
||||
case 'slider_question':
|
||||
case 'slider_question': {
|
||||
const scoreRules = opts.scoreRules || [];
|
||||
html = `
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
@ -497,8 +587,10 @@ function configFormHTML(layout, config, prefix, opts = {}) {
|
||||
<label>Unit label (optional)</label>
|
||||
<input type="text" id="${prefix}_unitLabel" value="${esc(config.unitLabel || '')}" placeholder="e.g. years">
|
||||
</div>` : ''}
|
||||
</div>`;
|
||||
</div>
|
||||
${valueScoreRulesSectionHTML(prefix, config, scoreRules)}`;
|
||||
break;
|
||||
}
|
||||
case 'date_spinner': {
|
||||
const notBefore = config.constraints?.notBefore || '';
|
||||
const notAfter = config.constraints?.notAfter || '';
|
||||
@ -694,11 +786,6 @@ function renderEditor() {
|
||||
<label>Order index</label>
|
||||
<input type="number" id="metaOrder" value="${questionnaire.orderIndex ?? 0}" min="0" ${editable ? '' : 'disabled'}>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:flex-end;padding-bottom:16px">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="metaShowPoints" ${questionnaire.showPoints ? 'checked' : ''} ${editable ? '' : 'disabled'}> Show points
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Category key (app grouping)</label>
|
||||
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
|
||||
@ -838,7 +925,7 @@ async function saveMeta() {
|
||||
const version = document.getElementById('metaVersion').value.trim();
|
||||
const state = document.getElementById('metaState').value;
|
||||
const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10);
|
||||
const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0;
|
||||
const showPoints = 0;
|
||||
const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim();
|
||||
let conditionJson = '{}';
|
||||
let conditionForm = null;
|
||||
@ -971,6 +1058,7 @@ function showAddQuestionForm() {
|
||||
document.getElementById('aq_config_section').innerHTML = configFormHTML(type, {}, 'aq_cfg');
|
||||
wireStringSpinnerOtherToggle('aq_cfg');
|
||||
if (type === 'glass_scale_question') wireGlassSymptomsSection('aq_cfg');
|
||||
if (type === 'slider_question' || type === 'value_spinner') wireValueScoreRules('aq_cfg');
|
||||
const notesEl = document.getElementById('aq_notes_section');
|
||||
if (notesEl) notesEl.innerHTML = notesSectionHTML({}, 'aq_cfg', qKey);
|
||||
}
|
||||
@ -1060,6 +1148,9 @@ function showAddQuestionForm() {
|
||||
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
|
||||
postBody.glassSymptoms = glassSymptoms;
|
||||
}
|
||||
if (type === 'slider_question' || type === 'value_spinner') {
|
||||
postBody.scoreRules = readValueScoreRulesFromForm('aq_cfg');
|
||||
}
|
||||
if (!validateStableKey(questionKey, 'Question key')) return;
|
||||
if (!text) { showToast('German text is required', 'error'); return; }
|
||||
if (type === 'string_spinner' && !validateStringSpinnerConfig('aq_cfg')) return;
|
||||
@ -1203,7 +1294,10 @@ function renderQuestionBody(q) {
|
||||
</label>
|
||||
${notesSectionHTML(config, `eq_cfg_${q.questionID}`, qKey)}
|
||||
<div id="eq_config_${q.questionID}">
|
||||
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, { glassSymptoms: glassSymptomsRowsFromQuestion(q) })}
|
||||
${configFormHTML(layout, config, `eq_cfg_${q.questionID}`, {
|
||||
glassSymptoms: glassSymptomsRowsFromQuestion(q),
|
||||
scoreRules: q.scoreRules || [],
|
||||
})}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button class="btn btn-primary btn-sm save-q-btn">Save Question</button>
|
||||
@ -1271,6 +1365,9 @@ function renderQuestionBody(q) {
|
||||
if (glassSymptoms.length && !validateGlassSymptoms(glassSymptoms)) return;
|
||||
payload.glassSymptoms = glassSymptoms;
|
||||
}
|
||||
if (type === 'slider_question' || type === 'value_spinner') {
|
||||
payload.scoreRules = readValueScoreRulesFromForm(cfgPrefix);
|
||||
}
|
||||
if (!validateStableKey(questionKey, 'Question key')) return;
|
||||
if (!text) { showToast('German text is required', 'error'); return; }
|
||||
if (type === 'string_spinner' && !validateStringSpinnerConfig(cfgPrefix)) return;
|
||||
@ -1291,15 +1388,19 @@ function renderQuestionBody(q) {
|
||||
const newLayout = document.getElementById(`eq_type_${q.questionID}`).value;
|
||||
const opts = newLayout === 'glass_scale_question'
|
||||
? { glassSymptoms: glassSymptomsRowsFromQuestion(q) }
|
||||
: {};
|
||||
: (newLayout === 'slider_question' || newLayout === 'value_spinner')
|
||||
? { scoreRules: q.scoreRules || [] }
|
||||
: {};
|
||||
document.getElementById(`eq_config_${q.questionID}`).innerHTML =
|
||||
configFormHTML(newLayout, {}, cfgPrefix, opts);
|
||||
wireStringSpinnerOtherToggle(cfgPrefix);
|
||||
if (newLayout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
|
||||
if (newLayout === 'slider_question' || newLayout === 'value_spinner') wireValueScoreRules(cfgPrefix);
|
||||
});
|
||||
|
||||
wireStringSpinnerOtherToggle(cfgPrefix);
|
||||
if (layout === 'glass_scale_question') wireGlassSymptomsSection(cfgPrefix);
|
||||
if (layout === 'slider_question' || layout === 'value_spinner') wireValueScoreRules(cfgPrefix);
|
||||
|
||||
body.querySelector('.del-q-btn').addEventListener('click', () => deleteQuestion(q));
|
||||
|
||||
|
||||
@ -48,6 +48,12 @@ function renderHomeDashboard(ov, isAdmin) {
|
||||
links.push({ href: '#/dev', label: 'Admin settings', desc: 'Dev import, exports, database tools' });
|
||||
}
|
||||
|
||||
const scoringProfiles = ov.scoringProfiles || [];
|
||||
const primaryProfile = scoringProfiles[0];
|
||||
const greenPct = primaryProfile && primaryProfile.clientCount
|
||||
? Math.round(((primaryProfile.bands?.green ?? 0) / primaryProfile.clientCount) * 100)
|
||||
: null;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="insights-kpi-grid">
|
||||
<a href="#/clients" class="insights-kpi card home-kpi-link">
|
||||
@ -66,6 +72,16 @@ function renderHomeDashboard(ov, isAdmin) {
|
||||
<div class="insights-kpi-value">${ov.submissionsLast30d ?? 0}</div>
|
||||
<div class="insights-kpi-label">Uploads (30 days)</div>
|
||||
</a>
|
||||
${primaryProfile ? `
|
||||
<a href="#/insights" class="insights-kpi card home-kpi-link">
|
||||
<div class="insights-kpi-value">${primaryProfile.clientCount ?? 0}</div>
|
||||
<div class="insights-kpi-label">${esc(primaryProfile.name)} scored</div>
|
||||
</a>` : ''}
|
||||
${greenPct != null ? `
|
||||
<a href="#/insights" class="insights-kpi card home-kpi-link">
|
||||
<div class="insights-kpi-value">${greenPct}%</div>
|
||||
<div class="insights-kpi-label">Green (${esc(primaryProfile.name)})</div>
|
||||
</a>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:20px">
|
||||
|
||||
@ -81,6 +81,35 @@ function renderInsights() {
|
||||
{ suffix: '%', max: 100 }
|
||||
);
|
||||
|
||||
const scoringProfiles = ov.scoringProfiles || [];
|
||||
const scoringCharts = scoringProfiles.map(sp => {
|
||||
const computed = sp.computedBands || sp.bands || {};
|
||||
const coach = sp.coachBands || {};
|
||||
const computedChart = horizontalBarChart([
|
||||
{ label: 'Green', value: computed.green ?? 0 },
|
||||
{ label: 'Yellow', value: computed.yellow ?? 0 },
|
||||
{ label: 'Red', value: computed.red ?? 0 },
|
||||
], { suffix: '' });
|
||||
const coachChart = horizontalBarChart([
|
||||
{ label: 'Green', value: coach.green ?? 0 },
|
||||
{ label: 'Yellow', value: coach.yellow ?? 0 },
|
||||
{ label: 'Red', value: coach.red ?? 0 },
|
||||
], { suffix: '' });
|
||||
return `
|
||||
<div class="card insights-chart-card">
|
||||
<h3>${esc(sp.name)}</h3>
|
||||
<p class="insights-chart-hint">
|
||||
${sp.clientCount ?? 0} client(s) with complete profile
|
||||
${sp.averageTotal != null ? ` · avg ${sp.averageTotal}` : ''}
|
||||
${sp.pendingReview ? ` · ${sp.pendingReview} pending coach review` : ''}
|
||||
</p>
|
||||
<p class="insights-chart-hint" style="margin:0 0 6px"><strong>Calculated bands</strong></p>
|
||||
${computedChart}
|
||||
<p class="insights-chart-hint" style="margin:12px 0 6px"><strong>Coach decisions</strong></p>
|
||||
${coachChart}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const qRows = (ov.questionnaires || []).map(q => `
|
||||
<tr>
|
||||
<td><strong>${esc(q.name)}</strong></td>
|
||||
@ -108,6 +137,12 @@ function renderInsights() {
|
||||
${idleChart}
|
||||
</div>
|
||||
</div>
|
||||
${scoringProfiles.length ? `
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h3 style="margin:0 0 8px">Scoring profiles</h3>
|
||||
<p class="insights-chart-hint" style="margin:0 0 12px">Calculated vs coach band distribution among clients with a complete weighted score</p>
|
||||
<div class="insights-charts-grid">${scoringCharts}</div>
|
||||
</div>` : ''}
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h3 style="margin:0 0 8px">Completion by questionnaire</h3>
|
||||
<p class="insights-chart-hint" style="margin:0 0 12px">Share of clients who completed each active questionnaire</p>
|
||||
|
||||
@ -7,6 +7,7 @@ export async function questionnairesPage() {
|
||||
app.innerHTML = `
|
||||
${pageHeaderHTML('Questionnaires', '<span id="headerActions"></span>')}
|
||||
<div id="qGrid" class="q-dashboard-root"><div class="spinner"></div></div>
|
||||
<div id="scoringProfilesPanel"></div>
|
||||
<div id="categoryKeysPanel"></div>
|
||||
`;
|
||||
|
||||
@ -20,9 +21,13 @@ export async function questionnairesPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiGet('questionnaires.php');
|
||||
const [data, profilesData] = await Promise.all([
|
||||
apiGet('questionnaires.php'),
|
||||
apiGet('scoring_profiles.php').catch(() => ({ profiles: [] })),
|
||||
]);
|
||||
const categoryKeys = data.categoryKeys || [];
|
||||
renderGrid(data.questionnaires || [], categoryKeys);
|
||||
renderScoringProfilesSection(profilesData.profiles || [], data.questionnaires || []);
|
||||
renderCategoryKeysPanel(categoryKeys);
|
||||
bindCategoryLabelEditors();
|
||||
} catch (e) {
|
||||
@ -232,7 +237,6 @@ function groupQuestionnairesByCategory(questionnaires, categoryKeys) {
|
||||
|
||||
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-header">
|
||||
@ -246,7 +250,6 @@ function renderQuestionnaireCard(q) {
|
||||
<span>v${esc(q.version || '—')}</span>
|
||||
<span>${q.questionCount || 0} question${q.questionCount == 1 ? '' : 's'}</span>
|
||||
<span>#${q.orderIndex ?? '—'}</span>
|
||||
${showPts ? '<span class="badge badge-active" style="font-size:.7rem;padding:1px 6px">pts</span>' : ''}
|
||||
</div>
|
||||
<div class="q-card-actions">
|
||||
<a href="#/questionnaire/${q.questionnaireID}" class="btn btn-sm">Edit</a>
|
||||
@ -377,6 +380,360 @@ function initGridDrag(questionnaires) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeProfileBands(profile) {
|
||||
const greenMax = profile?.greenMax ?? 12;
|
||||
const yellowMax = profile?.yellowMax ?? 36;
|
||||
return {
|
||||
greenMin: profile?.greenMin ?? 0,
|
||||
greenMax,
|
||||
yellowMin: profile?.yellowMin ?? (greenMax + 1),
|
||||
yellowMax,
|
||||
redMin: profile?.redMin ?? (yellowMax + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function bandSummary(profile) {
|
||||
const b = normalizeProfileBands(profile || {});
|
||||
return `Green ${b.greenMin}–${b.greenMax} · Yellow ${b.yellowMin}–${b.yellowMax} · Red ${b.redMin}+`;
|
||||
}
|
||||
|
||||
function readBandsFromModal(overlay) {
|
||||
return {
|
||||
greenMin: parseInt(overlay.querySelector('#spGreenMin')?.value || '0', 10),
|
||||
greenMax: parseInt(overlay.querySelector('#spGreenMax')?.value || '0', 10),
|
||||
yellowMin: parseInt(overlay.querySelector('#spYellowMin')?.value || '0', 10),
|
||||
yellowMax: parseInt(overlay.querySelector('#spYellowMax')?.value || '0', 10),
|
||||
redMin: parseInt(overlay.querySelector('#spRedMin')?.value || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
function validateBands(b) {
|
||||
if (b.greenMin > b.greenMax) return 'Green min must not exceed green max';
|
||||
if (b.yellowMin > b.yellowMax) return 'Yellow min must not exceed yellow max';
|
||||
if (b.greenMax >= b.yellowMin) return 'Green range must end before yellow range starts';
|
||||
if (b.yellowMax >= b.redMin) return 'Yellow range must end before red range starts';
|
||||
return null;
|
||||
}
|
||||
|
||||
function bandPreviewHTML(b) {
|
||||
return `
|
||||
<div class="scoring-band-strips">
|
||||
<span class="band-badge band-green">Green: ${b.greenMin} – ${b.greenMax}</span>
|
||||
<span class="band-badge band-yellow">Yellow: ${b.yellowMin} – ${b.yellowMax}</span>
|
||||
<span class="band-badge band-red">Red: ${b.redMin}+</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function bandRangesEditorHTML(bands) {
|
||||
const b = normalizeProfileBands(bands);
|
||||
return `
|
||||
<h4 class="scoring-modal-section-title">Score bands</h4>
|
||||
<p class="data-toolbar-hint" style="margin:0 0 10px">Set inclusive min/max for each band. Ranges must not overlap.</p>
|
||||
<div class="scoring-band-ranges-table">
|
||||
<div class="scoring-band-range-row scoring-band-range-header">
|
||||
<span>Band</span><span>Min</span><span>Max</span>
|
||||
</div>
|
||||
<div class="scoring-band-range-row">
|
||||
<span class="band-badge band-green">Green</span>
|
||||
<input type="number" id="spGreenMin" min="0" value="${b.greenMin}">
|
||||
<input type="number" id="spGreenMax" min="0" value="${b.greenMax}">
|
||||
</div>
|
||||
<div class="scoring-band-range-row">
|
||||
<span class="band-badge band-yellow">Yellow</span>
|
||||
<input type="number" id="spYellowMin" min="0" value="${b.yellowMin}">
|
||||
<input type="number" id="spYellowMax" min="0" value="${b.yellowMax}">
|
||||
</div>
|
||||
<div class="scoring-band-range-row">
|
||||
<span class="band-badge band-red">Red</span>
|
||||
<input type="number" id="spRedMin" min="0" value="${b.redMin}">
|
||||
<span class="scoring-band-max-placeholder">∞</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spBandPreview" class="scoring-band-labels"></div>`;
|
||||
}
|
||||
|
||||
function renderScoringProfilesSection(profiles, questionnaires) {
|
||||
const panel = document.getElementById('scoringProfilesPanel');
|
||||
if (!panel) return;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="scoring-profiles-section">
|
||||
<div class="card scoring-profiles-card">
|
||||
<div class="scoring-profile-header">
|
||||
<div>
|
||||
<h3 class="scoring-section-title">Scoring profiles</h3>
|
||||
<p class="scoring-section-desc">
|
||||
Weighted multi-questionnaire scores with Green / Yellow / Red bands for insights.
|
||||
</p>
|
||||
</div>
|
||||
${canEdit() ? '<button type="button" class="btn btn-primary btn-sm" id="newScoringProfileBtn">+ New profile</button>' : ''}
|
||||
</div>
|
||||
<div id="scoringProfilesList" class="scoring-profiles-list"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const list = panel.querySelector('#scoringProfilesList');
|
||||
if (!profiles.length) {
|
||||
list.innerHTML = '<p class="scoring-empty-hint">No scoring profiles yet. Create one to combine questionnaire scores for insights.</p>';
|
||||
} else {
|
||||
list.innerHTML = profiles.map(p => scoringProfileCardHTML(p, questionnaires)).join('');
|
||||
}
|
||||
|
||||
if (canEdit()) {
|
||||
panel.querySelector('#newScoringProfileBtn')?.addEventListener('click', () => {
|
||||
openScoringProfileModal(null, questionnaires, profiles);
|
||||
});
|
||||
list.querySelectorAll('.edit-profile-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const profile = profiles.find(x => x.profileID === btn.dataset.id);
|
||||
openScoringProfileModal(profile, questionnaires, profiles);
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.delete-profile-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete scoring profile "${btn.dataset.name}"?`)) return;
|
||||
try {
|
||||
await apiDelete('scoring_profiles.php', { profileID: btn.dataset.id });
|
||||
showToast('Profile deleted', 'success');
|
||||
await questionnairesPage();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scoringProfileCardHTML(profile, questionnaires) {
|
||||
const qnById = Object.fromEntries((questionnaires || []).map(q => [q.questionnaireID, q]));
|
||||
const members = profile.questionnaires || [];
|
||||
const activeBadge = profile.isActive
|
||||
? '<span class="badge badge-active">Active</span>'
|
||||
: '<span class="badge badge-draft">Inactive</span>';
|
||||
const memberRows = members.map(m => {
|
||||
const q = qnById[m.questionnaireID];
|
||||
const name = q?.name || m.questionnaireID;
|
||||
return `<div class="scoring-profile-q-row"><span class="scoring-profile-q-name">${esc(name)}</span><span class="scoring-profile-q-weight">× ${m.weight}</span></div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="scoring-profile-card">
|
||||
<div class="scoring-profile-card-top">
|
||||
<div class="scoring-profile-card-main">
|
||||
<div class="scoring-profile-name-row">
|
||||
<strong>${esc(profile.name)}</strong>
|
||||
${activeBadge}
|
||||
</div>
|
||||
${profile.description ? `<p class="scoring-profile-desc">${esc(profile.description)}</p>` : ''}
|
||||
<p class="scoring-profile-bands">${bandSummary(profile)}</p>
|
||||
</div>
|
||||
${canEdit() ? `
|
||||
<div class="scoring-profile-card-actions">
|
||||
<button type="button" class="btn btn-sm edit-profile-btn" data-id="${esc(profile.profileID)}">Edit</button>
|
||||
<button type="button" class="btn btn-sm btn-danger delete-profile-btn"
|
||||
data-id="${esc(profile.profileID)}" data-name="${esc(profile.name)}">Delete</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<p class="scoring-profile-member-count">${members.length} questionnaire(s)</p>
|
||||
<div class="scoring-profile-members">${memberRows || '<p class="scoring-empty-hint">No questionnaires linked</p>'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openScoringProfileModal(profile, questionnaires, allProfiles) {
|
||||
const isEdit = !!profile;
|
||||
const members = profile?.questionnaires?.length
|
||||
? [...profile.questionnaires]
|
||||
: [];
|
||||
const selectedIds = new Set(members.map(m => m.questionnaireID));
|
||||
const initialBands = normalizeProfileBands(profile || {});
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-dialog modal-dialog-wide">
|
||||
<h2>${isEdit ? 'Edit scoring profile' : 'New scoring profile'}</h2>
|
||||
<p class="modal-subtitle">Combine questionnaire totals with weights, then map the sum to Green / Yellow / Red bands.</p>
|
||||
<div class="form-group">
|
||||
<label for="spName">Name</label>
|
||||
<input type="text" id="spName" value="${esc(profile?.name || '')}" placeholder="e.g. RHS Index">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="spDesc">Description</label>
|
||||
<textarea id="spDesc" rows="2" placeholder="Optional note for coaches and admins">${esc(profile?.description || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group scoring-profile-active-row">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="spActive" ${profile?.isActive !== 0 ? 'checked' : ''}>
|
||||
Active (shown in insights)
|
||||
</label>
|
||||
</div>
|
||||
${bandRangesEditorHTML(initialBands)}
|
||||
<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>
|
||||
<div id="spQnPicker" class="scoring-profile-picker"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-sm" id="spCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="spSave">${isEdit ? 'Save' : 'Create'}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
const picker = overlay.querySelector('#spQnPicker');
|
||||
const sortedQn = [...(questionnaires || [])].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
|
||||
|
||||
function renderPicker() {
|
||||
const ordered = members.length
|
||||
? members.map(m => ({ ...m, q: sortedQn.find(q => q.questionnaireID === m.questionnaireID) }))
|
||||
: [];
|
||||
const available = sortedQn.filter(q => !selectedIds.has(q.questionnaireID));
|
||||
|
||||
picker.innerHTML = `
|
||||
${ordered.map((m, idx) => `
|
||||
<div class="scoring-profile-q-row" data-qn="${esc(m.questionnaireID)}">
|
||||
<span class="scoring-profile-q-name">${esc(m.q?.name || m.questionnaireID)}</span>
|
||||
<span class="scoring-profile-q-actions">
|
||||
<label class="sp-weight-wrap">Weight
|
||||
<input type="number" class="sp-weight" step="0.1" min="0.1"
|
||||
value="${m.weight ?? 1}">
|
||||
</label>
|
||||
<button type="button" class="btn btn-sm sp-move-up" ${idx === 0 ? 'disabled' : ''} title="Move up">↑</button>
|
||||
<button type="button" class="btn btn-sm sp-move-down" ${idx === ordered.length - 1 ? 'disabled' : ''} title="Move down">↓</button>
|
||||
<button type="button" class="btn btn-sm btn-danger sp-remove" title="Remove">Remove</button>
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${available.length ? `
|
||||
<div class="scoring-profile-add-row">
|
||||
<select id="spAddSelect">
|
||||
<option value="">Add questionnaire…</option>
|
||||
${available.map(q => `<option value="${esc(q.questionnaireID)}">${esc(q.name)}</option>`).join('')}
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm" id="spAddBtn">Add</button>
|
||||
</div>` : ''}`;
|
||||
|
||||
picker.querySelectorAll('.sp-weight').forEach((input, idx) => {
|
||||
input.addEventListener('change', () => {
|
||||
members[idx].weight = parseFloat(input.value) || 1;
|
||||
});
|
||||
});
|
||||
picker.querySelectorAll('.sp-remove').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('.scoring-profile-q-row');
|
||||
const id = row?.dataset.qn;
|
||||
if (!id) return;
|
||||
selectedIds.delete(id);
|
||||
const i = members.findIndex(m => m.questionnaireID === id);
|
||||
if (i >= 0) members.splice(i, 1);
|
||||
renderPicker();
|
||||
});
|
||||
});
|
||||
picker.querySelectorAll('.sp-move-up').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('.scoring-profile-q-row');
|
||||
const id = row?.dataset.qn;
|
||||
const i = members.findIndex(m => m.questionnaireID === id);
|
||||
if (i > 0) {
|
||||
[members[i - 1], members[i]] = [members[i], members[i - 1]];
|
||||
renderPicker();
|
||||
}
|
||||
});
|
||||
});
|
||||
picker.querySelectorAll('.sp-move-down').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const row = btn.closest('.scoring-profile-q-row');
|
||||
const id = row?.dataset.qn;
|
||||
const i = members.findIndex(m => m.questionnaireID === id);
|
||||
if (i >= 0 && i < members.length - 1) {
|
||||
[members[i], members[i + 1]] = [members[i + 1], members[i]];
|
||||
renderPicker();
|
||||
}
|
||||
});
|
||||
});
|
||||
overlay.querySelector('#spAddBtn')?.addEventListener('click', () => {
|
||||
const sel = overlay.querySelector('#spAddSelect');
|
||||
const id = sel?.value;
|
||||
if (!id || selectedIds.has(id)) return;
|
||||
selectedIds.add(id);
|
||||
members.push({ questionnaireID: id, weight: 1, orderIndex: members.length });
|
||||
renderPicker();
|
||||
});
|
||||
}
|
||||
|
||||
function updateBandPreview() {
|
||||
const el = overlay.querySelector('#spBandPreview');
|
||||
if (!el) return;
|
||||
const b = readBandsFromModal(overlay);
|
||||
const err = validateBands(b);
|
||||
if (err) {
|
||||
el.innerHTML = `<p class="error-text" style="margin:0">${esc(err)}</p>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = bandPreviewHTML(b);
|
||||
}
|
||||
|
||||
renderPicker();
|
||||
updateBandPreview();
|
||||
['#spGreenMin', '#spGreenMax', '#spYellowMin', '#spYellowMax', '#spRedMin'].forEach(sel => {
|
||||
overlay.querySelector(sel)?.addEventListener('input', updateBandPreview);
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
overlay.remove();
|
||||
document.body.classList.remove('modal-open');
|
||||
};
|
||||
overlay.querySelector('#spCancel')?.addEventListener('click', close);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
overlay.querySelector('#spSave')?.addEventListener('click', async () => {
|
||||
const name = overlay.querySelector('#spName')?.value.trim();
|
||||
const description = overlay.querySelector('#spDesc')?.value.trim() || '';
|
||||
const isActive = overlay.querySelector('#spActive')?.checked;
|
||||
const bands = readBandsFromModal(overlay);
|
||||
const bandErr = validateBands(bands);
|
||||
if (!name) {
|
||||
showToast('Profile name is required', 'error');
|
||||
return;
|
||||
}
|
||||
if (bandErr) {
|
||||
showToast(bandErr, 'error');
|
||||
return;
|
||||
}
|
||||
if (!members.length) {
|
||||
showToast('Add at least one questionnaire', 'error');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name,
|
||||
description,
|
||||
isActive,
|
||||
...bands,
|
||||
questionnaires: members.map((m, idx) => ({
|
||||
questionnaireID: m.questionnaireID,
|
||||
weight: parseFloat(picker.querySelector(`[data-qn="${m.questionnaireID}"] .sp-weight`)?.value) || m.weight || 1,
|
||||
orderIndex: idx,
|
||||
})),
|
||||
};
|
||||
if (isEdit) payload.profileID = profile.profileID;
|
||||
|
||||
const saveBtn = overlay.querySelector('#spSave');
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiPut('scoring_profiles.php', payload);
|
||||
} else {
|
||||
await apiPost('scoring_profiles.php', payload);
|
||||
}
|
||||
showToast(isEdit ? 'Profile saved' : 'Profile created', 'success');
|
||||
close();
|
||||
await questionnairesPage();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindImportBundle() {
|
||||
const fileInput = document.getElementById('importBundleFile');
|
||||
const btn = document.getElementById('importBundleBtn');
|
||||
|
||||
Reference in New Issue
Block a user