added website overwrite for score decision
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
@ -92,8 +92,10 @@
|
|||||||
"review_scores_calculated_category",
|
"review_scores_calculated_category",
|
||||||
"review_scores_coach_category",
|
"review_scores_coach_category",
|
||||||
"review_scores_empty",
|
"review_scores_empty",
|
||||||
|
"review_scores_pending_upload",
|
||||||
"review_scores_save_failed",
|
"review_scores_save_failed",
|
||||||
"review_scores_saved",
|
"review_scores_saved",
|
||||||
|
"review_scores_saved_pending",
|
||||||
"review_scores_set_category",
|
"review_scores_set_category",
|
||||||
"review_scores_subtitle",
|
"review_scores_subtitle",
|
||||||
"review_scores_total",
|
"review_scores_total",
|
||||||
@ -108,9 +110,14 @@
|
|||||||
"start_upload",
|
"start_upload",
|
||||||
"upload",
|
"upload",
|
||||||
"upload_failed_title",
|
"upload_failed_title",
|
||||||
|
"upload_item_scoring_review",
|
||||||
"upload_nothing_pending",
|
"upload_nothing_pending",
|
||||||
"upload_prepare_failed",
|
"upload_prepare_failed",
|
||||||
|
"upload_scoring_review_summary",
|
||||||
|
"upload_summary",
|
||||||
"upload_success_message",
|
"upload_success_message",
|
||||||
|
"upload_success_questionnaires",
|
||||||
|
"upload_success_scoring_reviews",
|
||||||
"username_hint",
|
"username_hint",
|
||||||
"year"
|
"year"
|
||||||
],
|
],
|
||||||
@ -205,8 +212,10 @@
|
|||||||
"review_scores_calculated_category": "Berechnete Kategorie",
|
"review_scores_calculated_category": "Berechnete Kategorie",
|
||||||
"review_scores_coach_category": "Coach-Kategorie",
|
"review_scores_coach_category": "Coach-Kategorie",
|
||||||
"review_scores_empty": "Noch keine vollständigen Profilwerte zum Prüfen.",
|
"review_scores_empty": "Noch keine vollständigen Profilwerte zum Prüfen.",
|
||||||
|
"review_scores_pending_upload": "Ausstehend — wird mit dem Upload gesendet",
|
||||||
"review_scores_save_failed": "Kategorie konnte nicht gespeichert werden.",
|
"review_scores_save_failed": "Kategorie konnte nicht gespeichert werden.",
|
||||||
"review_scores_saved": "Kategorie gespeichert.",
|
"review_scores_saved": "Kategorie gespeichert.",
|
||||||
|
"review_scores_saved_pending": "Kategorie gespeichert — wird beim Upload gesendet.",
|
||||||
"review_scores_set_category": "Kategorie setzen",
|
"review_scores_set_category": "Kategorie setzen",
|
||||||
"review_scores_subtitle": "Prüfen Sie die berechneten Werte und bestätigen oder setzen Sie die Kategorie.",
|
"review_scores_subtitle": "Prüfen Sie die berechneten Werte und bestätigen oder setzen Sie die Kategorie.",
|
||||||
"review_scores_total": "Gewichtete Summe",
|
"review_scores_total": "Gewichtete Summe",
|
||||||
@ -221,9 +230,14 @@
|
|||||||
"start_upload": "Upload starten?",
|
"start_upload": "Upload starten?",
|
||||||
"upload": "Hochladen",
|
"upload": "Hochladen",
|
||||||
"upload_failed_title": "Upload fehlgeschlagen",
|
"upload_failed_title": "Upload fehlgeschlagen",
|
||||||
|
"upload_item_scoring_review": "Punkteprüfung: {profile}",
|
||||||
"upload_nothing_pending": "Es wurde nichts hochgeladen. Es liegen keine abgeschlossenen Fragebögen zum Upload vor. Bitte zuerst einen Fragebogen abschließen.",
|
"upload_nothing_pending": "Es wurde nichts hochgeladen. Es liegen keine abgeschlossenen Fragebögen zum Upload vor. Bitte zuerst einen Fragebogen abschließen.",
|
||||||
"upload_prepare_failed": "Es wurde nichts hochgeladen. Die Daten konnten nicht für den Upload vorbereitet werden. Bitte erneut synchronisieren oder den Support kontaktieren.",
|
"upload_prepare_failed": "Es wurde nichts hochgeladen. Die Daten konnten nicht für den Upload vorbereitet werden. Bitte erneut synchronisieren oder den Support kontaktieren.",
|
||||||
|
"upload_scoring_review_summary": "Coach: {band} · Summe {total}",
|
||||||
|
"upload_summary": "{qn} Fragebögen · {scores} Punkteprüfungen · {clients} Klienten",
|
||||||
"upload_success_message": "Hochladen: {count} erledigt.",
|
"upload_success_message": "Hochladen: {count} erledigt.",
|
||||||
|
"upload_success_questionnaires": "{count} Fragebogen/Fragebögen",
|
||||||
|
"upload_success_scoring_reviews": "{count} Punkteprüfung(en)",
|
||||||
"username_hint": "Benutzername",
|
"username_hint": "Benutzername",
|
||||||
"year": "Jahr",
|
"year": "Jahr",
|
||||||
"questionnaire_group_demographics": "Demografie test",
|
"questionnaire_group_demographics": "Demografie test",
|
||||||
|
|||||||
@ -98,6 +98,61 @@ case 'POST':
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
$body = read_json_body();
|
||||||
|
$clientCode = trim((string)($body['clientCode'] ?? ''));
|
||||||
|
$profileID = trim((string)($body['profileID'] ?? ''));
|
||||||
|
$coachBand = trim((string)($body['coachBand'] ?? ''));
|
||||||
|
|
||||||
|
if ($clientCode === '' || $profileID === '' || $coachBand === '') {
|
||||||
|
json_error('MISSING_FIELDS', 'clientCode, profileID, and coachBand are required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$calculatedBand = trim((string)($body['calculatedBand'] ?? ''));
|
||||||
|
$weightedTotal = isset($body['weightedTotal']) ? (float)$body['weightedTotal'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||||
|
|
||||||
|
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
|
||||||
|
$chk = $pdo->prepare(
|
||||||
|
"SELECT clientCode FROM client cl WHERE cl.clientCode = :cc AND ($clause)"
|
||||||
|
);
|
||||||
|
$chk->execute(array_merge([':cc' => $clientCode], $params));
|
||||||
|
if (!$chk->fetch()) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/scoring.php';
|
||||||
|
if (!qdb_is_valid_scoring_band($coachBand)) {
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
json_error('INVALID_FIELD', 'coachBand must be green, yellow, or red', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile = qdb_save_coach_scoring_review(
|
||||||
|
$pdo,
|
||||||
|
$clientCode,
|
||||||
|
$profileID,
|
||||||
|
$coachBand,
|
||||||
|
(string)($tokenRec['userID'] ?? ''),
|
||||||
|
$weightedTotal,
|
||||||
|
$calculatedBand !== '' ? $calculatedBand : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
json_success(['profile' => $profile]);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
qdb_discard($tmpDb ?? null, $lockFp ?? null);
|
||||||
|
json_error('INVALID_FIELD', $e->getMessage(), 400);
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
qdb_discard($tmpDb ?? null, $lockFp ?? null);
|
||||||
|
json_error('NOT_FOUND', $e->getMessage(), 404);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'DELETE':
|
case 'DELETE':
|
||||||
$body = read_json_body();
|
$body = read_json_body();
|
||||||
$clientCode = trim($body['clientCode'] ?? '');
|
$clientCode = trim($body['clientCode'] ?? '');
|
||||||
|
|||||||
@ -3034,6 +3034,20 @@ select:disabled {
|
|||||||
letter-spacing: .04em;
|
letter-spacing: .04em;
|
||||||
color: #ca8a04;
|
color: #ca8a04;
|
||||||
}
|
}
|
||||||
|
.client-coach-band-select {
|
||||||
|
min-width: 6.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: .75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface, #fff);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.client-coach-band-select:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
.client-profile-score-hint,
|
.client-profile-score-hint,
|
||||||
.client-profile-score-meta {
|
.client-profile-score-meta {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { apiGet, apiPost, apiDelete } from '../api.js';
|
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
|
||||||
import { pageHeaderHTML, showToast } from '../app.js';
|
import { pageHeaderHTML, showToast } from '../app.js';
|
||||||
import { isDevTestClientCode, testDataRowClassForClient } from '../test-data.js';
|
import { isDevTestClientCode, testDataRowClassForClient } from '../test-data.js';
|
||||||
|
|
||||||
@ -167,6 +167,12 @@ function renderClientTableBody() {
|
|||||||
body.querySelectorAll('.delete-client-btn').forEach(btn => {
|
body.querySelectorAll('.delete-client-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => deleteClient(btn.dataset.code));
|
btn.addEventListener('click', () => deleteClient(btn.dataset.code));
|
||||||
});
|
});
|
||||||
|
body.querySelectorAll('.client-coach-band-select').forEach(select => {
|
||||||
|
select.addEventListener('focus', () => {
|
||||||
|
select.dataset.prev = select.value;
|
||||||
|
});
|
||||||
|
select.addEventListener('change', () => onCoachBandChange(select));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const pag = document.getElementById('clientsPagination');
|
const pag = document.getElementById('clientsPagination');
|
||||||
@ -251,7 +257,7 @@ function clientDetailPanelHTML(clientCode) {
|
|||||||
Coach: ${esc(cl.coachUsername || '—')}
|
Coach: ${esc(cl.coachUsername || '—')}
|
||||||
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
|
${cl.supervisorUsername ? ` · Supervisor: ${esc(cl.supervisorUsername)}` : ''}
|
||||||
</p>
|
</p>
|
||||||
${renderClientScoringProfiles(detail.scoringProfiles || [])}
|
${renderClientScoringProfiles(detail.scoringProfiles || [], clientCode)}
|
||||||
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`;
|
${questionnaires.map(q => clientQnBlockHTML(clientCode, q)).join('')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,7 +302,39 @@ function bandBadgeHTML(band, { pending = false, incomplete = false, label = '' }
|
|||||||
return `<span class="band-badge band-${esc(band)}">${esc(text || band)}</span>`;
|
return `<span class="band-badge band-${esc(band)}">${esc(text || band)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function profileScoreBlockHTML(p) {
|
const COACH_BANDS = ['green', 'yellow', 'red'];
|
||||||
|
|
||||||
|
function bandLabel(band) {
|
||||||
|
if (!band) return '—';
|
||||||
|
return band.charAt(0).toUpperCase() + band.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coachBandSelectHTML(p, clientCode) {
|
||||||
|
const calcBand = p.calculatedBand || p.band;
|
||||||
|
if (!calcBand || !clientCode || !p.profileID) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const current = p.coachBand || '';
|
||||||
|
const coachPending = p.pendingReview !== false && !current;
|
||||||
|
const placeholder = coachPending
|
||||||
|
? '<option value="" selected disabled>Awaiting review</option>'
|
||||||
|
: '';
|
||||||
|
const options = COACH_BANDS.map(b => {
|
||||||
|
const selected = current === b ? ' selected' : '';
|
||||||
|
return `<option value="${esc(b)}"${selected}>${esc(bandLabel(b))}</option>`;
|
||||||
|
}).join('');
|
||||||
|
return `
|
||||||
|
<select class="client-coach-band-select"
|
||||||
|
data-client="${esc(clientCode)}"
|
||||||
|
data-profile="${esc(p.profileID)}"
|
||||||
|
data-profile-name="${esc(p.name || p.profileID)}"
|
||||||
|
data-calculated="${esc(calcBand)}"
|
||||||
|
data-weighted="${esc(String(p.weightedTotal ?? ''))}"
|
||||||
|
data-prev="${esc(current)}"
|
||||||
|
title="Set coach category">${placeholder}${options}</select>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileScoreBlockHTML(p, clientCode = '') {
|
||||||
const calcBand = p.calculatedBand || p.band;
|
const calcBand = p.calculatedBand || p.band;
|
||||||
if (!calcBand) {
|
if (!calcBand) {
|
||||||
return `
|
return `
|
||||||
@ -307,8 +345,11 @@ function profileScoreBlockHTML(p) {
|
|||||||
}
|
}
|
||||||
const coachPending = p.pendingReview !== false && !p.coachBand;
|
const coachPending = p.pendingReview !== false && !p.coachBand;
|
||||||
const coachDiffers = p.coachBand && p.coachBand !== calcBand;
|
const coachDiffers = p.coachBand && p.coachBand !== calcBand;
|
||||||
|
const coachControl = clientCode
|
||||||
|
? coachBandSelectHTML(p, clientCode)
|
||||||
|
: (coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand));
|
||||||
return `
|
return `
|
||||||
<div class="client-profile-score-block${coachDiffers ? ' has-coach-override' : ''}">
|
<div class="client-profile-score-block${coachDiffers ? ' has-coach-override' : ''}" data-profile-id="${esc(p.profileID || '')}">
|
||||||
<div class="client-profile-score-block-title">${esc(p.name)}</div>
|
<div class="client-profile-score-block-title">${esc(p.name)}</div>
|
||||||
<div class="client-profile-score-row">
|
<div class="client-profile-score-row">
|
||||||
<span class="client-profile-score-label">Calculated</span>
|
<span class="client-profile-score-label">Calculated</span>
|
||||||
@ -317,13 +358,13 @@ function profileScoreBlockHTML(p) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="client-profile-score-row">
|
<div class="client-profile-score-row">
|
||||||
<span class="client-profile-score-label">Coach</span>
|
<span class="client-profile-score-label">Coach</span>
|
||||||
${coachPending ? bandBadgeHTML(null, { pending: true }) : bandBadgeHTML(p.coachBand)}
|
${coachControl}
|
||||||
${coachDiffers ? '<span class="client-profile-override-hint">override</span>' : ''}
|
${coachDiffers ? '<span class="client-profile-override-hint">override</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderClientScoringProfiles(profiles) {
|
function renderClientScoringProfiles(profiles, clientCode = '') {
|
||||||
if (!profiles.length) return '';
|
if (!profiles.length) return '';
|
||||||
return `
|
return `
|
||||||
<div class="client-scoring-profiles-detail">
|
<div class="client-scoring-profiles-detail">
|
||||||
@ -333,14 +374,14 @@ function renderClientScoringProfiles(profiles) {
|
|||||||
</p>
|
</p>
|
||||||
<div class="client-profile-scores">${profiles.map(p => {
|
<div class="client-profile-scores">${profiles.map(p => {
|
||||||
const calc = p.calculatedBand || p.band;
|
const calc = p.calculatedBand || p.band;
|
||||||
if (!calc) return profileScoreBlockHTML(p);
|
if (!calc) return profileScoreBlockHTML(p, clientCode);
|
||||||
const coachPending = p.pendingReview !== false && !p.coachBand;
|
const coachPending = p.pendingReview !== false && !p.coachBand;
|
||||||
const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : '';
|
const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : '';
|
||||||
const reviewedAt = p.coachReviewedAt ? `Coach reviewed ${esc(p.coachReviewedAt)}` : '';
|
const reviewedAt = p.coachReviewedAt ? `Coach reviewed ${esc(p.coachReviewedAt)}` : '';
|
||||||
const meta = [computedAt, reviewedAt].filter(Boolean).join(' · ');
|
const meta = [computedAt, reviewedAt].filter(Boolean).join(' · ');
|
||||||
return `
|
return `
|
||||||
<div class="scoring-profile-detail-card">
|
<div class="scoring-profile-detail-card">
|
||||||
${profileScoreBlockHTML(p)}
|
${profileScoreBlockHTML(p, clientCode)}
|
||||||
${meta ? `<p class="client-profile-score-meta">${meta}</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>' : ''}
|
${coachPending ? '<p class="client-profile-score-hint">Coach has not reviewed this profile in the app yet.</p>' : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
@ -413,7 +454,7 @@ function clientQnBlockHTML(clientCode, q) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function profileDotsHTML(profiles) {
|
function profileDotsHTML(profiles, clientCode) {
|
||||||
if (!profiles?.length) {
|
if (!profiles?.length) {
|
||||||
return '<span class="client-profile-dots-empty">—</span>';
|
return '<span class="client-profile-dots-empty">—</span>';
|
||||||
}
|
}
|
||||||
@ -421,7 +462,71 @@ function profileDotsHTML(profiles) {
|
|||||||
if (!hasAny) {
|
if (!hasAny) {
|
||||||
return `<span class="client-profile-dots-empty" title="No complete scoring profiles yet">—</span>`;
|
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>`;
|
return `<div class="client-profile-scores">${profiles.map(p => profileScoreBlockHTML(p, clientCode)).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchClientScoringBand(clientCode, profileID, coachBand) {
|
||||||
|
const client = clientsList.find(c => c.clientCode === clientCode);
|
||||||
|
if (client?.scoringProfiles) {
|
||||||
|
const profile = client.scoringProfiles.find(p => p.profileID === profileID);
|
||||||
|
if (profile) {
|
||||||
|
profile.coachBand = coachBand;
|
||||||
|
profile.pendingReview = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const detail = detailByClient[clientCode];
|
||||||
|
if (detail?.scoringProfiles) {
|
||||||
|
const profile = detail.scoringProfiles.find(p => p.profileID === profileID);
|
||||||
|
if (profile) {
|
||||||
|
profile.coachBand = coachBand;
|
||||||
|
profile.pendingReview = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCoachBandChange(select) {
|
||||||
|
const clientCode = select.dataset.client;
|
||||||
|
const profileID = select.dataset.profile;
|
||||||
|
const profileName = select.dataset.profileName || profileID;
|
||||||
|
const calculatedBand = select.dataset.calculated;
|
||||||
|
const weightedRaw = select.dataset.weighted;
|
||||||
|
const weightedTotal = weightedRaw !== '' ? Number(weightedRaw) : null;
|
||||||
|
const prev = select.dataset.prev || '';
|
||||||
|
const coachBand = select.value;
|
||||||
|
|
||||||
|
if (!coachBand) {
|
||||||
|
select.value = prev;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (coachBand === prev) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = `Set coach category to "${bandLabel(coachBand)}" for ${profileName} (${clientCode})?\n\nCalculated category: ${bandLabel(calculatedBand)}.`;
|
||||||
|
if (!confirm(msg)) {
|
||||||
|
select.value = prev || '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.disabled = true;
|
||||||
|
try {
|
||||||
|
await apiPut('clients.php', {
|
||||||
|
clientCode,
|
||||||
|
profileID,
|
||||||
|
coachBand,
|
||||||
|
calculatedBand,
|
||||||
|
weightedTotal,
|
||||||
|
});
|
||||||
|
patchClientScoringBand(clientCode, profileID, coachBand);
|
||||||
|
select.dataset.prev = coachBand;
|
||||||
|
showToast(`Coach category saved for ${profileName}`, 'success');
|
||||||
|
renderClientTableBody();
|
||||||
|
} catch (e) {
|
||||||
|
select.value = prev || '';
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
select.disabled = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clientHasResponseData(c) {
|
function clientHasResponseData(c) {
|
||||||
@ -438,7 +543,7 @@ function clientRowHTML(c) {
|
|||||||
? `<button type="button" class="btn btn-sm btn-link client-expand-btn" data-code="${esc(c.clientCode)}">${expanded ? '▼' : '▶'}</button> `
|
? `<button type="button" class="btn btn-sm btn-link client-expand-btn" data-code="${esc(c.clientCode)}">${expanded ? '▼' : '▶'}</button> `
|
||||||
: '';
|
: '';
|
||||||
const profileCol = scoringProfileCatalog.length > 0
|
const profileCol = scoringProfileCatalog.length > 0
|
||||||
? `<td class="client-profile-dots-cell">${profileDotsHTML(c.scoringProfiles || [])}</td>`
|
? `<td class="client-profile-dots-cell">${profileDotsHTML(c.scoringProfiles || [], c.clientCode)}</td>`
|
||||||
: '';
|
: '';
|
||||||
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
|
const colCount = scoringProfileCatalog.length > 0 ? 4 : 3;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user